Matrix 首页推荐 

Matrix 是少数派的写作社区,我们主张分享真实的产品体验,有实用价值的经验与思考。我们会不定期挑选 Matrix 最优质的文章,展示来自用户的最真实的体验和观点。

文章代表作者个人观点,少数派仅对标题和排版略作修改。


💡 免责声明:这套方案只是最适合我们这个小团队的,本文不表示其任何优越性。 

💡 终极免责:以下内容仅代表作者个人观点。

我们为什么会写这样一篇文章

Hi!自我介绍一下,这里是「及游册」App 的开发者。和很多独立开发的个人或者小团队一样,我们没有专门的技术团队(家徒四壁,我们 2 人都是为及游册写代码的程序员)。

这篇文章的主笔是我们的主力开发:wifi。他是一个高中就有梦想成为独立开发者的人,年纪轻轻但是自称「10 年老安卓」(别信,虚标的,还没到 10 年)。因为对技术的追求,他觉得对于做独立开发的同路人们来说,运营固然是重中之重(确实是最重要的),但是技术也在大家实现想法的路上占了很大的一部分,甚至也成为了很多独立产品发展的阻力。

即便开发任务总是会占据日常迭代的相当一部分时间,我们相信:「对于我们而言,技术是不值钱的。」 

准确地说,我们希望技术的实现应当是相对「Cheap」的。我们也很感激,因为有很多优秀的官方的非官方的框架在帮我们让开发变得更简单。所以我们策划了这期文章,希望尽自己的绵薄之力为大家带来一份适合独立产品的技术架构分享,也希望这篇文章给永远在重构路上的你带来一点点的灵感。

1. 概述

🔴 TL;DR: 客户端:KMM + Compose + SwiftUI。服务端:AWS Serverless。

首先,「及游册」是一个有服务端的项目(写完这句话感觉耳边听到了不少 iCloud 选手的嘘声),但且慢,我们把对于「服务端」的探讨留在后面的章节,让我们暂时忘记这一 part。

对于大家都在做的客户端,「及游册」采用了跨平台技术,但是是一个 「十分保守的跨平台方案:KMM (官方已经把它合并到 「KMP」 这个名词里面啦)。

我们对 KMM 的总结就是:尽管它不会帮我们实现 UI 的跨平台实现,但是它有助于我们实现更干净的代码分层架构,因此它值得。 用数据说话,我们客户端 Git 仓库中,Android、iOS 和 KMM 的代码量占比大致在 1:1:1 。这其实也出乎我们的意料,但是它确实帮我们省掉了不少代码量。

至于客户端的 UI 部分,Android 端我们使用 Jetpack Compose,iOS 则是 SwiftUI。

至于 SwiftUI 我惊喜地发现:认识的独立开发者们中使用率还是蛮高的。丢下大厂的 OC 包袱并且提高 Target 之后带来的就是非常简单的 UI 开发体验,毋庸置疑。

保命声明:

  1. 反正目前为止大部分 SwiftUI 组件的底层实现也还是 UIKit,这么说应该不冒犯 UIKit 开发者吧?
  2. 但是 SwiftUI 的 Bug 都很奇怪,某种程度上也算一把双刃剑了😮‍💨

而 Jetpack Compose 就相对不那么普及了,通过 LibChecker 可以直接看出手机上安装的应用使用了什么技术。一顿看下来真是令人唏嘘,作为一个 Android 开发者我衷心希望 Jetpack Compose 能够被更多国内开发者采用,多翻翻 Android Developer 的 Medium 我们真的能发现 Compose 用了非常多的心思和非常巧妙的设计来强制开发者不要去打破 UDF(Unidirectional Data Flow)原则,多写写 Compose 真的会帮助任何一个客户端开发者规范他们的代码责任划分。和灵活的 SwiftUI 相比,这是门槛,但也是进阶。

当然了,硬要说的话令人唏嘘的就是做 Android 端的独立开发者并不多。背后的原因当然十分复杂,但是,额,我能做的也只是在这里唏嘘吧。

2. 客户端方案及架构设计

这一章节我们希望深入介绍一下「及游册」客户端工程的架构实践,这需要一些客户端开发相关的知识储备。

你最好也能知晓一些相关的名词:MVI (MVVM)、Kotlin Multiplatform。并且拥有 Android 或 iOS 开发的经验。

