1. 技术栈选型:从零到发布,为什么这一步决定了后面80%的坑
做独立开发最怕什么?不是代码写不出来,而是写到一半发现选错了技术栈,推倒重来的成本高到让人想放弃。我见过太多人,第一行代码还没敲,光在“用Flutter还是React Native”“后端用Node还是Go”这些问题上纠结了两周,最后项目还没开始就结束了。这篇内容就是把我自己从零到把APP推上应用商店的完整技术栈决策过程拆开讲清楚,包括每一层选了什么、为什么这么选、当时还考虑过哪些方案、以及事后回头看哪些选对了哪些踩了坑。适合正在准备做第一个完整APP的独立开发者,也适合已经写过Demo但没走过完整发布流程的朋友。核心关键词就一个:技术栈。但技术栈不是一张清单,而是一连串有先后依赖关系的决策链,每一步都会影响下一步的选择空间。
先说一个基本判断:技术栈没有绝对的好坏,只有跟你的约束条件匹不匹配。你的约束条件包括:你已有的技术背景、项目的时间预算、目标平台(iOS/Android/Web)、是否需要后端、后期维护成本、以及最容易被忽略的——你愿不愿意为某些环节付费。把这几个约束想清楚,技术栈的选择范围会迅速收窄,纠结的时间能省掉一大半。
我自己的约束条件是这样的:前端只会JavaScript和一点Python,后端经验有限,预算控制在每月100块以内,目标平台是iOS和Android双端,项目类型是内容管理加用户系统的工具类APP。基于这些约束,下面逐层拆解我的选择逻辑。
2. 前端框架:跨平台方案里我为什么最终选了Flutter
2.1 跨平台 vs 原生:先算一笔时间账
独立开发者做双端APP,第一个绕不开的决策就是:写两套原生代码,还是用跨平台框架写一套。我当时的算法很简单:假设原生开发iOS需要X小时,Android需要Y小时,跨平台需要Z小时。根据社区里大量独立开发者的经验数据,跨平台方案的Z大约是X的1.3到1.5倍,而X加Y大约是X的1.8到2倍。也就是说,跨平台能省下大约30%到40%的总开发时间。对于一个人做项目的情况,这个时间差往往就是“能做完”和“做不完”的区别。
但跨平台不是没有代价。性能敏感的场景(比如高频动画、复杂手势、大量列表渲染)跨平台框架确实会吃亏。不过对于我这种内容管理加用户系统的工具类APP,性能瓶颈根本不在渲染层,而在网络请求和数据缓存。所以跨平台方案完全够用。
2.2 Flutter、React Native、原生三选一的决策过程
当时我重点对比了三个方案:
| 对比维度 | Flutter | React Native | 原生双端 |
|---|---|---|---|
| 学习曲线 | Dart语言需重新学,但语法接近JS | 直接复用JS/TS经验 | Swift和Kotlin都要学 |
| UI一致性 | 自绘引擎,双端像素级一致 | 依赖原生组件,双端有差异 | 各自平台最自然 |
| 生态成熟度 | 包管理pub.dev,常用库齐全 | npm生态最大 | 各自官方生态 |
| 热重载体验 | 极快,状态保持好 | 快,但偶尔丢失状态 | 各有方案 |
| 发布流程 | 一套代码分别打包 | 一套代码分别打包 | 两套独立流程 |
| 长期维护 | Google主力维护 | Meta维护,社区活跃 | 各自平台官方 |
最终选Flutter的决定性因素有三个。第一,我受够了React Native里双端UI不一致带来的调试成本,Flutter的自绘引擎意味着我在iOS上看到什么样,Android上就是什么样,省掉了大量“这个间距在Android上怎么多了2px”的排查时间。第二,Flutter的热重载体验确实更好,改完代码保存后状态不丢失,对于频繁调整UI的项目来说效率提升明显。第三,Dart语言的空安全机制在编译期就能挡掉大量空指针问题,对于我这种经常忘记判空的人来说,少写了很多防御性代码。
注意:如果你已经对React生态非常熟悉,React Native的学习成本几乎为零,这时候选React Native可能比Flutter更划算。技术栈选型的第一原则是复用已有知识,而不是追求“最好”的方案。
2.3 Flutter项目初始化时我踩过的坑
初始化Flutter项目时,我建议直接用命令行而不是IDE的向导:
flutter create --org com.yourdomain --project-name your_app --platforms ios,android your_app这里有几个细节值得注意。--org参数决定了iOS的Bundle ID和Android的包名,一旦发布后就不能改了,所以一定要想清楚。--platforms参数只生成你需要的平台目录,避免项目里多出一堆用不到的文件夹。项目名用下划线命名,因为Dart的包名规范要求如此。
初始化完成后,第一件事是锁定Flutter版本。在项目根目录的pubspec.yaml里加上:
environment: sdk: '>=3.2.0 <4.0.0'然后在CI或者本地开发机上用fvm(Flutter Version Management)来管理版本。我试过直接用系统全局的Flutter,结果换电脑后版本不一致导致构建失败,排查了半天。用fvm之后,项目根目录放一个.fvmrc文件,团队里所有人用的都是同一个版本,省心很多。
3. 状态管理与架构:为什么我放弃了Provider转向Riverpod
3.1 状态管理方案的选择逻辑
Flutter的状态管理方案多到让人眼花缭乱:setState、Provider、Riverpod、Bloc、GetX、MobX……我一开始用的是Provider,因为官方文档推荐,社区示例也多。但写到第三个页面的时候问题就来了:多个Provider之间的依赖关系变得复杂,某个Provider需要在另一个Provider更新后重新计算,用Provider的ProxyProvider写起来很绕,而且调试时很难追踪到底是哪个环节触发了重建。
后来我换成了Riverpod,核心原因是它的编译期安全。Riverpod的Provider是全局变量,不依赖BuildContext,这意味着我可以在任何地方读取状态,包括在纯Dart函数里。而且Riverpod的ref.watch和ref.read语义清晰,watch用于需要响应变化的场景,read用于只需要读取一次的场景,写代码时不容易搞混。
3.2 我的项目分层架构
最终我的项目采用了三层架构:
- 数据层:Repository模式,每个Repository负责一个领域的数据获取和缓存。比如
UserRepository负责用户信息的读写,ContentRepository负责内容列表的增删改查。 - 状态层:Riverpod的Notifier,每个Notifier对应一个页面或一个功能模块的状态。Notifier里调用Repository的方法,把数据转换成UI需要的格式。
- UI层:Widget只负责渲染,通过
ref.watch监听Notifier的状态变化,通过ref.read调用Notifier的方法。
这个分层的好处是每一层都可以独立测试。Repository层可以用mock数据测试,Notifier层可以用Riverpod的ProviderContainer测试,UI层用Widget测试。我试过在项目中期重构时,因为分层清晰,只改了Repository层的实现,上层代码一行没动。
3.3 路由管理:GoRouter的实际使用体验
路由我选了GoRouter,原因是它支持声明式路由和深度链接。声明式路由的好处是路由配置集中在一个地方,不像Navigator 1.0那样路由逻辑散落在各个页面里。深度链接对于内容类APP很重要,用户从浏览器点开一个链接应该直接跳到对应的内容详情页,而不是打开APP首页再让用户自己找。
GoRouter的配置大概长这样:
final router = GoRouter( routes: [ GoRoute( path: '/', builder: (context, state) => const HomePage(), routes: [ GoRoute( path: 'content/:id', builder: (context, state) => ContentDetailPage( id: state.pathParameters['id']!, ), ), ], ), ], );这里有个细节:路径参数用:id声明,在builder里通过state.pathParameters读取。我一开始忘了在路径里加冒号,结果一直匹配不到,排查了半小时才发现。
4. 后端与数据存储:从Firebase到自建服务的权衡
4.1 后端方案的三条路线
独立开发者的后端选择大概有三条路:一是用BaaS(Backend as a Service)比如Firebase或Supabase,二是用Serverless函数,三是自己租服务器写后端。我三条路都试过,最后选了第三条,但这个过程值得展开说。
Firebase的优势是上手极快,认证、数据库、存储、推送全都现成的,文档也全。但问题在于成本不可控。Firebase的Firestore按读写次数计费,项目上线后如果某个查询没做好索引,一次全表扫描就能烧掉几天的预算。我试过在测试环境跑了一个批量导入脚本,第二天收到账单提醒,虽然金额不大但那种“不知道钱花在哪”的感觉很难受。
Supabase是开源的Firebase替代品,基于PostgreSQL,可以用SQL查询,成本相对可控。但Supabase的免费层有项目暂停机制,一段时间不访问数据库会自动暂停,下次访问需要等待唤醒。对于个人项目来说这个体验不太好。
Serverless函数(比如Cloud Functions或Edge Functions)适合处理轻量级的业务逻辑,但不适合做主要的数据存储层。而且冷启动延迟在移动端场景下比较明显,用户打开APP等两秒才看到数据,体验很差。
4.2 我最终的后端架构
最后我选了一个折中方案:用轻量级后端框架自己写API,部署在一台低配云服务器上,数据库用SQLite加定期备份。这个方案的优势是成本固定(每月几十块),数据完全自己掌控,没有按量计费的不确定性。
后端框架我选了FastAPI,原因是Python写起来快,自动生成OpenAPI文档,而且异步支持好。数据库用SQLite是因为项目初期数据量小,单文件数据库备份和迁移都方便。等数据量上来了再迁移到PostgreSQL,SQL语法基本兼容,迁移成本可控。
API设计上我遵循了几个原则。第一,所有接口返回统一的JSON结构:
{ "code": 0, "message": "success", "data": { ... } }code为0表示成功,非0表示业务错误。这样客户端只需要判断一个字段就能知道请求是否成功。第二,认证用JWT,token放在请求头的Authorization字段里。第三,所有时间字段用ISO 8601格式的UTC时间,客户端负责转成本地时区显示。
4.3 数据缓存策略
移动端不能每次都从网络拉数据,必须有本地缓存。我用了两层缓存:内存缓存和磁盘缓存。内存缓存用Dart的Map实现,存储当前会话中频繁访问的数据,比如用户信息。磁盘缓存用shared_preferences存简单的键值对,用sqflite存结构化的列表数据。
缓存的更新策略是“先读缓存再请求网络,网络返回后更新缓存并刷新UI”。这样用户打开APP时能立刻看到上次的数据,同时后台静默请求最新数据。如果网络请求失败,用户至少还能看到缓存的内容,不会白屏。
注意:缓存一定要设过期时间。我试过因为缓存没设过期,用户改了头像后自己看得到但别人看不到,排查后发现是本地缓存一直没更新。后来所有缓存都加了时间戳,超过一定时间就强制从网络拉取。
5. 开发工具链与发布流程:那些文档里不会写的细节
5.1 代码管理与协作工具
代码托管用Git,这没什么好说的。但分支策略值得说一下。我一个人开发,用的是简化版Git Flow:main分支保持可发布状态,dev分支日常开发,功能分支从dev切出,完成后合并回dev,发布时dev合并到main并打tag。
CI/CD我用了GitHub Actions,配置了三个工作流:push到dev时跑测试和静态分析,push到main时构建iOS和Android的发布包,打tag时自动上传到应用商店的测试轨道。这样每次发布只需要打一个tag,剩下的交给CI。
5.2 iOS和Android发布流程的差异
iOS和Android的发布流程差异很大,这里展开说几个关键点。
iOS方面,证书和描述文件是最容易出问题的环节。我建议用Fastlane的match来管理证书,把所有证书和描述文件存在一个私有的Git仓库里,CI运行时自动拉取。这样换电脑或者换CI环境时不需要手动配置证书。App Store Connect的审核周期通常是1到3天,首次提交可能更长。审核被拒最常见的原因是隐私政策不完整和截图不符合规范。
Android方面,Google Play的审核通常几小时内就能完成,但首次发布需要完成开发者账号验证,这个流程可能需要几天。Android的签名用keystore文件,一定要备份好,丢了就没办法更新已发布的应用了。我试过因为keystore密码记错,折腾了一下午才找回来。
| 对比项 | iOS | Android |
|---|---|---|
| 开发者账号费用 | 每年99美元 | 一次性25美元 |
| 审核周期 | 1-3天 | 几小时到1天 |
| 签名方式 | 证书+描述文件 | keystore文件 |
| 测试轨道 | TestFlight | 内部测试/封闭测试/开放测试 |
| 截图要求 | 严格,尺寸必须匹配 | 相对宽松 |
5.3 版本号管理策略
版本号我用的是major.minor.patch+build的格式。major在有重大功能更新时递增,minor在有小功能更新时递增,patch在修bug时递增,build每次提交到商店时自动递增。Flutter里版本号在pubspec.yaml里配置:
version: 1.2.3+45+前面的部分用户能看到,后面的build号是给商店和CI用的。我试过忘记递增build号,结果商店拒绝上传,说版本号已存在。后来在CI里加了一个脚本,每次构建时自动从Git的commit数量生成build号,再也不用手动改了。
6. 常见问题与排查技巧实录
6.1 技术栈相关的典型问题
问题一:Flutter在iOS上构建失败,报错“Module not found”
这个通常是因为CocoaPods的缓存出了问题。解决方法是进ios目录,删掉Podfile.lock和Pods文件夹,然后重新跑pod install。如果还不行,试一下pod repo update更新本地的spec仓库。
问题二:Riverpod的Provider在页面销毁后仍然被监听
这是因为ref.watch在Widget销毁时没有自动取消监听。解决方法是确保在ConsumerWidget或ConsumerStatefulWidget里使用ref.watch,不要在其他地方用。如果需要在非Widget环境里监听,用ref.listen并手动管理订阅。
问题三:后端API返回的数据在客户端解析失败
最常见的原因是字段类型不匹配。比如后端返回的id是数字,客户端定义的是字符串。解决方法是所有API接口都用OpenAPI文档定义清楚字段类型,客户端用代码生成工具自动生成模型类,不要手写解析逻辑。
问题四:应用商店审核被拒,理由是“隐私政策不完整”
隐私政策必须包含:收集哪些数据、为什么收集、数据存储在哪里、是否与第三方共享、用户如何删除数据。我建议直接用隐私政策生成工具生成一份,然后根据自己APP的实际情况修改。不要直接复制别人的,因为每个APP收集的数据不一样。
6.2 独家避坑技巧
第一个技巧:在项目初期就配置好错误监控。我用的是Sentry,Flutter和FastAPI都有对应的SDK。配置好之后,线上出现的任何异常都会自动上报,包括堆栈信息和用户操作路径。这个在排查线上问题时能省掉大量“用户说闪退了但我复现不出来”的时间。
第二个技巧:所有网络请求都加超时和重试。移动网络不稳定是常态,没有超时设置的请求可能会一直挂着,用户看到的就是转圈圈。我设的是连接超时5秒,读取超时10秒,失败后重试2次,重试间隔指数递增。
第三个技巧:在APP里加一个隐藏的调试页面,显示当前的环境配置、API地址、缓存状态、版本号等信息。触发方式可以是在设置页面连续点击版本号5次。这个页面在排查用户反馈的问题时非常有用,可以让用户截图发过来,一眼就能看出问题所在。
第四个技巧:发布前一定要在真机上测试。模拟器和真机的差异比想象中大,尤其是涉及权限、推送、支付这些功能。我试过在模拟器上一切正常,真机上推送收不到,排查后发现是推送证书配置错了。
6.3 技术栈决策的复盘
回头看整个技术栈的选择,有几个决策我认为是对的:选Flutter省下了大量双端适配时间,选Riverpod让状态管理清晰可控,选自建后端让成本固定且数据自主。也有几个决策如果重来我会调整:后端框架可能会选Go而不是Python,因为部署更简单(单二进制文件);数据库可能会一开始就用PostgreSQL而不是SQLite,省掉后期迁移的麻烦。
但技术栈不是一成不变的。项目不同阶段可以用不同的技术栈。初期追求开发速度,中期追求稳定性,后期追求性能和成本优化。关键是每一步都知道自己为什么这么选,以及如果选错了怎么切换。最怕的不是选错,而是不知道为什么选,出了问题也不知道往哪个方向排查。
提示:技术栈的决策文档一定要写下来,哪怕只是几行字。记录当时的选择理由、考虑过的替代方案、以及切换成本。三个月后你回头看,会感谢自己写了这些。