iOS架构
蔡学镛(架构师)(开发)架构的几个原则,根据优先次序高低排列:
- (逻辑)拆分越细越好
- 依赖关细越少越好
- 交互越少越好 ... 相互矛盾时,如果没有特殊理由,以优先权高者胜出。
设计架构应该拆的越细越好。这样做有如下几点好处:
对于大中型软件,层次越多,每一层就更单纯,更容易维护。
团队成员只需了解一小部分业务,就能顺利进行开发。
相对底层的模块,可以更好的重用。
层次分的越多,开发者对抽象的理解就更深入。
iOS说到分层,有几种常见的做法。
按功能分:有MVC,MVVM......
按层次分:有数据层、逻辑层、展现层......
为什么要考虑架构选择的问题
因为开发时如果不采用架构,随着App复杂度的提高,势必出现一些巨大的类,在其中定位及修复bug都会变得越来越困难。代码组织很可能会是这样的:
- 那些巨大的类是UIViewController的子类
- 在UIViewController中操作数据
- UIView基本不干啥
- Model只是些数据结构,没有动作
- 单元测试并没有覆盖到什么
好的架构需要什么特性
- 不同模块角色明晰,代码均衡分布于这些模块上
- 由第1点带来的可测试性
- 易用性,维护成本低
架构设计质量约束:
提高复用度
足够的扩展性
架构设计的方法论
App确实就是主要做这些事情,但是支撑这些事情的基础,就是做架构要考虑的事情:
调用网络API。如何让业务开发工程师方便安全地调用网络API?然后尽可能保证用户在各种网络环境下都能有良好的体验?
页面展示。页面如何组织,才能尽可能降低业务方代码的耦合度?尽可能降低业务方开发界面的复杂度,提高他们的效率?
数据的本地持久化。当数据有在本地存取的需求的时候,如何能够保证数据在本地的合理安排?如何尽可能地减小性能消耗?
动态部署方案。iOS应用有审核周期,如何能够通过不发版本的方式展示新的内容给用户?如何修复紧急bug?
上面几点是针对App说的,下面还有一些是针对团队说的:
收集用户数据,给产品和运营提供参考
合理地组织各业务方开发的业务模块,以及相关基础模块
每日app的自动打包,提供给QA工程师的测试工具
架构设计的方法:
第一步:搞清楚要解决哪些问题,并找到解决这些问题的充要条件
第二步:问题分类,分模块
第三步:搞清楚各问题之间的依赖关系,建立好模块交流规范并设计模块
第四步:推演预测一下未来可能的走向,必要时添加新的模块,记录更多的基础数据以备未来之需
第五步:先解决依赖关系中最基础的问题,实现基础模块,然后再用基础模块堆叠出整个架构
第六步:打点,跑单元测试,跑性能测试,根据数据去优化对应的地方
什么样的架构叫好架构?
代码整齐,分类明确,没有common,没有core
不用文档,或很少文档,就能让业务方上手
思路和方法要统一,尽量不要多元
没有横向依赖,万不得已不出现跨层访问
对业务方该限制的地方有限制,该灵活的地方要给业务方创造灵活实现的条件
易测试,易拓展
保持一定量的超前性
接口少,接口参数少
高性能
常见架构
MVC
MVVM
VIPER
MVP
前两者结构类似,都是把App的模块分成3个大类:
- Models:负责数据或者操作数据的数据存取层。例如User或者UserDataProvider类。
- Views:负责展示层(GUI)。对iOS来说,包括所有那些前缀是UI的东西。
- Controller / ViewModel:Model,View之间的胶水或者中间人。对用户在View所做的操作进行响应,改变Model,同时当Model变化时,更新View。
把模块分开的好处:
更容易理解
利于重用,尤其是View和Model
便于隔离开进行测试
MVP:
Model-View-Presenter,MVC的一个演变模式,将Controller换成了Presenter,主要为了解决上述第一个缺点,将View和Model解耦,不过第二个缺点依然没有解决。
MVC&MVVM&VIPER比较
MVC:
分布:View和Model确实是分开的,但是view和controller是紧密耦合的。
可测试性:由于糟糕的分布,你可能只会测试Model。
易用:在所有模式中代码量是最少的。另外,对所有人来说都很熟悉,所以即使是生手也能维护。
MVVM:
MVVM和RAC更配偶?MVVM相对于MVC其职责划分更细,必然引入了更多的类(view-model),而任何架构都很难脱离Apple的MVC的基础,这势必延长整个数据的传递链。基于响应式思想的RAC,使用流式处理数据。这正好缩短了使用过程中,数据传递路径。可以直接将界面事件绑定到VM上,从来极大的减少开发成本。其实引入RAC是最好用方式,但是通过观察发现:在我们的APP中真正需要传递的更多的是事件,而这个事件可能被Element相关联的链条上的每一个元素响应,设计模式上类似于响应链模式。
在view和view model之间存在binding。
分布:MVVM的view比MVP的view责任更多,他通过设置binding,根据view model更新状态。
可测试性:view model对view一无所知,所以测试比较容易。View或许也能测,但是因为依赖于UIKit,可能就想跳过了
易用性:如果采用binding,MVVM的代码量会大为减少
VIPER:
- Interactor: 包含了与数据(Entity)或者网络相关的业务逻辑,例如创建数据的新实例,从服务器获取数据。对于那些任务,一般会使用一些Service或者Manager,这些一般不被认为是VIPER模块的组成部分,而认为是外部依赖。
- Presenter: 包含与UI相关(同时与UIKit无关)的业务逻辑,会调用Interactor的方法。
- Entities: 普通的数据对象,但不是数据存取层,因为那属于Interactor负责的。
- Router: 负责VIPER各模块之间的转移。
VIPER模块既可以是单一屏幕,也可以是应用的整个user story,比方说用户认证,可以是一个屏幕也可以是多个相关的屏幕来实现。一块积木有多大,是由你决定的。
通过与MV(X)类的比较,可以发现在责任的分布上是有些不同的:
Model(数据交互)逻辑移到了Interactor,而Entity只是哑的数据结构。
Controller/ViewModel中,只有UI表达的职责交给了Presenter,而不包括改变数据的能力。
VIPER是第一个明确谈到导航职责的模式,由Router来解决。
分布:毫无疑问,VIPER是在职责分布上是做的最好的。
可测试性:更好的分布带来了更好的可测试性。
易用性:最后,和你猜的一样,上述两者需要付出的代价是可维护性。你不得不为类写大量的接口,每个只负责很小的部分。
iOS架构设计 优化:【界面卡顿】iOS 保持界面流畅的技巧
屏幕显示图像原理
1.电子枪在一帧之内一行行扫描,一行完成发出HSync水平同步信号
2.电子枪完成一帧后,进行下一帧扫描,同时发出VSync垂直同步信号
3.一帧一帧显示就完成了图像的显示
4.注意:显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率
CPU、GPU、显示器合作流程
CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示
图片撕裂产生原因
1.什么是双缓冲机制?
双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器
2.图片撕裂产生原因?
当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象
解决图片撕裂
当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象
目前市面手机系统的缓存策略
iOS 设备会始终使用双缓存,并开启垂直同步
安卓系统是三缓存+垂直同步(4.1之后)
界面卡顿产生原因
1.卡顿产生原因
由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因
一句话总结【cpu或者gpu没有完成内容的提交】
2.一帧提交需要的步骤:
步骤1:App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后
步骤2:CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上
解决卡顿
CPU 资源消耗原因和解决方案
1.对象创建【1,对性能敏感,用代码替换storyboard;2,对不需要响应触摸事件的控件使用CALayer替换UIView;3,推迟对象创建时间;4,尽量复用对象】
2.对象调整【1,尽量减少对象的属性修改,如frame、bounds、transform;2,尽量避免调整视图层次、添加和移除视图】
3.对象销毁【把对象捕获到 block 中,然后扔到后台队列去随便发送个消息以避免编译器警告,就可以让对象在后台线程销毁了】
4.布局计算【尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性】
5.Autolayout【使用AsyncDisplayKit】
6.文本计算【用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本】【用 CoreText 绘制文本】
7.文本渲染【用 TextKit 或最底层的 CoreText 对文本异步绘制】
8.图片的解码【在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片】
9.图像的绘制【放到后台线程进行】
GPU 资源消耗原因和解决方案
1.纹理的渲染【尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示】【纹理尺寸上限都是 4096x4096】
2.视图的混合 (Composing)【尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成】
3.图形的生成【可以用一张已经绘制好的圆角图片覆盖到原本视图上面来模拟相同的视觉效果】【最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性】
VSync 与 Runloop
1、iOS 的显示系统是由 VSync 信号驱动的
2、VSync 信号生成?
VSync 信号由硬件时钟生成,每秒钟发出 60 次(这个值取决设备硬件,比如 iPhone 真机上通常是 59.97)。
3、VSync怎么与Runloop工作?
iOS 图形服务接收到 VSync 信号后,会通过 IPC 通知到 App 内。App 的 Runloop 在启动后会注册对应的 CFRunLoopSource 通过 mach_port 接收传过来的时钟信号通知,随后 Source 的回调会驱动整个 App 的动画与显示。
【发出VSync 信号】------【iOS 图形服务接收到 VSync 信号】——【runloop通过mach_port收到VSync信号】——【source回调,驱动APP动画与显示】
简单就是:
【runloop收到VSync信号后开始工作】
Core Animation 在 RunLoop的工作机制
Core Animation 在 RunLoop 中注册了一个 Observer,监听了 BeforeWaiting 和 Exit 事件。这个 Observer 的优先级是 2000000,低于常见的其他 Observer。
当一个触摸事件到来时,RunLoop 被唤醒,App 中的代码会执行一些操作,比如创建和调整视图层级、设置 UIView 的 frame、修改 CALayer 的透明度、为视图添加一个动画;这些操作最终都会被 CALayer 捕获,并通过 CATransaction 提交到一个中间状态去(CATransaction 的文档略有提到这些内容,但并不完整)。
当上面所有操作结束后,RunLoop 即将进入休眠(或者退出)时,关注该事件的 Observer 都会得到通知。这时 CA 注册的那个 Observer 就会在回调中,把所有的中间状态合并提交到 GPU 去显示;如果此处有动画,CA 会通过 DisplayLink 等机制多次触发相关流程。
iOS架构设计 应用架构-网络层
- 网络层跟业务对接部分的设计
- 网络层的安全机制实现
- 网络层的优化方案
以Delegate为主,Notification为辅。原因如下:
尽可能减少跨层数据交流的可能,限制耦合
统一回调方法,便于调试和维护
在跟业务层对接的部分只采用一种对接手段(在我这儿就是只采用delegate这一个手段)限制灵活性,以此来交换应用的可维护性
尽量减少适用Notification
使用Notification来进行网络层和业务层之间数据的交换,并不代表这一定就是跨层数据交流,但是使用Notification给跨层数据交流开了一道口子,因为Notification的影响面不可控制,只要存在实例就存在被影响的可能。另外,这也会导致谁都不能保证相关处理代码就在唯一的那个地方,进而带来维护灾难。作为架构师,在这里给业务工程师限制其操作的灵活性是必要的。另外,Notification也支持一对多的情况,这也给代码散落提供了条件。同时,Notification所对应的响应方法很难在编译层面作限制,不同的业务工程师会给他取不同的名字,这也会给代码的可维护性带来灾难。
Notification应用场景
跨层的时候使用,一对多的时候使用
不使用block的好处
【看苹果官方写的代码就很少用到block,就知道block的缺陷,而delegate是经常使用的】
block会延长相关对象的生命周期
block会给内部所有的对象引用计数加一,这一方面会带来潜在的retain cycle,不过我们可以通过Weak Self的手段解决。另一方面比较重要就是,它会延长对象的生命周期。
在网络回调中使用block,是block导致对象生命周期被延长的其中一个场合,当ViewController从window中卸下时,如果尚有请求带着block在外面飞,然后block里面引用了ViewController(这种场合非常常见),那么ViewController是不能被及时回收的,即便你已经取消了请求,那也还是必须得等到请求着陆之后才能被回收。
然而使用delegate就不会有这样的问题,delegate是弱引用,哪怕请求仍然在外面飞,,ViewController还是能够及时被回收的,回收之后指针自动被置为了nil,无伤大雅。
block应用场景
用在集约型的调用场合
block和delegate乍看上去在作用上是很相似,但是关于它们的选型有一条严格的规范:当回调之后要做的任务在每次回调时都是一致的情况下,选择delegate,在回调之后要做的任务在每次回调时无法保证一致,选择block。在离散型调用的场景下,每一次回调都是能够保证任务一致的,因此适用delegate。这也是苹果原生的网络调用也采用delegate的原因,因为苹果也是基于离散模型去设计网络调用的
在集约型调用的场景下,使用block是合理的,因为每次请求的类型都不一样,那么自然回调要做的任务也都会不一样,因此只能采用block。AFNetworking就是属于集约型调用,因此它采用了block来做回调。
总结:
尽可能通过Delegate的回调方式交付数据,这样可以避免不必要的跨层访问。当出现跨层访问的需求时(比如信号类型切换),通过Notification的方式交付数据。正常情况下应该是避免使用Block的。
对于业务层而言,由Controller根据View和APIManager之间的关系,选择合适的reformer将View可以直接使用的数据(甚至reformer可以用来直接生成view)转化好之后交付给View。对于网络层而言,只需要保持住原始数据即可,不需要主动转化成数据原型。然后数据采用NSDictionary加Const字符串key来表征,避免了使用对象来表征带来的迁移困难,同时不失去可读性。
iOS架构设计之冗余性思考
机制与策略分离。我们希望设计的是一整套能够满足上述要求的协议,其次才是实现,最后才是在我们的APP中的具体应用。
在项目设计初期,我们可以通过去预判功能走向,来进行软件设计。透过表面UI和业务逻辑,去看背后的底层变化逻辑。而这也正是我一直比较看好的:机制与策略分离比较好的应用。
框架和架构
框架和架构的关系可以总结为两句话:(1)为了尽早验证架构设计,或者出于支持产品线开发的目的,可以将关键的通用机制甚至整个架构以框架的方式进行实现;(2)业界(及公司内部)可能存在大量可供重用的框架,这些框架或者已经实现了软件架构所需的重要架构机制,或者为未来系统的某个子系统提供了可扩展的半成品,所以最终的软件架构可以借助这些框架来构造。