2.0 为什么做双端?

实话实说:在我们加入独立开发者这个圈子之前,没有人问过我们这个问题,我们也没有想过这个问题。

从市场来说,做双端看起来就是天经地义的。因为只需要两个客户端,就已经能覆盖 99% 多的移动端用户了,这成本多低啊!

但是有意思的事情是,有这个疑问的开发者,现状大多是在做 iOS 单端,而不是 Android 单端。好吧,我承认不管是国内国外,Android 确实会很严格,上架各大应用商店很麻烦。但就我们团队自己而言,我们还是很认可和珍惜 Android 用户这庞大的市场的。我们自己也是 Android 用户。

总之我们选择了跨平台的路子来降低做双端的成本。因为确实理解纯原生实现双端开发,对独立开发团队而言会导致本末倒置。本来精力就有限,再被写代码夺走了大部分。这样的成本分配似乎也没有那么「健康」。

所以我们觉得好像真的有必要给点建议,就是如果你刚刚「新建文件夹」,可以好好考虑一下:现在,或者未来,会不会做双端(多端)。如果会,我们推荐你考虑在这两个技术里面选一个:Flutter 和 KMM。至于选哪一个,只有一个问题:UI 你喜欢用原生写还是用跨平台写?原生就选 KMM,跨平台选 Flutter。

最近其实有 Compose Multiplatform,我们也一直在关注。但对我们而言可能是看个热闹,看它会不会有追上 Flutter 的势头。如果你有兴趣,也可以关注一下,或许它就帮了你大忙。

2.1 跨平台技术——KMM

这一章节我们会简单介绍一下 KMM 项目和常规的双端双工程有什么不同。并且针对不同「处境」的开发者,说一说我们对于「你要不要考虑使用 KMM」的建议。

为了更好理解本章节,你可以先浏览这些资料:

notion image
KMM 项目架构

上图是 KMM 官网曾经贴出来的 KMM 项目架构示意图,可以看到除去 UI 部分,业务逻辑和部分平台 API 都是可以用 KMM 编写的。一个 KMM 模块的内容大致是这样的:

- shared (KMM module)
|- commonMain  // 纯 Kotlin module,初始只依赖 Kotlin stdlib
|- androidMain // android-library,依赖 Android SDK
|- iosMain     // xcframework,通过 OC 依赖 Foundation 和一些基础库

这里不再赘述 KMM 的优缺点(需要了解可以看这篇我非常赞同的 Medium 文章:Kotlin Multiplatform, Compose Multiplatform: Apple’s Strategic Failure)。简单来说,KMM 在工程中就是一个 Kotlin 编写的模块,这个模块可以通过 aar 被 Android 工程引用,也可以通过 cocoapods 集成到 iOS 项目中,所以这样一份代码可以做到跨平台通用。

什么样的项目可以考虑使用 KMM 呢?这里是我们的一些建议:

假如,在从事独立开发之前,你是一位 Android 开发者,那我们相信采纳 KMM 会是你很好的选择。因为既然你要开发双端,那 iOS 的业务逻辑也用 Kotlin 来写多是一件美事啊!

但是假如你是 iOS 开发者,而且手上已经有一个成形的 iOS App 了。这个时候考虑开发 Android 版的话,是有一点尴尬,毕竟已经用 NSxxx 和 xxxKit 写了一遍所有逻辑了,如果要使用 KMM,得牺牲掉原来辛辛苦苦写的,而且还要翻译成 Kotlin 的方式重新实现一遍。即便我本人还是挺喜欢重构的,但这种程度的重构好像也把我劝退了。

所以,对于一个「新建的文件夹」,或者先 Android 后 iOS 的项目(纯纯好奇有这样的项目吗?),KMM 是一个值得考虑的选项。至少我觉得它的可推荐指数和 Flutter 可以不相上下,比 RN、H5 套壳更适合「成熟」的客户端开发者。

但是,假如你是因为独立开发而补习的技术,或许 KMM 项目的整体门槛会相对高一些,因为用了它你依然需要完全了解双端开发的那些平台特性和差异——毕竟 UI 你终归是要写两套的。但是这个门槛值得不值得跨过吧,我的建议是:如果你想要当一个 Pro 的 Developer(就是正经的「工程师」那样的),它可以给你带来不小的提升,能让你有追得上成熟客户端架构的本事;如果你的目标只是 it just works,那这道门槛是还挺难跨过(或许对于这篇文章的阅读也可以戛然而止)。

