从无到有,开发一个 App(二)--- 技术选型以及前期准备

0x00 目标产品决定技术方案的选择

短生命周期和长生命周期产品

短生命周期的产品通常要求快速起步并在短时间内出品,目的性极强,技术门槛低、代码随便写、不用考虑任何最佳实践。当它的使命结束时,这些代码会被直接抛弃。比如 项目 Demo,临时活动专用 App。 所以,对于这类产品类似前苏联式的 “快糙猛” 的技术是较好的选择。当然能 “快精猛” 更佳,但在现实的短周期开发中实际上很难做到。

而长生命周期的产品则会对可维护性和可扩展性要求十分强烈,因为它们在相当长的时间内都是无法报废的。甚至对于一些关键的生命线产品,连项目重写都会要求在重写期间线上系统要万无一失地平稳度过,完全平滑地迁移到新技术。这种高水平的要求对团队的工程化能力是个极端的考验。如果工程以及项目管理能力有限,其代价不比用新技术重新写一个功能相同的系统更低。

探索型和成熟型的产品

探索型产品往往也是短周期产品,但这类项目与上面所说的用后即丢的产品有着显著区别,为了尽快打开或者占领市场它要求开发速度尽可能快,但往往同时也会对质量有高要求。因为探索型的产品的可行性如果得到肯定,那么它很有可能会转型为一个长生命周期项目。

在我看来对于这种项目的最理想实践应该是由一个资深的架构师设计一个可扩展性和可维护性都很强的核心系统架构,其他功能点的开发完全基于这个高度灵活可维护的架构。这一点其实很多客户端和前端开发框架(尤其是前端框架)可以帮我们做到。

另一个方面是进行这样的开发时,我们需要一套随项目演进的单元测试以及自动化回归测试系统来保证项目在未来演进时的可靠性。因为探索性的项目一旦如期转换为一个长周期项目,那么对项目的可靠性和可维护性要求会变高。由于先前开发十分迅速,代码质量以及模块结构的设计可能并不能适应日益变化的需求,这就需要大量的重构和重写的工作,但是在项目蒸蒸日上的阶段这样的工作很可能上导致严重的 BUG,那么自动化的质量控制甚至自动化的重构工具都是可以提升软件质量的途径。

不过,也可以采取十分激进的“快糙猛”的战术,但这要求团队要做好项目中期维护成本上升,维护难度变大的心理准备,更严重的是可能不得不面临全部重写的局面。

而对成熟型产品的选型则会侧重于与团队现有技术栈的相似程度和与现有项目无缝整合的能力。如果整合的过程中有很大的侵入性,或者所用技术需要大量 hack,那么很可能这种技术方案对项目来说是一个大坑。

在引入新技术的过程中,也应该尽可能符合现有的开发流程、基础设施和开发的习惯。如果这些旧的方案已经严重过时,则可以联合老技术方案的核心开发一起来规划一个进化路线图,让老技术平稳过渡到相对较先进的技术方案。还有要说的是,如果老技术已经有新版本,应该优先考虑升级到新版本。幻想换个技术栈就能解决一切问题是不切实际的。事实上由于新技术产生时间短,社区规模可能较小,应用规模不大,问题未能广泛暴露导致由新技术带来的问题往往会更多,甚至会遇到无法以合理代价解决的问题。比如 iOS 引入的 WKWebkit 带来的 Cookie 以及 API 方面的问题,又或者 RN 方案导致无法直接使用依赖 DOM 的前端框架(比如 Highcharts 图表库)。

边缘产品和生命线产品

在人员的学习能力和意愿允许的前提下,边缘产品是最佳的试验场,适合探索各种候选技术,试验各种激进方案,积累经验教训。其影响范围可控,即使失控也不会带来太大的损失。当然,即使探索,也应该有计划地探索,不要每个边缘产品都采用不同的技术方案,那样会给人才供应带来巨大的挑战。

