Matrix Code Rain及对Core Graphics绘制的优化

vbha4425 8年前
   <h2><strong>前情提要</strong></h2>    <p>在9月25号看到Kevin Chou的 这篇 介绍他开源的组件库 PNChart 受到欢迎的文章时,我突然想到:对啊,这是个把自己喜好与技术积累结合起来的好途径!之前总觉得往开源社区贡献代码需要超强的底层代码功力,又不想仿写已有的组件重复造轮子,这时我才刚刚意识到,上层的UI层面同样需要优秀的贡献——某种程度上讲更加稀缺,毕竟同时对设计和代码都有研究的程序员比较少。</p>    <p>恰好,一个做前端的朋友Fanta发来了一份他业余时间用HTML+JS写着玩做的《黑客帝国》代码雨效果的demo:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/de37505f6558ad528df3909c1613ad9d.gif"></p>    <p>我觉得这个还挺有意思,搜了一下GitHub上还没有做过的,于是便开始了编码工作。</p>    <h2><strong>架构及轨迹生成</strong></h2>    <p>这是一个很简单的小组件,所以基本架构也很简单:</p>    <p><img src="https://simg.open-open.com/show/e1d2f9e29a1d40e8ad09ff5bac919244.jpg"></p>    <p>我们约定将每一条下落的轨迹都称为一个 Track ,由一个 Generator 实例专门来生成,每隔指定的时间(显然,随机亦可)就新生成一条,加到 DataSource 中,并创建其对应的 CALayer 子类 CodeRainLayer 加到最底层的 UIView 上。</p>    <h2><strong>下落及轨迹清理</strong></h2>    <p>如何产生动画呢?最开始自然想到用 CAAnimation 来做。</p>    <p>因为代码太简单,就不在这里写了。</p>    <p>但是写完个大概之后,运行起来却发现不对劲:总感觉没有电影里面酷。</p>    <p>问题出在哪里呢?我又从移动硬盘里翻出了那三部曲仔细地研究了一下,经过一帧一帧地探究,我找到了原因:</p>    <p>电影里面的代码并不是在“下落”,如果你盯着一个字母看,会发现它根本就没移动过位置(除去镜头本身的移动)。换句话说,整个空间是一个已经排列好的字母矩阵,而我们看到的表象是 <strong>一阵脉冲流过</strong> 而已。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/7322d24618d7a357ba722f955b22d4cc.gif"></p>    <p>所以最后改成的方案是由每个 Track 实例自带的 Timer 负责驱动控制自身的下落(为表述方便我们依然沿用这个词),当需要刷新时,通知其对应的 CodeRainLayer 实例( -setNeedsDisplay )进行重绘。至于如何重绘,由每个 CodeRainLayer 自行负责。</p>    <p>而当整条轨迹掉出屏幕的时候, Track 会检测出边界条件,然后把对应的 CALayer 执行 removeFromSuperlayer ,最后把自身从 DataSource 中清除。</p>    <h2><strong>阶段性成果</strong></h2>    <p>OK, so far so good. 我们成功实现了整个的动画效果,看起来也确实蛮酷的:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/c61bf729e819cbf7bdd60870f67f58ac.gif"></p>    <h2><strong>封装</strong></h2>    <p>在把它传到GitHub之前,还需要进行一些封装。这里主要有两方面的工作,一个是增加控制关键字来限制外界能接触到的内部类和方法,另一个是将可调节的参数向外界暴露出来。</p>    <h3><strong>Access Control</strong></h3>    <p>在Swift 3中特地新加了 fileprivate 这个访问权限,正好在这里可以用到。我们把不希望暴露给外界的类都加上这个限定关键字。</p>    <p>顺便,Swift 3中的访问权限依次是:</p>    <p>open,public,internal,fileprivate,private.</p>    <h3><strong>Configurable Parameters</strong></h3>    <p>在之前,组件中用到的所有参数都定义在了一个 struct 里:</p>    <pre>  <code class="language-swift">fileprivate struct JSMatrixConstants {      static let maxGlowLength: Int = 3 // Characters      static let minTrackLength: Int = 8 // Characters      static let maxTrackLength: Int = 40 // Characters      static let charactersSpacing: CGFloat = 0.0 // pixel      static let characterChangeRate = 0.9      static let firstDropShowTime = 2.0 // Time between the First drop and the later        // Configurable      static let speed: TimeInterval = 0.15 // Seconds that new character pop up      static let newTrackComingLap: TimeInterval = 0.4      static let tracksSpacing: Int = 5  }</code></pre>    <p>为了暴露其中的一些参数,我们在 CodeRainView 那里增加几个变量:</p>    <pre>  <code class="language-swift">var trackSpacing: Int  var newTrackComingLap: CGFloat  var speed: CGFloat</code></pre>    <p>那么如果用户不设置的时候呢?我们应该用回默认值。比如这样:</p>    <pre>  <code class="language-swift">var speed: CGFloat = CGFloat(JSMatrixConstants.speed){      didSet{          datasource.speed = TimeInterval(speed)      }  }  var newTrackComingLap: CGFloat = CGFloat(JSMatrixConstants.newTrackComingLap){      didSet{          datasource.newTrackComingLap = TimeInterval(newTrackComingLap)      }  }  var trackSpacing: Int = JSMatrixConstants.tracksSpacing{      didSet{          datasource.trackSpacing = trackSpacing      }  }</code></pre>    <p>而一个2016年的UI组件应当是Interface Builder-Friendly的——尤其是,要做到这点只需举手之劳:将上面的参数声明为 @IBInspectable 。</p>    <p>最后在IB中看到的效果是:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/791d1dc5a2b16f19a09465585920a129.jpg"></p>    <h2><strong>优化性能</strong></h2>    <p>在我的iPhone6s上测试时,整个组件的表现没什么大问题;但在比较老的iPhone5s上测试时,就有点吃力了。虽然画面依然比较流畅,在CPU监测中能明显看出占用:</p>    <p><img src="https://simg.open-open.com/show/e983ee4ba977677259d9b82fb551ea5f.jpg"></p>    <p>而在我后面想结合一些 CoreMotion 的回调实现 视角缩放 效果时,在5s上的画面终于卡了起来。</p>    <p>之所以会卡很容易理解,整个组件在主线程中进行了大量的绘制工作,搁你你也卡。</p>    <p>绘制UIView最快的方法就是把它当成imageview,我们把需要用Core Graphic绘制的代码放到另一个线程中去绘制,生成image后直接赋值给view,达到异步绘制的目的。</p>    <p>我试了一下,差不多是这样:</p>    <pre>  <code class="language-swift">let track = self.track  DispatchQueue.global().async {      let size = self.bounds.size      UIGraphicsBeginImageContext(size)      context.saveGState()        ... // Calculate positions, etc.        context.restoreGState()      self.render(in: context)      let resultImage = UIGraphicsGetImageFromCurrentImageContext();      DispatchQueue.main.async {          if let image = resultImage{              self.contents = image.cgImage          }      }      UIGraphicsEndImageContext()  }</code></pre>    <p>但这样做有问题:在每一次更新的时候,这个Layer需要在空白的背景下进行绘制,而直接调用 self.render(in: context) 方法,绘制的内容会叠加在当前显示的内容之上,出来的效果是不可用的。(截图过于残暴,从略)</p>    <p>那么怎么解决这个问题呢?一个直接的想法是,如果能在一个新的context上绘制就好了。</p>    <p>带着这个目标去搜索,在 这个文章 里面介绍了创建context的方法,于是上面的代码变成了:</p>    <pre>  <code class="language-swift">let track = self.track  DispatchQueue.global().async {      let size = self.bounds.size      UIGraphicsBeginImageContext(size)        /* Create drawing context */      let colorSpace = CGColorSpaceCreateDeviceRGB()      let createdContext = CGContext(data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)        if let context = createdContext{          context.saveGState()            ... // calc positions, etc.            context.restoreGState()          self.render(in: context)          let resultImage = UIGraphicsGetImageFromCurrentImageContext();          DispatchQueue.main.async {              if let image = resultImage{                  self.contents = image.cgImage              }          }      }      UIGraphicsEndImageContext()  }</code></pre>    <h2><strong>优化结果</strong></h2>    <p>搞定了这些之后兴冲冲地在5s上跑了一下,发现除了线程多了一些之外,差别几乎不可见:</p>    <p><img src="https://simg.open-open.com/show/6c05b658c3483cc63f8b6d795d10e7e5.jpg"></p>    <p>细想一下也可以理解,我们并没有减少任何绘制的工作量,只不过是把它们移到了后台线程而已。</p>    <p>那么接下来的问题是,在为主线程减了这么多负之后,程序的响应性能有提高吗?因为要是再没什么变化的话,我要为前面这些花出的时间哭几秒。</p>    <p>接下来我搜到了一篇讲述如何测量程序响应性的 <a href="/misc/goto?guid=4959719977377133271" rel="nofollow,noindex">文章</a> ,还附了源码的截图,非常良心。</p>    <pre>  <code class="language-swift">fileprivate class PingThread: Thread{      var pingTaskIsRunning = false      var semaphore = DispatchSemaphore(value: 0)      override func main(){          while !self.isCancelled{              pingTaskIsRunning = true              DispatchQueue.main.async {                  self.pingTaskIsRunning = false                  self.semaphore.signal()              }              Thread.sleep(forTimeInterval: 1/30.0)              if pingTaskIsRunning {                  NSLog("Delayed!")              }              _ = semaphore.wait(timeout: DispatchTime.distantFuture)          }      }  }</code></pre>    <p>核心思想是,每隔一定的时间就在主线程给该线程的信号量发消息,要是主线程因为卡顿耽搁了,该线程就会输出警告信息。</p>    <p>我把时间设为1/30秒,因为这是一个流畅的动画所应当达到的帧率。</p>    <p>这下终于有了喜人的对比结果:</p>    <p>之前:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/336521caffe22ee9748a7662cc90dcd7.jpg"></p>    <p>之后:</p>    <p><img src="https://simg.open-open.com/show/a89c7f2bf5eea6abe987fc629df81aff.jpg"></p>    <p>直到启动20多秒后收到内存警告,都没有一次卡顿出现!</p>    <p>虽然我不是一个使用meme表情控,但看国外的blog看多了之后,总觉得在这种情况下需要出现一个表情……</p>    <p>就是下面这个:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/0f2f8dafdfdc5a21580237b4a7aca1d4.jpg"></p>    <h2><strong>最后的话</strong></h2>    <p>通过这个项目,我学到的东西包括:</p>    <ul>     <li>Core Graphic的一些深入内容</li>     <li>一些之前用不到的封装策略</li>     <li>一个优化绘制性能的方法</li>     <li>一个测量程序响应性能的方法</li>    </ul>    <p>接下来又想到一个比较有趣的项目,不知道什么时候能填坑。</p>    <p>感谢观赏。</p>    <p> </p>    <p>来自:http://www.jianshu.com/p/b38ef781e2c5</p>    <p> </p>