风行的博客

iOS 应用的开发设计

实现功能是软件开发的最低要求。




一个 app 从设计到发布应用商店会经历需求设计、UI设计、架构设计、开发、优化、测试等流程,每个环节做的好与坏都会影响到整个 app 的质量,作为开发人员,不仅要对需求以及 UI 的合理性进行评审,还要保证提供给测试的代码是经过自测并且覆盖了相关测试用例,更重要的是要做好架构设计、开发以及优化这三个环节。

架构设计

架构设计可以以业务为驱动,按照最适合自己的方式去设计,设计思路和方法尽量统一,同类型问题用同样方式去解决,下面几点是我认为比较重要的:

  • 关注分离
    将系统分解为多个模块并各司其职,纵向分层,横向拆分业务。

  • 高内聚
    一个模块只完成一个功能,并合理有度的进行封装。

  • 松耦合
    依赖关系越少越好,尽量不横向依赖,不跨层访问。继承是紧耦合的一种操作,它通常与多态一起存在,如果仅仅是为了代码重用而做的继承还不如用组合。

  • 适度设计
    对业务方该限制的地方要进行限制,该灵活的地方也要给业务方创造灵活实现的条件,架构的设计要保持一定量的超前性而做到易扩展,但不要设计过度,避免多做无用功而增加框架复杂度。好的架构是随着时间而更新的,而不是一开始就设计好了一切。

  • 技术选型
    不要仅是为了体验新技术而改变现有架构,等别人把坑踩平后再根据团队及业务情况考虑是否使用,技术本身没有什么好不好,只有适合不适合。


响应式编程

响应式编程是一种面向数据及其变化响应的编程方式,相比传统编程方式来说,它可以使逻辑相关的代码能够紧凑的聚在一起,它通过信号机制来实现,用信号来记录值的变化,并通过订阅的方式来响应变化,下面这个例子很好的描述了响应式编程的作用

1
2
3
4
a = 2 // signal
b = 2 // signal
c = a + b // signal binding c = 4 
b = 3 // c = 5

目前我们在 Swift 项目中选择使用 RxSwift + MVVM 的方式进行开发。

组件化开发

随着 app 功能和体积的增长,也带来了一些问题

  • 编译速度缓慢
  • commit 代码经常要 merge
  • 业务代码混在一起、开发功能类似的新项目时需要从头搭建

拆分组件

  • 每个组件对应一个 project,身为组件的同时还能够独立运行和打测试包
  • 大点的项目除了按框架分层拆分还可以按业务模块拆分,需要把握好拆分粒度
  • 小项目按框架分层拆分就可以了

组件之间的依赖关系

  • 业务模块依赖于框架
  • 业务模块之间避免横向依赖(可适当的有些重复代码或存放重复资源)
  • 禁止框架依赖业务模块

组件化实施

通过 CocoaPods 来实现组件化

  • Podfile:定义组件以及第三方库的依赖关系
  • Podspec:定义组件的依赖库、编译设置、以及需要打包的资源及代码,资源包括 xib、国际化文件、assets 等

组件维护

需要调试其它组件代码时可通过本地 link 方式去组装项目,也可以直接去 Pods 目录下修改,但要及时将修改好的代码迁移出来,否则代码会有丢失的危险,当组件变更后需要修改 podspec 中的版本号,然后通过 pod repo push 命令更新仓库,最后依赖方要按指定版本号更新,并且在使用 pod update 时不要加 —-no-repo-update

组件中的资源访问

首先要在代码中正确使用 open、public、private,另外访问资源需要指定 bundle,例:UIImage(named, bundle),有种简单的办法就是使用 R.Swift 提供的功能来访问资源,在使用时首先要添加下面角本到 Run Script,其中第二行角本的主要目的是替换 R.generate.swift 中关于 bundle 的设置,主要目的是让组件内写的 R.image 这种代码能够在别的组件中也正常使用,R.Swift 不支持跨组件访问资源,自行修改的话成本太大

