iOS Audio hand by hand: 變聲,混響,語音合成 TTS,Swift5,基于 AVAudioEngine 等
AVAudioEngine 比 AVAudioPlayer 更加強大,當然使用上比起 AVAudioPlayer 繁瑣。
AVAudioEngine 對于 Core Audio 作了一些使用上的封裝簡化,簡便的做了一些音頻信號的處理。
使用 AVAudioPlayer ,是音頻文件級別的處理。
使用 AVAudioEngine,是音頻數(shù)據(jù)流級別的處理。
AVAudioEngine 可以做到低時延的、實時音頻處理。還可以做到音頻的多輸入,添加特殊的效果,例如三維空間音效
AVAudioEngine 可以做出強大的音樂處理與混音 app, 配合制作復雜的三維空間音效的游戲,本文來一個簡單的變聲應用
通用架構圖,場景是 K 歌

AVAudioEngine 使用指南
首先,簡單理解下

來一個 AVAudioEngine 實例,然后添加節(jié)點 Node, 有播放器的 Player Node, 音效的 Effect Node.
將節(jié)點連在音頻引擎上,即 AVAudioEngine 實例。然后建立節(jié)點間的關聯(lián),組成一條音頻的數(shù)據(jù)處理鏈。
處理后的音頻數(shù)據(jù),流過最后的一個節(jié)點,就是音頻引擎的輸出了。
開始做一個變聲的功能,也就是音調(diào)變化
需要用到 AVAudioEngine 和 AVAudioPlayerNode
// 音頻引擎是樞紐
var audioAVEngine = AVAudioEngine()
// 播放節(jié)點
var enginePlayer = AVAudioPlayerNode()
// 變聲單元:調(diào)節(jié)音高
let pitchEffect = AVAudioUnitTimePitch()
// 混響單元
let reverbEffect = AVAudioUnitReverb()
// 調(diào)節(jié)音頻播放速度單元
let rateEffect = AVAudioUnitVarispeed()
// 調(diào)節(jié)音量單元
let volumeEffect = AVAudioUnitEQ()
// 音頻輸入文件
var engineAudioFile: AVAudioFile!
做一些設置
先取得輸入節(jié)點的 AVAudioFormat 引用,
這是音頻流數(shù)據(jù)的默認描述文件,包含通道數(shù)、采樣率等信息。
實際上,AVAudioFormat 就是對 Core Audio 的音頻緩沖數(shù)據(jù)格式文件 AudioStreamBasicDescription, 做了一些封裝。
audioAVEngine 做子節(jié)點關聯(lián)的時候,要用到。
// 做一些配置,功能初始化
func setupAudioEngine() {
// 這個例子,是單音
let format = audioAVEngine.inputNode.inputFormat(forBus: 0)
// 添加功能
audioAVEngine.attach(enginePlayer)
audioAVEngine.attach(pitchEffect)
audioAVEngine.attach(reverbEffect)
audioAVEngine.attach(rateEffect)
audioAVEngine.attach(volumeEffect)
// 連接功能
audioAVEngine.connect(enginePlayer, to: pitchEffect, format: format)
audioAVEngine.connect(pitchEffect, to: reverbEffect, format: format)
audioAVEngine.connect(reverbEffect, to: rateEffect, format: format)
audioAVEngine.connect(rateEffect, to: volumeEffect, format: format)
audioAVEngine.connect(volumeEffect, to: audioAVEngine.mainMixerNode, format: format)
// 選擇混響效果為大房間
reverbEffect.loadFactoryPreset(AVAudioUnitReverbPreset.largeChamber)
do {
// 可以先開啟引擎
try audioAVEngine.start()
} catch {
print("Error starting AVAudioEngine.")
}
}
播放
func play(){
let fileURL = getURLforMemo()
var playFlag = true
do {
// 先拿 URL 初始化 AVAudioFile
// AVAudioFile 加載音頻數(shù)據(jù),形成數(shù)據(jù)緩沖區(qū),方便 AVAudioEngine 使用
engineAudioFile = try AVAudioFile(forReading: fileURL)
// 變聲效果,先給一個音高的默認值
// 看效果,來點尖利的
pitchEffect.pitch = 2400
reverbEffect.wetDryMix = UserSetting.shared.reverb
rateEffect.rate = UserSetting.shared.rate
volumeEffect.globalGain = UserSetting.shared.volume
} catch {
engineAudioFile = nil
playFlag = false
print("Error loading AVAudioFile.")
}
// AVAudioPlayer 主要是音量大小的檢測,這里做了一些取巧
// 就是為了制作上篇播客介紹的,企鵝張嘴的動畫效果
do {
audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
audioPlayer.delegate = self
if audioPlayer.duration > 0.0 {
// 不靠他播放,要靜音
// audioPlayer 不是用于播放音頻的,所以他的音量設置為 0
audioPlayer.volume = 0.0
audioPlayer.isMeteringEnabled = true
audioPlayer.prepareToPlay()
} else {
playFlag = false
}
} catch {
audioPlayer = nil
engineAudioFile = nil
playFlag = false
print("Error loading audioPlayer.")
}
// 兩個播放器,要一起播放,前面做了一個 audioPlayer 可用的標記
if playFlag == true {
// enginePlayer,有聲音
// 真正用于播放的 enginePlayer
enginePlayer.scheduleFile(engineAudioFile, at: nil, completionHandler: nil)
enginePlayer.play()
// audioPlayer,沒聲音,用于檢測
audioPlayer.play()
setPlayButtonOn(flag: true)
startUpdateLoop()
audioStatus = .playing
}
}
上面的小技巧: AVAudioPlayerNode + AVAudioPlayer
同時播放 AVAudioPlayerNode (有聲音), AVAudioPlayer (啞巴的,就為了取下數(shù)據(jù)與狀態(tài)), 通過 AVAudioPlayerNode 添加變聲等音效,通過做音量大小檢測。
看起來有些累贅,蘋果自然是不會推薦這樣做的。