而生命线产品则应该稳妥优先,采用保守方案。所以应该优先采用团队内部积累了一定经验或具有稳定的强力外援的技术。

所有的生命线产品几乎必然是长周期产品,所以其可维护性同样是重中之重。

结论

在目标产品维度上,低价值产品优先考虑门槛低的技术,但是高价值产品应该尽早进行投资性技术积累,优先考虑一些天花板高的技术,这样才不至于在若干年之后因为各种原因(团队技术能力进化空间方面或者项目维护方面)被迫重写。由于项目年久月深,如果工程化能力不足,这种重写往往会成为灾难。

0x01 几种主流的客户端技术方案

在客户端开发技术快速演进的几年里,许多技术方案涌现了出来。在众多的技术方案的更新迭代中,我们可以发现一些规律。这些技术的关注点主要都在解决跨平台和热更新的问题。早期关于热更新的探索产生了 JSPatch, OCScript 等方案。而这种对于热更新的探索的研究慢慢拓展到了和跨平台特性结合的研究中,而我们熟悉的 Xamarin, Cordova,ReactNative,Weex 以及最近比较热门的 Flutter 都是这些研究催生的产物。

相对一般的 App 而言,游戏客户端在上面的道路上早已经走得很远了,Cocos2d-x, Unity3d, Unreal Engine 等及跨平台和热更新及一身的引擎层出不穷,不过这些不是我们讨论的重点。至于为什么不会用游戏引擎来开发 App,请参见知乎上的这个回答

纯原生 + JSPatch

这种方案是一种支持热更新的终端开发方案,这项技术诞生于 2015-2016 年左右的 iOS 平台。主要应用的还是 iOS 和 安卓的原生开发技术,在此基础之上辅以 JS 代码为载体的热更新机制。热更新的主要原理是通过 JavaScriptCore 来执行开发者下发的 js 脚本,将包含在脚本中的 OC 代码端提取出来。再通过 Objective-C 的运行时的反射特性,修改类,方法的实现;创建新的协议,类或者方法;在运行时加载原来没有的运行库等等。

这个方法非常灵活,几乎可以解决一切的原生补丁问题。但这种过于灵活的性质也让 JSPatch 变得十分危险。应用基于这种方式可以绕过 AppStore 的审核实现对系统私有 API 的调用,从而破坏 iOS 的沙盒机制,威胁到用户的安全。这也是苹果所不允许的,目前使用这项技术的应用基本无法通过 AppStore 上架审核。

Xamarin

