quququq

作者: BoxDeng | 来源:发表于2019-10-02 12:49 被阅读0次

iOS Audio hand by hand: 变声,混响,语音合成 TTS,Swift5,基于 AVAudioEngine 等

AVAudioEngine 比 AVAudioPlayer 更加强大,当然使用上比起 AVAudioPlayer 繁琐。
AVAudioEngine 对于 Core Audio 作了一些使用上的封装简化,简便的做了一些音频信号的处理。
使用 AVAudioPlayer ,是音频文件级别的处理。
使用 AVAudioEngine,是音频数据流级别的处理。
AVAudioEngine 可以做到低时延的、实时音频处理。还可以做到音频的多输入,添加特殊的效果,例如三维空间音效

AVAudioEngine 可以做出强大的音乐处理与混音 app, 配合制作复杂的三维空间音效的游戏,本文来一个简单的变声应用

通用架构图,场景是 K 歌

aaa

AVAudioEngine 使用指南

首先,简单理解下

111

来一个 AVAudioEngine 实例,然后添加节点 Node, 有播放器的 Player Node, 音效的 Effect Node.
将节点连在音频引擎上,即 AVAudioEngine 实例。然后建立节点间的关联,组成一条音频的数据处理链。
处理后的音频数据,流过最后的一个节点,就是音频引擎的输出了。

开始做一个变声的功能,也就是音调变化

需要用到 AVAudioEngine 和 AVAudioPlayerNode

    // 音频引擎是枢纽
    var audioAVEngine = AVAudioEngine()
    // 播放节点
    var enginePlayer = AVAudioPlayerNode()
    // 变声单元:调节音高
    let pitchEffect = AVAudioUnitTimePitch()
    // 混响单元
    let reverbEffect = AVAudioUnitReverb()
    // 调节音频播放速度单元
    let rateEffect = AVAudioUnitVarispeed()
    // 调节音量单元
    let volumeEffect = AVAudioUnitEQ()
    // 音频输入文件
    var engineAudioFile: AVAudioFile!

做一些设置
先取得输入节点的 AVAudioFormat 引用,
这是音频流数据的默认描述文件,包含通道数、采样率等信息。
实际上,AVAudioFormat 就是对 Core Audio 的音频缓冲数据格式文件 AudioStreamBasicDescription, 做了一些封装。
audioAVEngine 做子节点关联的时候,要用到。

// 做一些配置,功能初始化
    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 加载音频数据,形成数据缓冲区,方便 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 (哑巴的,就为了取下数据与状态), 通过 AVAudioPlayerNode 添加变声等音效,通过做音量大小检测。

看起来有些累赘,苹果自然是不会推荐这样做的。

111

如果是录音,通过 NodeTapBlock 对音频输入流的信息,做实时分析。
播放也类似,处理音频信号,取出平均音量,就可以刷新 UI 了。

通过 AVAudioPlayer ,可以方便拿到当前播放时间,文件播放时长等信息,

通过 AVAudioPlayerDelegate,可以方便播放结束了,去刷新 UI

当然,使用 AVAudioPlayerNode ,这些都是可以做到的


结束播放

func stopPlayback() {
        setPlayButtonOn(flag: false)
        audioStatus = .stopped
        // 两个播放器,一起结束,一起结束
        audioPlayer.stop()
        enginePlayer.stop()
        stopUpdateLoop()
    } 

音效: 音高,混响,播放速度,音量大小

调节音高,用来变声, AVAudioUnitTimePitch

音效的 pitch 属性,取值范围从 -2400 音分到 2400 音分,包含 4 个八度音阶。
默认值为 0
一個八度音程可以分为12个半音。
每一个半音的音程相当于相邻钢琴键间的音程,等于100音分

    func setPitch(value: Float) {
        pitchEffect.pitch = value
    }
调节混响, AVAudioUnitReverb

wetDryMix 的取值范围是 0 ~ 100,
0 是全干,干声即无音乐的纯人声
100 是全湿润,空间感很强。
干声是原版,湿声是经过后期处理的。

   func toSetReverb(value: Float) {
        reverbEffect.wetDryMix = value
    }
调节音频播放速度, AVAudioUnitVarispeed

音频播放速度 rate 的取值范围是 0.25 ~ 4.0,
默认是 1.0,正常播放。

func toSetRate(value: Float) {
        rateEffect.rate = value
    }
调节音量大小, 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 封装了文字,还有对应的朗读效果参数。

朗读效果中,可以设置口音,本文 Demo 采用 zh-CN。还可以设置变声和语速 (发音速度)。

拿到 AVSpeechUtterance ,合成器 AVSpeechSynthesizer 就可以朗读了。如果 AVSpeechSynthesizer 正在朗读,AVSpeechUtterance 就会放在 AVSpeechSynthesizer 的朗读队列里面,按照先进先出的顺序等待朗读。