如果是錄音,通過 NodeTapBlock 對音頻輸入流的信息,做實時分析。
播放也類似,處理音頻信號,取出平均音量,就可以刷新 UI 了。
通過 AVAudioPlayer ,可以方便拿到當前播放時間,文件播放時長等信息,
通過 AVAudioPlayerDelegate,可以方便播放結束了,去刷新 UI
當然,使用 AVAudioPlayerNode ,這些都是可以做到的
結束播放
func stopPlayback() {
setPlayButtonOn(flag: false)
audioStatus = .stopped
// 兩個播放器,一起結束,一起結束
audioPlayer.stop()
enginePlayer.stop()
stopUpdateLoop()
}
音效: 音高,混響,播放速度,音量大小
調(diào)節(jié)音高,用來變聲, AVAudioUnitTimePitch
音效的 pitch 屬性,取值范圍從 -2400 音分到 2400 音分,包含 4 個八度音階。
默認值為 0
一個八度音程可以分為12個半音。
每一個半音的音程相當于相鄰鋼琴鍵間的音程,等于100音分
func setPitch(value: Float) {
pitchEffect.pitch = value
}
調(diào)節(jié)混響, AVAudioUnitReverb
wetDryMix 的取值范圍是 0 ~ 100,
0 是全干,干聲即無音樂的純?nèi)寺?br>
100 是全濕潤,空間感很強。
干聲是原版,濕聲是經(jīng)過后期處理的。
func toSetReverb(value: Float) {
reverbEffect.wetDryMix = value
}
調(diào)節(jié)音頻播放速度, AVAudioUnitVarispeed
音頻播放速度 rate 的取值范圍是 0.25 ~ 4.0,
默認是 1.0,正常播放。
func toSetRate(value: Float) {
rateEffect.rate = value
}
調(diào)節(jié)音量大小, AVAudioUnitEQ
globalGain 的取值范圍是 -96 ~ 24, 單位是分貝
func toSetVolumn(value: Float){
volumeEffect.globalGain = value
}
語音合成 TTS,輸入文字,播放對應的語音
TTS,一般會用到 AVSpeechSynthesizer 和他的代理 AVSpeechSynthesizerDelegate
AVSpeechSynthesizer 是 AVFoundation 框架下的一個類,它的功能就是輸入文字,讓你的應用,選擇 iOS 平臺支持的語言和方言,然后合成語音,播放出來。
iOS 平臺,支持三種中文,就是三種口音,有中文簡體 zh-CN,Ting-Ting 朗讀;有 zh-HK,Sin-Ji 朗讀;有 zh-TW,Mei-Jia 朗讀。
可參考 How to get a list of ALL voices on iOS
AVSpeechSynthesizer 合成器相關知識
AVSpeechSynthesizer 需要拿材料 AVSpeechUtterance 去朗讀。
語音文本單元 AVSpeechUtterance 封裝了文字,還有對應的朗讀效果參數(shù)。
朗讀效果中,可以設置口音,本文 Demo 采用 zh-CN。還可以設置變聲和語速 (發(fā)音速度)。
拿到 AVSpeechUtterance ,合成器 AVSpeechSynthesizer 就可以朗讀了。如果 AVSpeechSynthesizer 正在朗讀,AVSpeechUtterance 就會放在 AVSpeechSynthesizer 的朗讀隊列里面,按照先進先出的順序等待朗讀。
蘋果框架的粒度都很細,語音合成器 AVSpeechSynthesizer,也有暫定、繼續(xù)播放與結束播放功能。
停止了語音合成器 AVSpeechSynthesizer,如果他的朗讀隊列里面還有語音文本AVSpeechUtterance,剩下的都會直接移除。
AVSpeechSynthesizerDelegate 合成器代理相關
使用合成器代理,可以監(jiān)聽朗讀時候的事件。例如:開始朗讀,朗讀結束
TTS: Text To Speech 三步走
先設置
// 來一個合成器
let synthesizer = AVSpeechSynthesizer()
// ...
// 設置合成器的代理,監(jiān)聽事件
synthesizer.delegate = self
朗讀、暫停、繼續(xù)朗讀與停止朗讀
// 朗讀
func play() {
let words = UserSetting.shared.message
// 拿文本,去實例化語音文本單元
let utterance = AVSpeechUtterance(string: words)
// 設置發(fā)音為簡體中文 ( 中國大陸 )
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
// 設置朗讀的語速
utterance.rate = AVSpeechUtteranceMaximumSpeechRate * UserSetting.shared.rate
// 設置音高
utterance.pitchMultiplier = UserSetting.shared.pitch
synthesizer.speak(utterance)
}
// 暫停朗讀,沒有設置立即暫停,是按字暫停
func pausePlayback() {
synthesizer.pauseSpeaking(at: AVSpeechBoundary.word)
}
// 繼續(xù)朗讀
func continuePlayback() {
synthesizer.continueSpeaking()
}
// 停止播放
func stopPlayback() {
// 讓合成器馬上停止朗讀
synthesizer.stopSpeaking(at: AVSpeechBoundary.immediate)
// 停止計時器更新狀態(tài),具體見文尾的 github repo
stopUpdateLoop()
setPlayButtonOn(false)
audioStatus = .stopped
}
設置合成器代理,監(jiān)聽狀態(tài)改變的時機
// 開始朗讀。朗讀每一個語音文本單元的時候,都會來一下
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
setPlayButtonOn(true)
startUpdateLoop()
audioStatus = .playing
}
// 結束朗讀。每一個語音文本單元結束朗讀的時候,都會來一下
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
stopUpdateLoop()
setPlayButtonOn(false)
audioStatus = .stopped
}
// 語音文本單元里面,每一個字要朗讀的時候,都會來一下
// 讀書應用,朗讀前,可以用這個高光正在讀的詞語
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
let speakingString = utterance.speechString as NSString
let word = speakingString.substring(with: characterRange)
print(word)
}
// 暫定朗讀
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
stopUpdateLoop()
setPlayButtonOn(false)
audioStatus = .paused
}
// 繼續(xù)朗讀
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
setPlayButtonOn(true)
startUpdateLoop()
audioStatus = .playing
}
11 個例子,由淺到深,學習 iOS 動畫
iOS 的動畫框架很成熟,提供必要的信息,譬如動畫的起始位置與終止位置,動畫效果就出來了
動畫的實現(xiàn)方式挺多的,
有系統(tǒng)提供的簡單 API ,直接提供動畫般的交互效果。
有手動設置交互效果,看起來像是動畫,一般要用到插值。
至于動畫框架,有 UIView 級別的,有功能強勁的 CALayer 級別的動畫。
CALayer 級別的動畫通過靈活設置的 CoreAnimation,CoreAnimation 的常規(guī)操作,就是自定義路徑
當然有蘋果推了幾年的 UIViewPropertyAnimator, 動畫可交互性做得比較好
例子一,導航欄動畫