Xamarin 最初是由 Xamarin 公司开发提供的一基于 Mono(一个 C# 跨平台开发方案)的 iOS 和 Android 应用开发的解决方案。2015
年这套方案被微软收购,成为 Visual Studio 中的一个组件。

虽然它实现了两端代码都用 C# 编写,但是对于平台相关的 API, Xamarin 只是做了翻译,而并没有去试图抹平平台间的差异。也就是说虽然你可以用 C# 编写一些公用的业务逻辑代码,但是平台相关的代码依然要写两份。虽然这样相对较薄的封装有利于应对平台日后的发展和变化,但是对于开发者来讲,依然没有显著降低学习成本。而且最致命的问题是它的社区不活跃,软件相对封闭遇到棘手问题的时候很难迅速解决。

ReactNative

React Native 确实是最近最火的跨平台App解决方案了。它脱胎于React,因为 React 基于 Virtual DOM 来进行界面渲染,所以用 Native 的 View 来替换掉原本 React 的 HTML DOM 就形成了 React Native 这个框架。

虽然大部分代码是平台无关的,但是平台相关的代码都交由开发者进行统一的封装和实现,这虽然对跨平台带来了不便,但是引入的好处也是显而易见的,View层的部分通过原生组件实现,性能相对 H5 页面来说要高不少。

RN 对于热更新也是十分友好的,由于 RN 的全部 js 代码都会被打包在一个 jsbundle 资源文件中,所以我们只需要下载并且替换 jsbundle 文件即可实现对 js 业务逻辑的更新。

但是 RN 也不是没有缺点,首先 RN 提供了一个类似 CSS 的样式系统,但是由于平台差异以及官方开发力度的问题,这套布局系统存在很多兼容性问题,bug 以及缺少很多必要样式的支持。有些问题在开发中会变成一个阻塞性的障碍,即便有解决方案,实现可能也会是极其复杂的。

再者,由于没有 DOM,也不支持标准的 SVG,许多涉及 UI 操作的 js 框架都是无法在 RN 中工作的。有人会说,不能用的话就自己写一个呗,那么比如 Highcharts 这种极其复杂的图表框架,显然不是所有团队都有能力和时间随随便便重写一个的,那么这时候就不得不用 webview 来展示这些必要的视图,这样一开实际上开发效率是很差的。

调试能力不足,开发基础设施差也是一个不能忽视的问题,基于 Chrome Dev Tool 的调试工具经常会莫名其妙的用掉 6,7GB 内存,甚至整个电脑都被拖得变卡。调试需要将手机和电脑连接到同一网段,安卓机需要手动运行 adb 命令才可以实现调试。调试器也经常卡死,尤其是调试进行到最关键阶段的时候,调试器突然变得很卡,单步调试都变得极为困难,这会让人有一种要疯掉的感觉。

还有一个严重的问题是,RN 框架迟迟未能进入 Release 版本。各种破坏性的改动依然在每个新版本中出现,这就导致框架更新变得十分困难。而且不知出于什么原因,RN 的新版本中经常会出现以前旧版本中已经修改过的 bug,作为一个框架来讲这样的稳定性和维护的水平实在是值得推敲。

Flutter

Flutter 是谷歌研发的一个新的跨平台开发技术,使用 Dart 强类型语言作为开发语言。作为谷歌的下一代操作系统 fuchsia 的应用开发框架,的被谷歌寄予厚望。目前 1.0 版本已经释放出来,也有一些 App 将这个框架用在生产环境中,比如闲鱼

框架的优点,可以在网上搜一下,有很多布道文章都在吹捧 Flutter, 这里讲一些我认为 Flutter 可能存在的局限性。

  1. 不支持热更新,由于 Flutter 在 Release 模式下使用 AOT 方式运行,实际上就是将 Dart 编译成机器码直接在 Dart 虚拟机上执行,这样的运行方式就导致无法通过下发补丁包的方式来直接更新代码。官方曾经在 2019 年的 Roadmap 中提出要关注热更新的问题,但是最近官方也由于苹果的政策问题以及性能问题正式放弃了热更新的开发。

  2. 无法与现有的前端技术社区提供的解决方案兼容,由于使用了 Dart 和 Dart 虚拟机,Flutter 完全不支持 js 库,而 Dart 库又远远不及 js 库那样丰富。

  3. 对于图表和图形类的应用,支持十分有限,目前还没有任何完善的图表和图形库支持 Flutter。

  4. 由于 Flutter 底层完全使用 Skia 绘图引擎来代替各个平台提供的视图组件,Flutter 无法很好地和原生视图一起工作,常见的场景比如嵌入式的地图组件在 Flutter 中的实现会变得异常复杂,甚至无法实现。

  5. bug 依旧较多,项目仍不稳定,尚未解决的 Issue,截止现在还有 6018 个之多。而且 Flutter 应用的底层技术较为复杂,一旦框架出现问题,难以解决。

Webview 方案

Webview 方案是一个比较早期的跨平台方案,大多数 App 也都或多或少会用到 Webview。使用这一方案作为跨平台 App 解决方案的框架,最出名的就是 Cordova 了。虽然这是个很老的技术,但在我的理解来看,即便在今天依然可以发挥它的作用。尤其是多 Webview 的模式,甚至可以解决很多其他方案解决不了的难题。

直接使用各个平台内建的 Webview,再辅以 JavaScriptBridge 用于 Web 和原生层之间的通信是这个技术方案的基本原理。使用标准的浏览器,可以让开发者将更多地经历放在前端开发商,而且开发者的选择也更加丰富,只要是 js 组件基本上就都可以随心所欲的使用。

Cordava 基于 Argular2+ 和 AngularJS,使用单页面模式开发,在实践中动画性能存在一些问题,尤其是在安卓平台的页面切换过程中,卡顿尤为明显。

由于 Webview 本身可以是一个原生页面,多 Webview 的方式不但可以实现保持页面的状态,并且可以直接使用原生的页面切换动画已达到和原生 App 近似的使用体验。

热更新同样也不是难题,对于页面文件放在本地的方案,我们只需要更新这些文件即可。如果有的页面被放在远端,那么我们无需考虑热更新的问题,只要将新版本的页面进行发布就可以实现全量客户端的更新。

0x02 架构设计

对于客户端的架构设计,我觉得可以简单分为三个大块,第一块内容是客户端系统结构的设计,这部分要解决的问题是,客户端要采用哪些技术,每项技术在系统中处于什么层次,负责什么样的功能;第二块是 UI 框架,包括设计一个合理的可扩展的主题系统,以及扩展性较强的基本组件封装;第三块是对数据流的管理,主要任务是解决如何设计数据流动的路径,让应用在长期发展的过程中保持好的可维护性。

系统结构

系统中会涉及到原生和非原生的系统组件,原生组件应该承担诸如 SDK 接入,系统能力调用,通用接口实现等职责,而 UI 相关的能力应该尽量放到非原生或者说跨平台的部分实现,这样可以降低开发的成本。但是遇到 UI 的性能瓶颈,不得不使用原生开发的情况应属例外。

UI 框架

由于 UI 的定制化程度和变化的可能性都很高,这一层可以做的比较薄,实现一个轻量的主题系统同时只对基本组件进行简单封装,尽量薄的封装可以提供很强的扩展性和可维护性。

数据流管理

软件架构的一个重要任务之一就是组织应用中的数据流,组织应用数据流的方法多种多样,常用的有 MVC,MVVM,Redux 等。

![Alt text](bfebe9bc-902e-4550-b295-0c8b8bece03b.png)
MVC

这个模式认为,程序不论简单或复杂,从结构上看,都可以分成三层。

  1. View 传送指令到 Controller
  2. Controller 完成业务逻辑后,要求 Model 改变状态
  3. Model 将新的数据发送到 View,用户得到反馈
![Alt text](f4ece01a-6eb4-427d-a07b-d748dd465ba1.png)
MVVM
  1. 各部分之间的通信,都是双向的。

  2. View 与 Model 不发生联系,都通过 ViewModel 传递。

  3. View 非常薄,不部署任何业务逻辑,称为”被动视图”(Passive View),即没有任何主动性,而 ViewModel 非常厚,所有逻辑都部署在那里。View 和 ViewModel 之间通过双向绑定来实现数据的同步。

![Alt text](918e01e3-1182-4330-b695-e0a8280fa646.png)
Redux

关于 Redux 的概念,可以参见这篇文章

三种模式各有各的优缺点,网上的分析也很多,这里我不想重复这些内容,下面谈一点我自己的感受。

网上很多观点认为 MVVM 是 MVC 的进化,主要的论点是说 MVC 会导致 Controller 中代码过于臃肿。而 MVVM 的双向数据绑定以及 View 和 Model 的解耦解决了这个问题。其实多了解一些信息就会发现 MVC 模式并不只一种形式,还有很多其他的变形。有一些变形比如 MVP
Alt text
和 MVVM 十分相似。难道只是在三者之间简单改变数据流向或者添加数据绑定就可以解决 controller 臃肿的问题吗?我认为还是远远不够的,真正解决问题还是需要将业务逻辑从 controller 中分散出来,分散到 service, adaptor, model 中去才能解决臃肿的问题。而数据绑定只是减少了更新数据的逻辑,这部分逻辑其实相对而言只占用了少数的代码量,并不能从根本上解决问题。

Redux 看似简洁明了,单向数据流很清晰。但实际上自定义 Action 和为了保障数据不可变性而编写的大量模板代码让开发过程略显繁琐。

因此,我认为设计架构不光要遵守某种教条的逻辑,更重要的是思考问题发生的根源,然后在一个基础上去改进设计。

0x03 准备脚手架

代码生成

在创建视图,控制器或者数据模型的时候的时候免不了要写许多模板代码。软件工程的前辈们告诉我们,能够让电脑自动完成的事情就不要手动去做。代码生成器就是一个能够很大程度上解放生产力的工具。一旦开发模式固化下来,就应该着手去写一个这样的工具供开发人员使用。一键生成模板代码是一件非常爽的事情,大家可以将更多的时间和精力投入到有效的业务代码开发中。而且自动化的工具在很大程度上可以防止低级错误的出现,在这个层面上也有助于提升软件质量。

构建方案

作为客户端项目来说,构建打包是一个必经的过程。对于小型项目来讲,可能构建和打包就是点击一下 IDE 上的 Build 按钮这么简单。但是对于有复杂依赖的大型项目来讲,构建就是一个过程很繁琐的过程,远不是单纯依靠 IDE 可以实现的。

以一个成熟的 ReactNative 项目为例,构建和打包需要经历以下几个过程:

  1. 安装 iOS / Android 依赖包
  2. 安装 js 依赖包
  3. 进行代码质量检查
  4. 构建 ReactNative 资源包
  5. 对资源包进行签名
  6. 为资源包添加平台以及版本号信息
  7. ReactNative 资源包压缩
  8. 将 ReactNative 资源移动到原生资源目录
  9. 构建 iOS / Android 项目原生代码(在此过程中需要确保 iOS 证书的正确性,如果在开发者账户中添加了新的设备 ID 需要更新证书以确保构建包可以在目标设备上使用)
  10. 为构建结果添加版本以及构建时间等信息
  11. 将构建结果输出至目标路径
  12. 清理工作空间

可以看到想要得到一个可安装的 ReactNative App 包要经历如此复杂的步骤,这些步骤如果是依赖手动操作的话,想必几乎不可能一次性没有失误地通过。而构建脚本就是来解决这个问题的一个手段。

shell 脚本

由于涉及到的很多操作基于操作系统提供的工具链以及其他应用提供的命令行界面才能完成,编写 shell 脚本是打造一个构建方案的必备能力。但是 shell 脚本有先天的劣势,语法较现代语言来讲比较晦涩,语法的容错性和灵活性也比较差,多一个或者少一个空格就会导致语句无法执行。调试也只有日志输出这个唯一的办法。在构件流程随着项目发展越发复杂的时候,shell 脚本较差的可维护性会变成项目的一个炸弹,可能在某次修改之后,构建会产生意料之外的结果进而造成质量问题。

fastlane

为了解决单单依靠 shell 带来的问题,fastlane 被发明了出来,虽然在操作系统的框架之上,使用 fastlane 的构建过程也不能摆脱依赖 shell 的命运,但是由 ruby 编写的 fastlane 针对客户端构建提供了很多包装好的 ruby 方法,包括调用构建工具,执行 shell 指令,解决 iOS 证书相关问题等。使用这些方法可以方便地使用现代语言实现一些功能。即便当你不得不使用 shell 的时候,fastlane 提供的 shell 脚本包装方法也有助于将 shell 脚本产生的问题控制在最小的区域。统一的异常处理机制为脚本的可靠性提供了有力的保障。

0x04 参见

  1. Fastlane
  2. 用游戏引擎(如Unity)开发一款App应用有什么优势或劣势?
  3. 闲鱼开源 FlutterBoost:实现 Flutter 混合开发
  4. 坏消息:Flutter官方暂时不会开发热更新(Code push)了。
  5. Redux 入门教程(一):基本用法
  6. 贫血充血模型