苹果框架的粒度都很细,语音合成器 AVSpeechSynthesizer,也有暂定、继续播放与结束播放功能。

停止了语音合成器 AVSpeechSynthesizer,如果他的朗读队列里面还有语音文本AVSpeechUtterance,剩下的都会直接移除。

AVSpeechSynthesizerDelegate 合成器代理相关

使用合成器代理,可以监听朗读时候的事件。例如:开始朗读,朗读结束

TTS: Text To Speech 三步走

先设置
// 来一个合成器
let synthesizer = AVSpeechSynthesizer()

// ...

// 设置合成器的代理,监听事件
synthesizer.delegate = self


朗读、暂停、继续朗读与停止朗读
// 朗读
func  play() {
    let words = UserSetting.shared.message
    // 拿文本,去实例化语音文本单元
    let utterance = AVSpeechUtterance(string: words)
    // 设置发音为简体中文 ( 中国大陆 )
    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)
    }

// 继续朗读
 func continuePlayback() {
        synthesizer.continueSpeaking()
    }

// 停止播放
func stopPlayback() {
    // 让合成器马上停止朗读
    synthesizer.stopSpeaking(at: AVSpeechBoundary.immediate)
    // 停止计时器更新状态,具体见文尾的 github repo
    stopUpdateLoop()
    setPlayButtonOn(false)
    audioStatus = .stopped
  }
设置合成器代理,监听状态改变的时机
// 开始朗读。朗读每一个语音文本单元的时候,都会来一下
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
    }
    
    // 继续朗读
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
        setPlayButtonOn(true)
        startUpdateLoop()
        audioStatus = .playing
    }

11 个例子,由浅到深,学习 iOS 动画

iOS 的动画框架很成熟,提供必要的信息,譬如动画的起始位置与终止位置,动画效果就出来了

动画的实现方式挺多的,
有系统提供的简单 API ,直接提供动画般的交互效果。
有手动设置交互效果,看起来像是动画,一般要用到插值。
至于动画框架,有 UIView 级别的,有功能强劲的 CALayer 级别的动画。
CALayer 级别的动画通过灵活设置的 CoreAnimation,CoreAnimation 的常规操作,就是自定义路径
当然有苹果推了几年的 UIViewPropertyAnimator, 动画可交互性做得比较好


例子一,导航栏动画

NavigationBarAnimation.gif
navigationController?.hidesBarsOnSwipe = true

简单设置 hidesBarsOnSwipe 属性,就可以了。
该属性,除了可以调节头部导航栏,还可以调节底部标签工具栏 toolbar


例子二,屏幕开锁效果

image

一眼看起来有点炫,实际设置很简单

    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()
                })
        })
    }

总共有四个控件,先让中间的锁控件旋转一下,然后对四个控件,做移位操作

用简单的关键帧动画,处理要优雅一点


例子三,地图定位波动

MapLocationAnimation.gif

看上去有些眼花的动画,可以分解为三个动画

111

一波未平,一波又起,做一个动画效果的叠加,就成了动画的第一幅动画

一个动画波动效果,效果用到了透明度的变化,范围的变化
范围的变化,用的就是 CoreAnimation 的路径 path

CoreAnimation 简单设置,就是指明 from 、to,动画的起始状态,和动画终止状态,然后选择使用哪一种动画效果。
动画的起始状态,一般是起始位置。简单的动画,就是让他动起来

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")
    }


波动效果调用了三次

    
    func startAnimation() {
        // 三次动画,效果合成,
        sonar(CACurrentMediaTime())
        sonar(CACurrentMediaTime() + 0.92)
        sonar(CACurrentMediaTime() + 1.84)
    }


例子四,加载动画

LoadingDotsAnimation.gif

这是 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)
    }


例子五,下划线点击转移动画

这个也是 UIView 的动画

UnderlineAnimation.gif

动画的实现效果,是通过更改约束。
约束动画要注意的是,确保动画的起始位置准确,起始的时候,一般要调用其父视图的 layoutIfNeeded 方法,确保视图的实际位置与约束设置的一致。
这里的约束动画,是通过 NSLayoutAnchor 做得。
一般我们用的是 SnapKit 设置约束,调用也差不多。

 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)
        
    }


例子六,列表视图的头部拉伸效果

这个没有用到动画框架,就是做了一个交互插值

就是补插连续的函数 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
    }

例子七,进度绘制动画

ProgressAnimation.gif

用到了 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
    }

}

// 动画路径,结合插值


例子八,渐变动画

GradientAnimation.gif

这个渐变动画,主要用到了渐变图层 CAGradientLayerlocations 位置属性,用来调整渐变区域的分布