1
2
$PODS_ROOT/R.swift/rswift" generate "$SRCROOT" --accessLevel public
sed -i '' -e "s/Bundle(for: R.Class.self)/Bundle.core/g" “$SRCROOT/

Bundle.core 内容如下,每个组件都要去扩展 Bundle 类,添加自己的 bundle 名称

1
2
3
4
5
6
7
8
9
10
11
12
13
extension Bundle {
    public static var core: Bundle {
        let bundleName = "ProjectCore"
        
        if let path = Bundle(for: BaseViewModel.self).path(forResource: bundleName, ofType: "bundle") {
            let bundle = Bundle(path: path)!
            bundle.name = bundleName // name 是自已扩展的属性,相关代码不再列出
            return bundle
        }
        
        return Bundle.main
    }
}

组件之间的路由跳转

网上有很多关于这方面的文章,这里不再多说,只介绍两个小功能

  • 打开页面时指定是否需要登录,然后将原本想要跳转的页面信息及参数传递给登录页,当登录成功后直接跳转到该页面,并将登录页本身从导航堆栈中移除

  • 返回页面时除原来的三种方式外,增加锚点方式返回,例如在购买商品页面填加锚点,然后当页面流转到购买成功时,可将导航堆栈反转过来,pop 到最近的一个锚点页面

组件的多环境打包

App 运行环境通常有测试环境、预上线环境、正常环境三种,这里分别用 QA、STG、PRD 表示,有些公司还会有企业开发者账号,用来自己分发项目更方便的去做测试工作,下面针对这种情况做多环境打包设置

  • 通过 xcconfig 方式在原先的 debug、release 上扩展出 staging、enterprisePRD、production,切换环境时只要在 Scheme 中的 Build Configration 中设置一下就可以了(对于 xcconfig 这里不做详细介绍)
  • 上面列出的 debug 和 release 对应 QA,enterprisePRD 和 production 对应 PRD,其中 production 作为正式打包上线的设置项
  • xcconfig 文件中可针对不同环境设置不同的 app 名字,还可以对 enterprisePRD 设置不同的微信Id 或其它内容

优化

安全

我们从 AppStore 下载的 app 其实都已经由苹果加壳,要砸壳后才能有办法看到汇编形式的源代码,如果我们要自己再加一层壳,可以对关键代码的方法名做一些混淆,以增加别人分析代码的复杂度,不过这对于大多数 app 来说不太值得去做,我们可以把精力放在其他更易做并且更有效率的地方。

本地数据

存储在沙盒里的文件是能够被拿到的,所以对于 sqlite、plist 等文件不要存储重要信息,如果一定要存,可以将信息用对称加密(AES)的方式处理后再存储,用于加密的 key 不要以明文的方式写在代码里,可以在 app 启动时通过 Https 方式管后台要一个非对称加密过的 key,并且这个 key 后台应该定期变更。

网络数据

Https 可以用来确认网站的真实性,防止用户误入钓鱼网站,还可以保证数据传输的安全,不过即使用了 Https 也要对一些关键数据或 URL 参数再次加密,因为数据还是有可能被人拦截拿到。(当用户使用不安全的 WIFI 并且请求被代理拦截后,代理伪装用户去请求服务,并对拿到的证书进行解密骗取公钥,然后再用自己做的假证书伪装成服务器证书传递给用户骗取信任。)

  • 对称加密:文件加密和解密使用相同的密钥,AES 相对其他对称加密方式来说更安全些。
  • 非对称加密:需要公钥和私钥,用公钥对数据进行加密,只有对应的私钥才能解密;如果用私钥对数据进行加密,那么只有对应的公钥才能解密。可以用 RSA 来做非对称加密,相对对称加密来说,速度较慢,但非常安全,据说还没被破解过。
  • 单向认证:只要求站点部署了 SSL 证书就行,并且客户端要信任服务端证书,我们可以在代码里设置为不支持无效证书,并且对域名做验证,一般应用都是用单向认证。
  • 双向认证:需要服务端与客户端都提供证书,并且他们之间相互信任,安全性相对要高一些,适合对安全性要求比较高的应用。