navigationController?.hidesBarsOnSwipe = true
簡單設置 hidesBarsOnSwipe 屬性,就可以了。
該屬性,除了可以調(diào)節(jié)頭部導航欄,還可以調(diào)節(jié)底部標簽工具欄 toolbar
例子二,屏幕開鎖效果

一眼看起來有點炫,實際設置很簡單
func openLock() {
UIView.animate(withDuration: 0.4, delay: 1.0, options: [], animations: {
// Rotate keyhole.
self.lockKeyhole.transform = CGAffineTransform(rotationAngle: CGFloat.pi)
}, completion: { _ in
UIView.animate(withDuration: 0.5, delay: 0.2, options: [], animations: {
// Open lock.
let yDelta = self.lockBorder.frame.maxY
self.topLock.center.y -= yDelta
self.lockKeyhole.center.y -= yDelta
self.lockBorder.center.y -= yDelta
self.bottomLock.center.y += yDelta
}, completion: { _ in
self.topLock.removeFromSuperview()
self.lockKeyhole.removeFromSuperview()
self.lockBorder.removeFromSuperview()
self.bottomLock.removeFromSuperview()
})
})
}
總共有四個控件,先讓中間的鎖控件旋轉(zhuǎn)一下,然后對四個控件,做移位操作
用簡單的關鍵幀動畫,處理要優(yōu)雅一點
例子三,地圖定位波動

看上去有些眼花的動畫,可以分解為三個動畫

一波未平,一波又起,做一個動畫效果的疊加,就成了動畫的第一幅動畫
一個動畫波動效果,效果用到了透明度的變化,范圍的變化
范圍的變化,用的就是 CoreAnimation 的路徑 path
CoreAnimation 簡單設置,就是指明 from 、to,動畫的起始狀態(tài),和動畫終止狀態(tài),然后選擇使用哪一種動畫效果。
動畫的起始狀態(tài),一般是起始位置。簡單的動畫,就是讓他動起來
func sonar(_ beginTime: CFTimeInterval) {
let circlePath1 = UIBezierPath(arcCenter: self.center, radius: CGFloat(3), startAngle: CGFloat(0), endAngle:CGFloat.pi * 2, clockwise: true)
let circlePath2 = UIBezierPath(arcCenter: self.center, radius: CGFloat(80), startAngle: CGFloat(0), endAngle:CGFloat.pi * 2, clockwise: true)
let shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = ColorPalette.green.cgColor
shapeLayer.fillColor = ColorPalette.green.cgColor
shapeLayer.path = circlePath1.cgPath
self.layer.addSublayer(shapeLayer)
// 兩個動畫
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.fromValue = circlePath1.cgPath
pathAnimation.toValue = circlePath2.cgPath
let alphaAnimation = CABasicAnimation(keyPath: "opacity")
alphaAnimation.fromValue = 0.8
alphaAnimation.toValue = 0
// 組動畫
let animationGroup = CAAnimationGroup()
animationGroup.beginTime = beginTime
animationGroup.animations = [pathAnimation, alphaAnimation]
// 時間有講究
animationGroup.duration = 2.76
// 不斷重復
animationGroup.repeatCount = Float.greatestFiniteMagnitude
animationGroup.isRemovedOnCompletion = false
animationGroup.fillMode = CAMediaTimingFillMode.forwards
// Add the animation to the layer.
// key 用來 debug
shapeLayer.add(animationGroup, forKey: "sonar")
}
波動效果調(diào)用了三次
func startAnimation() {
// 三次動畫,效果合成,
sonar(CACurrentMediaTime())
sonar(CACurrentMediaTime() + 0.92)
sonar(CACurrentMediaTime() + 1.84)
}
例子四,加載動畫