另一个关键点是用了图层 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
    }()
    
    // 文字转图片,然后绘制到视图上
    // 通过设置渐变图层的遮罩 `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
        
        // 一直循环
        gradientAnimation.repeatCount = Float.infinity
        gradientAnimation.isRemovedOnCompletion = false
        gradientAnimation.fillMode = CAMediaTimingFillMode.forwards
        
        gradientLayer.add(gradientAnimation, forKey: nil)
    }

}

例子九,下拉刷新动画

PullToRefreshAnimation.gif

首先通过方法 scrollViewDidScrollscrollViewWillEndDragging 做插值

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)
           
           // 做互斥的状态管理
           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)
    }

}

这个动画比较复杂,需要做大量的数学计算,还要调试,具体看文尾的 git repo.
一般这种动画,我们用 Lottie


例子十,文本变换动画

SecretTextAnimation.gif

这个动画有些复杂,重点使用了 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
    }
    

例子十一,动态图动画

GifAnimation.gif

从 gif 文件里面取出每桢图片,算出持续时间,设置动画图片


    internal class func animatedImageWithSource(_ source: CGImageSource) -> UIImage? {
        
        // 需要喂图片,
        // 喂动画持续时间
        
        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 还是挺有用的,缓存管理的时候,有时用到。

因为内存是有限的,要聚焦在重点的资源上,

学习 LRU, 首先要建立直观的认识

LRU 的描述很简洁,容量有限,最近使用到的资源,排前面。

运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put 。

获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。
写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。

模拟一下

下面有两个绿色的格子,代表这个 LRU 的容量,是两个

先放节点 1 ,容量 2, 当前是空的,直接放

0.png

再放节点 2 ,容量 2, 当前个数 1,可以直接放

1.png 2.png

然后读取节点 1,当前哈希表中有,返回正常,该元素为最新使用元素

3.png 4.png

放入节点 3, 当前个数达到容量,需要删除一个最久使用的,才能插入新的。

怎么删除,从当前节点出发,顺着箭头数。数到容量个数的,不重复节点,就都要的。(记得跳过,读取不到值的节点 )
数到容量个数的,不重复节点的,前面的那一个不重复节点,就是要被删除的。(记得跳过,读取不到值的节点 )
因为当前节点,是最新使用的,越是箭头方向,越是以前有用到,( 同一元素,第一次数到,为有效 )

5.png

删除节点 2

6.png

读取节点 2,发现取不到,返回 -1

7.png 8.png

最终:

9.png

数据结构部分:

采用了哈希表 ( Swift 中的字典 )和双链表。

Key - Value 存取,当然要用哈希表。
要保证新插入和新使用的元素在前,很久没使用的元素在后,可以来一个链表。

头部元素,最近使用。尾部方向元素,最近越来越少用到

元素个数超过了容量,就要删除尾部元素,需要有一个尾指针记录,有了尾指针,要删除最后的元素,就要找到他的上一个指针来操作,就要有前驱。(或者上一个指针的上一个来操作)

有了前驱。链表元素自然要找到他的下一个,也就是后继。(链表的固有属性)

链表存在前驱与后继,就是双链表了。

另一角度,双链表里面的节点,可以轻松实现自删除,不需要其他指针的协助

简单粗暴,实现一个 LRU, O ( 1 ) 复杂度

LRU 可以简单分为两部分,数据的写入与读取

先实现写入,写入了,进程里面才有数据,方便调试读取

设计存入的部分

分情况处理,

如果要插入的元素,已经在链表里面了,根据 key.
要维持链表的 LRU 有序,就要把他放在最前面,就要改他的前后指针,已经相关节点的。
先删除他,再把他插入头节点,
还要更新他的 value, 也许这个 key 的值变了。

如果要插入的元素,不在链表里面了,根据 key.
又要考虑三种情况,
如果是插入第一个元素,要先建立结构,假的头部节点,后面是假的尾节点
如果已经存在的元素满了,要删除最后面的元素,也就是最近少用到的
最后一种情况,一切正常。把新的节点,插入头部第一个。



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]()
    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, 就找到对应的节点,返回值。同时把该节点更新到头部第一个节点。
也就是在链表中,先删除,再插入到头部。



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]()
   // 这个记录实际的元素个数
    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
            // 不包含,就插入头节点
            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 不存在 )

方便调试,会更好。重写了 NSObject 的 var description.

最后的完整版本:

优化一点,
假的头节点和尾节点的链表关系结构,可以一开始就建好,不用以后每次写元素,都判断


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
    }
    // 辅助调试 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 ). 关心的状态,都用一个专门的指针,记录了
    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
            // 不包含,就插入头节点
            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)
        }
    }
}


相关文章

  • quququq

    iOS Audio hand by hand: 变声,混响,语音合成 TTS,Swift5,基于 AVAudioE...

网友评论

      本文标题:quququq

      本文链接:https://www.haomeiwen.com/subject/przlpctx.html