启动时间

可以通过 Instruments 的 Time Profiler 工具来查看耗费时间,也可以通过在 Xcode 中修改项目的 scheme,在 Run -> Auguments 增加环境变量 DYLD_PRINT_STATISTICS 并设置为 1,然后观察控制台打出的 log 来查看

  • pre-main 阶段系统会由 dyld(the dynamic link editor)加载相关动态链接库,然后再初始化 runtime 环境,并由 runtime 触发 + load 方法,所以不要在 load 方法里执行耗时任务,否则会影响启动时间,并且用不到的代码文件要及时删除。
  • 检查 didFinishLaunchingWithOptions 方法,将部分操作改为异步执行或延迟处理

App 瘦身

  • 将部分资源文件从工程中移除,改为从接口获取,并且可以通过给数据加版本号,避免每次重复下载。
  • 清理无用的代码文件和资源,不要 @1x 图片,如果用到了 ProtoBuf,可以将 .proto 文件从 Compile Sources 中移除。
  • 压缩图片,目前发现 tinypng.com 的压缩效果最好,而且是无损的。
  • 小图标可以使用 iconfont 替代,大图可以用 pdf 替代
  • 启动图界面用 LaunchScreen.storyboard,不要用多张 Default 图。
  • 用 .xcassets 来管理图片,Xcode 能够把里边的所有 png 图片压缩成 .car 文件。并且 iOS9 之后,如果是用 .xcassets 管理图片,AppStore 会根据不同设备准备不同的安装包,每个包内只有对应尺寸的图片,例如 iPhone6 下载 app 时,包里就只有 @2x 图。
  • 考虑放弃 iPhone5s 之前发布的 32 位手机

流量

  • 合理使用本地缓存数据并及时更新缓存
  • 考虑用 ProtoBuf 替代 JSON,也可以仅是个别数据量大的接口改为 ProtoBuf 格式数据
  • 需要轮询的接口可用 WebSocket 替代
  • 请求的图片格式由 jpg 改为 webp
  • 按 UIImageView 大小取合适大小的图片,不要直接取原图
  • 非关键的业务数据,可以通过合并接口的方式,减少和服务器的交互次数。
  • 请求接口前客户端通过对 request params 做检查来减少不必要的网络请求
  • 可以用 ETag 这种服务端加本地验证的方式处理返回数据,当数据没有更新时不返回数据,而是返回 304
  • 数据量大时要提示用户是否在非 wifi 环境下下载

UI 交互

  • 延迟处理不需要马上展现的视图或操作
  • 不要阻塞主线程,能异步去做的就不要同步。
  • 由于 UIKit 中大部分对象都不是线程安全的,所以 UI 操作都需要放在主线程做串行处理,否则可能会导致未知行为(动画异常、页面错乱、Crash)。
  • 优化表格滑动
    • 提前算 Cell 高度并缓存,如果要加载的数据也是要经过处理才展示的也提前处理好
    • 异步绘制,滑动过程中不加载图片
    • 当 Cell 中有很多子视图并且他们要在不同条件下展示时,考虑拆成多个 Cell
    • 不要在 Cell 中频繁生成 NSDateFormatter 对象
  • 通过设置模拟器的 Debug 菜单下的几项设置来查看图层视图是否需要优化
    • Color Blended layers
      大多情况是因为视图的 backgroundColor 与父视图颜色不一致或者是透明的,它会用红色表示有问题的视图,而绿色的表示没问题
    • Color copied images
      当图片的色彩格式不能被 GPU 处理时,会交由 CPU 处理,应该尽可能避免这种问题
    • Color misaligned images
      图片大小与控件不一致,会用黄色表示图片被缩放了,用紫色表示像素没对齐
    • Color offscreen-rendered
      用黄色标示哪些 layer 需要做离屏渲染,多数时候离屏渲染会影响性能,应避免重写 drawRect 方法,避免使用 masksToBounds(设置圆角/模糊效果)、shouldRasterize(光栅化)、shadow(阴影)

PS:过早的优化是万恶之源!