2.2 代码的分层架构设计

为了更好地理解分层设计,请阅读这份真正具有指导意义的架构指南:Android 应用架构指南

及游册遵循的分层依据为 Android 架构指南所说的 UI、Domain、Data 3 层架构:

notion image

简单介绍一下 3 层职责:

  • UI Layer:负责呈现界面,以及通过状态(State)来驱动界面,达到动态的效果。
  • Domain Layer:可以把需要复用的复杂 Data Layer 操作抽取成 UseCase,从而方便 UI Layer 使用。
  • Data Layer:负责管理应用数据。包括但不限于:向服务端请求数据、管理本地持久化数据。

2.2.1 UI Layer - Views & ViewModels

UI 层的元素包括:UI 组件(Composables/Views)和 ViewModel。在及游册中,UI 组件我们是双端分别实现的,iOS 端使用 SwiftUI,Android 则是 Jetpack Compose。

View 的部分我们就不讲了,这是官方文档应该教会我们的。

而 ViewModel 我们通过一些微妙的手段,让它成功下沉到了 KMM 模块(具体实现可以看我们开源并且内部正在使用的方案,它并不完美,但还算得上够用)

我们使用纯 Kotlin 来实现 ViewModel,这意味着我们不会在 ViewModel 中做一些平台相关的操作(除非我们的基础库有实现这些功能,比如我们的 Key-Value 存储就已经通过 KMM 基础库实现了)。通常来说这样的 ViewModel 可复用性更好,假如将来我要做 Web,或者 Desktop(Windows/Linux),我们现在的 ViewModel 就可以做到 out-of-box。

数据流设计上,我们规定每个 ViewModel 只向 UI 层提供 1 个 StateFlow(如果你不熟悉 Flow 的概念,它可以近似于:Combine Publisher on iOS / LiveData on Android)。我知道在单端开发中,我们通常会在 ViewModel 暴露一堆数据流,我以前也是这么干的。之所以在 KMM 选择只暴露 1 条,是因为这样我可以把这个 ViewModel 提供的所有 UI 要用到的数据都通过一个 UiState(只是个简单的 data class)聚合到一起。这样更方便我们查看哪些地方会 emit 一个新的 UiState,以及每一处 emit 更改的具体内容。因为 UI 已经是整个数据流的最终消费者了,所以这个时候 ViewModel 内部最好不要出现多个数据流互相影响的情况(A triggers B),这样会让 ViewModel 变得混乱。

另一个原因也是 KMM ViewModel 到 iOS 项目中需要写一个 Bridge,所以,统一成一个 Publisher 会更方便桥接。

以防万一真的有人用了我们那个不成熟的开源库,我在这里介绍一下双端如何使用 ViewModel:(你可以选择跳过这一部分,我们也并不建议你真的用🥹)

  • Android 端:BaseViewModel 会继承 androidx.lifecycle.ViewModel,所以可以像对待 Android ViewModel 一样对待我们这个 ViewModel。也就是 lifecycle-viewmodel-compose 提供的 viewModels() 来实现依赖注入,通过 collectAsStateWithLifecycle() 来在 Composable 中激活 StateFlow。
  • iOS 端:使用 @StateObject 注入 ViewModel。但是我们目前这个 ViewModel 需要通过一个 Bridge 才能够使用(用范型,不要一个一个写 Bridge)(需要将 StateFlow 转换为 Publisher,并且要根据 UI appear/disappear 来关闭/恢复 StateFlow,否则会大规模 Leak 😅)。

2.2.2 Domain Layer

这个我也不知道应该咋翻译,总之它是一个可以承上启下的东西。我们可以写一些数据层的用例,比如 combine 多个 repo 的数据并将它们输出为一个 UI 使用的格式……我们可以在多个 ViewModel 里面复用一个个用例,这样可以避免重复写大段复杂的数据处理逻辑。

在我们的理解中,它是为了拆分 ViewModel 的职责的,也就是可以把 ViewModel 里面的大段数据逻辑抽取到 UseCase 中来减少 ViewModel 类的代码行数,并且提供复用的可能。所以我们也建议设计中引入 Domain Layer,它只是做简单的代码拆分,没有那么复杂。😊