這是 UIView 框架自帶的動畫,看起來不錯,就是做了一個簡單的縮放,通過 transform 屬性做仿射變換
func startAnimation() {
dotOne.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
dotTwo.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
dotThree.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
// 三個不同的 delay, 漸進時間
UIView.animate(withDuration: 0.6, delay: 0.0, options: [.repeat, .autoreverse], animations: {
self.dotOne.transform = CGAffineTransform.identity
}, completion: nil)
UIView.animate(withDuration: 0.6, delay: 0.2, options: [.repeat, .autoreverse], animations: {
self.dotTwo.transform = CGAffineTransform.identity
}, completion: nil)
UIView.animate(withDuration: 0.6, delay: 0.4, options: [.repeat, .autoreverse], animations: {
self.dotThree.transform = CGAffineTransform.identity
}, completion: nil)
}
例子五,下劃線點擊轉(zhuǎn)移動畫
這個也是 UIView 的動畫

動畫的實現(xiàn)效果,是通過更改約束。
約束動畫要注意的是,確保動畫的起始位置準確,起始的時候,一般要調(diào)用其父視圖的 layoutIfNeeded 方法,確保視圖的實際位置與約束設置的一致。
這里的約束動畫,是通過 NSLayoutAnchor 做得。
一般我們用的是 SnapKit 設置約束,調(diào)用也差不多。
func animateContraintsForUnderlineView(_ underlineView: UIView, toSide: Side) {
switch toSide {
case .left:
for constraint in underlineView.superview!.constraints {
if constraint.identifier == ConstraintIdentifiers.centerRightConstraintIdentifier {
constraint.isActive = false
let leftButton = optionsBar.arrangedSubviews[0]
let centerLeftConstraint = underlineView.centerXAnchor.constraint(equalTo: leftButton.centerXAnchor)
centerLeftConstraint.identifier = ConstraintIdentifiers.centerLeftConstraintIdentifier
NSLayoutConstraint.activate([centerLeftConstraint])
}
}
case .right:
for constraint in underlineView.superview!.constraints {
if constraint.identifier == ConstraintIdentifiers.centerLeftConstraintIdentifier {
// 先失效,舊的約束
constraint.isActive = false
// 再新建約束,并激活
let rightButton = optionsBar.arrangedSubviews[1]
let centerRightConstraint = underlineView.centerXAnchor.constraint(equalTo: rightButton.centerXAnchor)
centerRightConstraint.identifier = ConstraintIdentifiers.centerRightConstraintIdentifier
NSLayoutConstraint.activate([centerRightConstraint])
}
}
}
UIView.animate(withDuration: 0.6, delay: 0.0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.0, options: [], animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}
例子六,列表視圖的頭部拉伸效果
這個沒有用到動畫框架,就是做了一個交互插值
就是補插連續(xù)的函數(shù) scrollViewDidScroll, 及時更新列表視圖頭部的位置、尺寸
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateHeaderView()
}
func updateHeaderView() {
var headerRect = CGRect(x: 0, y: -tableHeaderHeight, width: tableView.bounds.width, height: tableHeaderHeight)
// 決定拉動的方向
if tableView.contentOffset.y < -tableHeaderHeight {
// 就是改 frame
headerRect.origin.y = tableView.contentOffset.y
headerRect.size.height = -tableView.contentOffset.y
}
headerView.frame = headerRect
}
例子七,進度繪制動畫

用到了 CoreAnimation,也用到了插值。
每一段插值都是一個 CoreAnimation 動畫,進度的完成分為多次插值。
這里動畫效果的主要用到 strokeEnd 屬性, 筆畫結束
插值的時候,要注意,下一段動畫的開始,正是上一段動畫的結束
// 這個用來,主要的效果
let progressLayer = CAShapeLayer()
// 這個用來,附加的顏色
let gradientLayer = CAGradientLayer()
// 給個默認值,外部設置
var range: CGFloat = 128
var curValue: CGFloat = 0 {
didSet {
animateStroke()
}
}
func setupLayers() {
progressLayer.position = CGPoint.zero
progressLayer.lineWidth = 3.0
progressLayer.strokeEnd = 0.0
progressLayer.fillColor = nil
progressLayer.strokeColor = UIColor.black.cgColor
let radius = CGFloat(bounds.height/2) - progressLayer.lineWidth
let startAngle = CGFloat.pi * (-0.5)
let endAngle = CGFloat.pi * 1.5
let width = bounds.width
let height = bounds.height
let modelCenter = CGPoint(x: width / 2, y: height / 2)
let path = UIBezierPath(arcCenter: modelCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
// 指定路徑
progressLayer.path = path.cgPath
layer.addSublayer(progressLayer)
// 有一個漸變
gradientLayer.frame = CGRect(x: 0.0, y: 0.0, width: bounds.width, height: bounds.height)
// teal, 藍綠色
gradientLayer.colors = [ColorPalette.teal.cgColor, ColorPalette.orange.cgColor, ColorPalette.pink.cgColor]
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
gradientLayer.mask = progressLayer // Use progress layer as mask for gradient layer.
layer.addSublayer(gradientLayer)
}
func animateStroke() {
// 前一段的終點
let fromValue = progressLayer.strokeEnd
let toValue = curValue / range
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = fromValue
animation.toValue = toValue
progressLayer.add(animation, forKey: "stroke")
progressLayer.strokeEnd = toValue
}
}
// 動畫路徑,結合插值
例子八,漸變動畫

這個漸變動畫,主要用到了漸變圖層 CAGradientLayer 的 locations 位置屬性,用來調(diào)整漸變區(qū)域的分布
另一個關鍵點是用了圖層 CALayer 的遮罩 mask, 簡單理解,把漸變圖層全部蒙起來,只露出文本的形狀,就是那幾個字母的痕跡
class LoadingLabel: UIView {
let gradientLayer: CAGradientLayer = {
let gradientLayer = CAGradientLayer()
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
// 灰, 白, 灰
let colors = [UIColor.gray.cgColor, UIColor.white.cgColor, UIColor.gray.cgColor]
gradientLayer.colors = colors
let locations = [0.25, 0.5, 0.75]
gradientLayer.locations = locations as [NSNumber]?
return gradientLayer
}()
// 文字轉(zhuǎn)圖片,然后繪制到視圖上
// 通過設置漸變圖層的遮罩 `mask` , 為指定文字,來設置漸變閃爍的效果
@IBInspectable var text: String! {
didSet {
setNeedsDisplay()
UIGraphicsBeginImageContextWithOptions(frame.size, false, 0)
text.draw(in: bounds, withAttributes: textAttributes)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// 從文字中,抽取圖片
let maskLayer = CALayer()
maskLayer.backgroundColor = UIColor.clear.cgColor
maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0)
maskLayer.contents = image?.cgImage
gradientLayer.mask = maskLayer
}
}
// 設置位置與尺寸
override func layoutSubviews() {
gradientLayer.frame = CGRect(x: -bounds.size.width, y: bounds.origin.y, width: 2 * bounds.size.width, height: bounds.size.height)
}
override func didMoveToWindow() {
super.didMoveToWindow()
layer.addSublayer(gradientLayer)
let gradientAnimation = CABasicAnimation(keyPath: "locations")
gradientAnimation.fromValue = [0.0, 0.0, 0.25]
gradientAnimation.toValue = [0.75, 1.0, 1.0]
gradientAnimation.duration = 1.7
// 一直循環(huán)
gradientAnimation.repeatCount = Float.infinity
gradientAnimation.isRemovedOnCompletion = false
gradientAnimation.fillMode = CAMediaTimingFillMode.forwards
gradientLayer.add(gradientAnimation, forKey: nil)
}
}
例子九,下拉刷新動畫

首先通過方法 scrollViewDidScroll 和 scrollViewWillEndDragging 做插值
extension PullRefreshView: UIScrollViewDelegate{
// MARK: - UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = CGFloat(max(-(scrollView.contentOffset.y + scrollView.contentInset.top), 0.0))
self.progress = min(max(offsetY / frame.size.height, 0.0), 1.0)
// 做互斥的狀態(tài)管理
if !isRefreshing {
redrawFromProgress(self.progress)
}
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
if !isRefreshing && self.progress >= 1.0 {
delegate?.PullRefreshViewDidRefresh(self)
beginRefreshing()
}
}
}
畫面中飛碟動來動去,是通過 CAKeyframeAnimation(keyPath: "position") ,關鍵幀動畫的位置屬性,設置的
func redrawFromProgress(_ progress: CGFloat) {
/* PART 1 ENTER ANIMATION */
let enterPath = paths.start
// 動畫指定路徑走
let pathAnimation = CAKeyframeAnimation(keyPath: "position")
pathAnimation.path = enterPath.cgPath
pathAnimation.calculationMode = CAAnimationCalculationMode.paced
pathAnimation.timingFunctions = [CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)]
pathAnimation.beginTime = 1e-100
pathAnimation.duration = 1.0
pathAnimation.timeOffset = CFTimeInterval() + Double(progress)
pathAnimation.isRemovedOnCompletion = false
pathAnimation.fillMode = CAMediaTimingFillMode.forwards
flyingSaucerLayer.add(pathAnimation, forKey: nil)
flyingSaucerLayer.position = enterPath.currentPoint
let sizeAlongEnterPathAnimation = CABasicAnimation(keyPath: "transform.scale")
sizeAlongEnterPathAnimation.fromValue = 0
sizeAlongEnterPathAnimation.toValue = progress
sizeAlongEnterPathAnimation.beginTime = 1e-100
sizeAlongEnterPathAnimation.duration = 1.0
sizeAlongEnterPathAnimation.isRemovedOnCompletion = false
sizeAlongEnterPathAnimation.fillMode = CAMediaTimingFillMode.forwards
flyingSaucerLayer.add(sizeAlongEnterPathAnimation, forKey: nil)
}
// 設置路徑
func customPaths(frame: CGRect = CGRect(x: 4, y: 3, width: 166, height: 74)) -> ( UIBezierPath, UIBezierPath) {
// 兩條路徑
let startY = 0.09459 * frame.height
let enterPath = UIBezierPath()
// ...
enterPath.addCurve(to: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height), controlPoint1: CGPoint(x: frame.minX + 0.04828 * frame.width, y: frame.minY + 0.68225 * frame.height), controlPoint2: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height))
enterPath.addCurve(to: CGPoint(x: frame.minX + 0.36994 * frame.width, y: frame.minY + 0.92990 * frame.height), controlPoint1: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height), controlPoint2: CGPoint(x: frame.minX + 0.33123 * frame.width, y: frame.minY + 0.93830 * frame.height))
// ...
enterPath.usesEvenOddFillRule = true
let exitPath = UIBezierPath()
exitPath.move(to: CGPoint(x: frame.minX + 0.98193 * frame.width, y: frame.minY + 0.15336 * frame.height))
exitPath.addLine(to: CGPoint(x: frame.minX + 0.51372 * frame.width, y: frame.minY + 0.28558 * frame.height))
// ...
exitPath.miterLimit = 4
exitPath.usesEvenOddFillRule = true
return (enterPath, exitPath)
}
}
這個動畫比較復雜,需要做大量的數(shù)學計算,還要調(diào)試,具體看文尾的 git repo.
一般這種動畫,我們用 Lottie
例子十,文本變換動畫