One more thing,但是只在这里浅浅提一下:建议使用依赖注入(Dependency Injection)。

2.2.3 Data Layer

数据层的设计其实很简单,就是 Repo + DataSource 而已。DataSource 可以表示一种数据的一个来源,比如「UserLocalDataSource」,表示用户数据的本地来源。

以用户数据为例,我们大概会有这样的模板:UserRepo + UserLocalDataSource + UserRemoteDataSource。上面已经说了 DataSource 表示数据的来源,那 Repo 就是用来协调不同来源的数据。更具体一点的话:

  • UserLocalDataSource - 提供一些本地数据库的 CRUD 方法(比如:写入用户信息、查询本地的用户信息……)
  • UserRemoteDataSource - 提供一些接口的网络请求(比如:login 接口……)
  • UserRepo - 向上提供一些数据层的方法,向下分别调用 Local/Remote 的具体实现(比如:login()、getUserInfo())

好看到这里你应该已经掌握了数据层的设计方法,那我们来探讨一个问题:UI 需要数据的时候,应该尽可能用 Local 数据还是 Remote 数据?

Android 官方有个推荐:尽可能使用 Local 作为单一可信的数据来源。他们的理由是:这样可以使得 App 提供一定的离线访问能力。

你怎么看?

「及游册」也是实践了尽可能使用 Local 数据。只是一开始的时候我们还没有那么坚定,但是用到后来,我们也发现了这样设计的一些不那么容易在一开始就看得见的好处

  • 服务端复杂查询?NO!本地复杂查询?YES!
  • 服务端 API 的大多功能是为了本地和远程数据库的数据同步。所以,如果哪天我们想不开用 GraphQL,现在的 API 设计就真的很适合,因为 API 本身就不会提供各种泛化的 VO,基本上都是统一整理格式后的 PO。
  • 客户端 DB 其实就几乎包含了当前登陆的用户的所有数据,新增一些页面,可能都不需要开发新的服务端 API。

对了,也只是浅浅提一句,我们本地 DB 使用了 Realm Kotlin 版。尽管对初始包体积有一点影响,但它在 KMM 中表现出色,提高了我们的开发效率。夸夸。

哦,还有一件事:2023 年了,本地 DB 提供响应式查询应该已经是客户端开发的共识了吧?(即:Query 的结果数据变化时能通过异步数据流主动 emit 新结果。)

2.3 一些零碎的 Topic

2.3.1 第三方依赖的管理(尤其是 Android)

请注意引入新的第三方依赖带来的副作用,比如可能会新增一些网络请求库的依赖,或者一些 UI 组件库给你引入了多余的图片加载库……

在开发「及游册」时我们有意识地在做这些事情,就是尽可能不要为了还原 UI 效果而引入一些三方库。很多时候不好实现的 UI 效果,别人的实现也未必优雅……尤其是用 Compose 开发之后,市面上确实没有现成又好用的轮子

2.3.2 资源文件的处理

恳请各位开发者注意自己 App 的包体积……尤其注意自己引入的静态资源文件的大小……小小的工具动辄上百兆的包体积真的很不优雅。🥹

有一些位图资源处理的小技巧可以尝试一下:

  • iOS 端可以考虑使用 HEIF 格式的图片文件,Xcode 能正确打包
  • Android 端可以考虑使用 WebP 格式
  • 对于 PNG 格式可以使用 8bit 颜色来减小体积
  • 如果可以的话,大图片应该被上传到对象存储……

另外,对于其他类型的资源,也有相应的优化方法。只是在我一些解包的经验看来,安装包体积偏大的独立开发作品基本上都是因为有很多静态的图片资源。尤其多语言的高清大图,对包体积的影响是「灾难性」的。

2.3.3 媒体资源的云端存储

云端存储媒体资源特别需要注意的就是内容安全,不然不仅可能应用市场拒绝上架,甚至可能惹来不必要的麻烦。

但它不是免费的,所以如果 App 使用自己的云端存储,会带来比较明显的成本提升。关于这一点我们也还没有探索到可以优化成本的方案 😔,只是如果你以前不知道这点,我们就在这里浅浅提醒一下。

除了内容安全审核的成本,另一个媒体相关的就是 CDN 流量成本了。「及游册」尽可能使用编码效率更高的格式,比如 HEIC、WebP 来降低图片的原图体积,并且在大多 UI 场景提供缩略图加载(偷偷说:如果使用效率高的格式,很多时候缩略图和原图也看不太出太多差别),这样能节约不少流量。