這個動畫有些復雜,重點使用了 CoreAnimation 的組動畫,疊加了五種效果,縮放、尺寸、布局、位置與透明度。
具體看文尾的 git repo.
class func animation(_ layer: CALayer, duration: TimeInterval, delay: TimeInterval, animations: (() -> ())?, completion: ((_ finished: Bool)-> ())?) {
let animation = CLMLayerAnimation()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)) {
var animationGroup: CAAnimationGroup?
let oldLayer = self.animatableLayerCopy(layer)
animation.completionClosure = completion
if let layerAnimations = animations {
CATransaction.begin()
CATransaction.setDisableActions(true)
layerAnimations()
CATransaction.commit()
}
animationGroup = groupAnimationsForDifferences(oldLayer, newLayer: layer)
if let differenceAnimation = animationGroup {
differenceAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
differenceAnimation.duration = duration
differenceAnimation.beginTime = CACurrentMediaTime()
layer.add(differenceAnimation, forKey: nil)
}
else {
if let completion = animation.completionClosure {
completion(true)
}
}
}
}
class func groupAnimationsForDifferences(_ oldLayer: CALayer, newLayer: CALayer) -> CAAnimationGroup? {
var animationGroup: CAAnimationGroup?
var animations = [CABasicAnimation]()
// 疊加了五種效果
if !CATransform3DEqualToTransform(oldLayer.transform, newLayer.transform) {
let animation = CABasicAnimation(keyPath: "transform")
animation.fromValue = NSValue(caTransform3D: oldLayer.transform)
animation.toValue = NSValue(caTransform3D: newLayer.transform)
animations.append(animation)
}
if !oldLayer.bounds.equalTo(newLayer.bounds) {
let animation = CABasicAnimation(keyPath: "bounds")
animation.fromValue = NSValue(cgRect: oldLayer.bounds)
animation.toValue = NSValue(cgRect: newLayer.bounds)
animations.append(animation)
}
if !oldLayer.frame.equalTo(newLayer.frame) {
let animation = CABasicAnimation(keyPath: "frame")
animation.fromValue = NSValue(cgRect: oldLayer.frame)
animation.toValue = NSValue(cgRect: newLayer.frame)
animations.append(animation)
}
if !oldLayer.position.equalTo(newLayer.position) {
let animation = CABasicAnimation(keyPath: "position")
animation.fromValue = NSValue(cgPoint: oldLayer.position)
animation.toValue = NSValue(cgPoint: newLayer.position)
animations.append(animation)
}
if oldLayer.opacity != newLayer.opacity {
let animation = CABasicAnimation(keyPath: "opacity")
animation.fromValue = oldLayer.opacity
animation.toValue = newLayer.opacity
animations.append(animation)
}
if animations.count > 0 {
animationGroup = CAAnimationGroup()
animationGroup!.animations = animations
}
return animationGroup
}
例子十一,動態(tài)圖動畫

從 gif 文件里面取出每楨圖片,算出持續(xù)時間,設置動畫圖片
internal class func animatedImageWithSource(_ source: CGImageSource) -> UIImage? {
// 需要喂圖片,
// 喂動畫持續(xù)時間
let count = CGImageSourceGetCount(source)
var data: (images: [CGImage], delays: [Int]) = ([CGImage](), [Int]())
// Fill arrays
for i in 0..<count {
// Add image
if let image = CGImageSourceCreateImageAtIndex(source, i, nil) {
data.images.append(image)
}
let delaySeconds = UIImage.delayForImageAtIndex(Int(i),
source: source)
data.delays.append(Int(delaySeconds * 1000.0)) // Seconds to ms
}
// Calculate full duration
let duration: Int = {
var sum = 0
for val: Int in data.delays {
sum += val
}
return sum
}()
let gcd = gcdForArray(data.delays)
var frames = [UIImage]()
var frame: UIImage
var frameCount: Int
for i in 0..<count {
frame = UIImage(cgImage: data.images[Int(i)])
frameCount = Int(data.delays[Int(i)] / gcd)
for _ in 0..<frameCount {
frames.append(frame)
}
}
let animation = UIImage.animatedImage(with: frames,
duration: Double(duration) / 1000.0)
return animation
}
github repo
音頻,參考了這個庫 syedhali/AudioStreamer
形象地理解 LRU, 拿起算法的鋼筆
LRU 還是挺有用的,緩存管理的時候,有時用到。
因為內(nèi)存是有限的,要聚焦在重點的資源上,
學習 LRU, 首先要建立直觀的認識
LRU 的描述很簡潔,容量有限,最近使用到的資源,排前面。
運用你所掌握的數(shù)據(jù)結構,設計和實現(xiàn)一個 LRU (最近最少使用) 緩存機制。它應該支持以下操作: 獲取數(shù)據(jù) get 和 寫入數(shù)據(jù) put 。
獲取數(shù)據(jù) get(key) - 如果密鑰 (key) 存在于緩存中,則獲取密鑰的值(總是正數(shù)),否則返回 -1。
寫入數(shù)據(jù) put(key, value) - 如果密鑰不存在,則寫入其數(shù)據(jù)值。當緩存容量達到上限時,它應該在寫入新數(shù)據(jù)之前刪除最近最少使用的數(shù)據(jù)值,從而為新的數(shù)據(jù)值留出空間。
模擬一下
下面有兩個綠色的格子,代表這個 LRU 的容量,是兩個
先放節(jié)點 1 ,容量 2, 當前是空的,直接放

再放節(jié)點 2 ,容量 2, 當前個數(shù) 1,可以直接放


然后讀取節(jié)點 1,當前哈希表中有,返回正常,該元素為最新使用元素


放入節(jié)點 3, 當前個數(shù)達到容量,需要刪除一個最久使用的,才能插入新的。
怎么刪除,從當前節(jié)點出發(fā),順著箭頭數(shù)。數(shù)到容量個數(shù)的,不重復節(jié)點,就都要的。(記得跳過,讀取不到值的節(jié)點 )
數(shù)到容量個數(shù)的,不重復節(jié)點的,前面的那一個不重復節(jié)點,就是要被刪除的。(記得跳過,讀取不到值的節(jié)點 )
因為當前節(jié)點,是最新使用的,越是箭頭方向,越是以前有用到,( 同一元素,第一次數(shù)到,為有效 )

刪除節(jié)點 2

讀取節(jié)點 2,發(fā)現(xiàn)取不到,返回 -1


最終:

數(shù)據(jù)結構部分:
采用了哈希表 ( Swift 中的字典 )和雙鏈表。
Key - Value 存取,當然要用哈希表。
要保證新插入和新使用的元素在前,很久沒使用的元素在后,可以來一個鏈表。
頭部元素,最近使用。尾部方向元素,最近越來越少用到
元素個數(shù)超過了容量,就要刪除尾部元素,需要有一個尾指針記錄,有了尾指針,要刪除最后的元素,就要找到他的上一個指針來操作,就要有前驅(qū)。(或者上一個指針的上一個來操作)
有了前驅(qū)。鏈表元素自然要找到他的下一個,也就是后繼。(鏈表的固有屬性)
鏈表存在前驅(qū)與后繼,就是雙鏈表了。
另一角度,雙鏈表里面的節(jié)點,可以輕松實現(xiàn)自刪除,不需要其他指針的協(xié)助
簡單粗暴,實現(xiàn)一個 LRU, O ( 1 ) 復雜度
LRU 可以簡單分為兩部分,數(shù)據(jù)的寫入與讀取
先實現(xiàn)寫入,寫入了,進程里面才有數(shù)據(jù),方便調(diào)試讀取
設計存入的部分
分情況處理,
如果要插入的元素,已經(jīng)在鏈表里面了,根據(jù) key.
要維持鏈表的 LRU 有序,就要把他放在最前面,就要改他的前后指針,已經(jīng)相關節(jié)點的。
先刪除他,再把他插入頭節(jié)點,
還要更新他的 value, 也許這個 key 的值變了。
如果要插入的元素,不在鏈表里面了,根據(jù) key.
又要考慮三種情況,
如果是插入第一個元素,要先建立結構,假的頭部節(jié)點,后面是假的尾節(jié)點
如果已經(jīng)存在的元素滿了,要刪除最后面的元素,也就是最近少用到的
最后一種情況,一切正常。把新的節(jié)點,插入頭部第一個。
class DLinkedNode {
// 這個是,刪除尾節(jié)點,要同步哈希表。哈希表也要對應刪除的時候,用到
let key: Int
var val: Int
var prior: DLinkedNode?
var next: DLinkedNode?
init(_ key: Int, value: Int) {
self.key = key
val = value
}
}
class LRUCache {
var dummyHead = DLinkedNode(0, value: 0)
var dummyTail = DLinkedNode(0, value: 0)
var capacity: Int
var container = [Int: DLinkedNode]()
var hasCount: Int = 0
init(_ capacity: Int) {
self.capacity = capacity
}
func put(_ key: Int, _ value: Int) {
// 先設計存的部分
}
func insertHead(_ node: DLinkedNode){
let former = dummyHead.next
former?.prior = node
dummyHead.next = node
node.prior = dummyHead
node.next = former
}
func deleteNode(_ node: DLinkedNode){
node.prior?.next = node.next
node.next?.prior = node.prior
node.prior = nil
node.next = nil
}
func deleteTail(){
if let toDel = dummyTail.prior{
toDel.prior?.next = dummyTail
dummyTail.prior = toDel.prior
container.removeValue(forKey: toDel.key)
}
}
}
設計讀取的部分
讀取部分,相對簡單
哈希表中沒有 key, 就返回 -1 ,沒有
哈希表中存在 key, 就找到對應的節(jié)點,返回值。同時把該節(jié)點更新到頭部第一個節(jié)點。
也就是在鏈表中,先刪除,再插入到頭部。
class DLinkedNode {
let key: Int
var val: Int
var prior: DLinkedNode?
var next: DLinkedNode?
init(_ key: Int, value: Int) {
self.key = key
val = value
}
}
class LRUCache {
var dummyHead = DLinkedNode(0, value: 0)
var dummyTail = DLinkedNode(0, value: 0)
// 這個記錄設定的容量
var capacity: Int
var container = [Int: DLinkedNode]()
// 這個記錄實際的元素個數(shù)
var hasCount: Int = 0
init(_ capacity: Int) {
self.capacity = capacity
}
func get(_ key: Int) -> Int {
// 再設計取的部分
}
func put(_ key: Int, _ value: Int) {
if let node = container[key]{
// 包含,就換順序
// 還有一個更新操作
node.val = value
deleteNode(node)
insertHead(node)
}
else{
if hasCount == 0{
// 建立結構
dummyHead.next = dummyTail
dummyTail.prior = dummyHead
}
if hasCount >= capacity{
// 超過,就處理
hasCount -= 1
deleteTail()
}
hasCount += 1
// 不包含,就插入頭節(jié)點
let node = DLinkedNode(key, value: value)
insertHead(node)
container[key] = node
}
}
func insertHead(_ node: DLinkedNode){
let former = dummyHead.next
former?.prior = node
dummyHead.next = node
node.prior = dummyHead
node.next = former
}
func deleteNode(_ node: DLinkedNode){
node.prior?.next = node.next
node.next?.prior = node.prior
node.prior = nil
node.next = nil
}
func deleteTail(){
if let toDel = dummyTail.prior{
toDel.prior?.next = dummyTail
dummyTail.prior = toDel.prior
container.removeValue(forKey: toDel.key)
}
}
}
可以看出,LRU 的性能關鍵, 在于采用結構記錄與保持
每次存取,都對鏈表做了更新 ( 除了取的時候,key 不存在 )
方便調(diào)試,會更好。重寫了 NSObject 的 var description.
最后的完整版本:
優(yōu)化一點,
假的頭節(jié)點和尾節(jié)點的鏈表關系結構,可以一開始就建好,不用以后每次寫元素,都判斷
class DLinkedNode: NSObject {
let key: Int
var val: Int
var prior: DLinkedNode?
var next: DLinkedNode?
init(_ key: Int, value: Int) {
self.key = key
val = value
}
// 輔助調(diào)試 debug, 打印出信息的,方便看
override var description: String{
var result = String(val)
var point = prior
while let bee = point{
result = "\(bee.val) -> " + result
point = bee.prior
}
point = next
while let bee = point{
result = result + "-> \(bee.val)"
point = bee.next
}
return result
}
}
class LRUCache {
// 怎樣化 O ( n ) 為 O ( 1 ). 關心的狀態(tài),都用一個專門的指針,記錄了
var dummyHead = DLinkedNode(0, value: 0)
var dummyTail = DLinkedNode(0, value: 0)
var capacity: Int
var container = [Int: DLinkedNode]()
var hasCount: Int = 0
init(_ capacity: Int) {
self.capacity = capacity
// 建立結構
dummyHead.next = dummyTail
dummyTail.prior = dummyHead
}
func get(_ key: Int) -> Int {
// 有一個刷新機制
if let node = container[key]{
deleteNode(node)
insertHead(node)
return node.val
}
else{
return -1
}
}
func put(_ key: Int, _ value: Int) {
if let node = container[key]{
// 包含,就換順序
// 還有一個更新操作
node.val = value
deleteNode(node)
insertHead(node)
}
else{
if hasCount >= capacity{
// 超過,就處理
hasCount -= 1
deleteTail()
}
hasCount += 1
// 不包含,就插入頭節(jié)點
let node = DLinkedNode(key, value: value)
insertHead(node)
container[key] = node
}
}
func insertHead(_ node: DLinkedNode){
let former = dummyHead.next
former?.prior = node
dummyHead.next = node
node.prior = dummyHead
node.next = former
}
// 指針操作,最好還是弄個變量,接一下
func deleteNode(_ node: DLinkedNode){
node.prior?.next = node.next
node.next?.prior = node.prior
node.prior = nil
node.next = nil
}
func deleteTail(){
if let toDel = dummyTail.prior{
toDel.prior?.next = dummyTail
dummyTail.prior = toDel.prior
container.removeValue(forKey: toDel.key)
}
}
}