所以我们对初级小册设计仅仅 100 MB 的云存储,一方面是为了省钱,另一方面也是我们在节省空间这件事情上下了一些功夫。

2.3.4 有意识地去找「最佳实践」

这点很简单,搜索问题的时候在后面加上「best practice」,搜索引擎就会告诉你啦。遵循最佳实践也许会让你需要花时间来学习理解这一个 Topic,但是它可以帮你避免日后维护里一些不必要的麻烦。

嗯,这是一种学习方法!

3. 服务端方案

虽然说起来服务端方案五花八门,但是分类一下,也就几个路子:

  1. 自建服务端,提供 REST 接口
  2. iCloud 同步(仅 iOS,且不可能跨端同步数据)
  3. 云盘同步(只针对文件,不支持结构化数据)

低情商如我就会说:2 和 3 不都是野路子吗?

但是需要承认的是,2 和 3 也是被开发者认可并且在生产环境中使用的方法。所以他们有存在的必要和意义。

3.1 我觉得还是需要服务端的

站在多端的立场,我会首先排除 iCloud。站在多用户的立场,我会排除云盘同步。

服务端,或者,一个中心化的数据库,emmmm,应该,一定,是很有用的。

嗯,就这样吧,没有多余的解释了。如果是成本的原因让你对服务端望而却步,我们倒是有一些方案可以推荐一下,就在下个章节。

3.2 最重要的是省钱

话不多说上链接:

  • Serverless,按量付费的 Serverless。因为我们的产品的量肯定很小(好扎心但好真实),Serverless 方案的成本不会很贵,比租用 ECS 肯定是便宜很多很多。而且 Serverless 不需要运维技术,如果你的 ECS 像我一样中过挖矿病毒,你一定懂这种痛。
  • BaaS,Backend-as-a-Service。最早见这个词是我读书的时候用 LeanCloud 做项目,感觉成本也还好,应该也有免费额度。我也知道一些开发者在用知晓云啊这些的,感觉都挺成熟,对独立开发项目来说,投产不是问题。

既然是揭秘文章,我们还是得公开一下「及游册」现在在用的这一套技术。

我们使用的是 AWS 的 Serverless 「全家桶」:Lambda(云函数)、DynamoDB(云数据库)、ApiGateway(网关)、CloudWatch(日志)。

使用 AWS 有一点须知:需要有企业(公司,非个体户)的资质,这样才能使用国内的 AWS。如果你没有,请不要尝试在国内用 AWS 海外投产……因为有些省份的 AWS 海外可用性会成谜。我们上线初期也在这里栽了跟头,许多游友反馈无法登陆,实际上我们一查,是他们那边根本无法连接到 AWS 海外……后来我们从个体户转成了企业,才彻底解决了服务端的可用性问题。

有一个有趣的地方是我们在选择数据库的时候(P.S. 数据库往往是云服务里面最贵的),看到 DynamoDB 声称永久免费(一定额度)就两眼放光,心想不管它写起来什么样,总之就他了。于是看着 AWS 文档花了几天理解 DynamoDB。一开始是挺难理解它分区键和排序键的设计的,觉得 NoSQL 能被设计成这么花真的是好难学。后来动手设计了一些查询后发现它确实有它的优势在,于是一边啧啧称奇,一边开始接受它那一点都不常规的用法。

4. 结语

絮絮叨叨说了很多,这篇文章信息量也确实有点大。所以总结一下:

  • 我们希望这篇文章能够被独立开发者们看见,因为它不是面向企业级庞大的应用开发的,是专门为独立开发者而设计的。
  • 我们公开了「及游册」现行的客户端和服务端架构方案供大家参考,如果你也在思考技术架构,不嫌弃的话可以看看我们的方法,或许对你有一点点用。
  • 客户端我们使用 Kotlin Multiplatform 这一跨平台方案来提升我们的开发效率,效果十分显著。
  • 服务端我们只浅浅地表示一下,大家选择最适合自己的就好!
  • 如果你有什么想告诉我们的,欢迎邮件联系 postman@suoxing.tech

 > 关注 少数派公众号,解锁全新阅读体验 📰

> 实用、好用的 正版软件,少数派为你呈现 🚀