交付清单:主题、平台通道、测试与上架
Android 那半边你确实会——Gradle 还在,签名还是那套。但现在你多了 iOS 那半边,以及一台 Mac(这不是小事)。这一章是把学校 App 真正发出去的清单:主题统一、要调原生功能时的平台通道、Flutter 的三层测试、跨双平台的构建上架,最后用一张完整对照表收束全书。
主题:一次定义,全局生效
Material 3 是默认(第 1 章)。在 MaterialApp 顶层定一次,所有页面通过 Theme.of(context)(第 15 章的 O(1) 查表)取用:
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF027DFD)), // 一个种子色生成整套配色
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF027DFD), brightness: Brightness.dark),
),
themeMode: ThemeMode.system, // 跟随系统;也可让用户在设置里切
home: const HomePage(),
)
ColorScheme.fromSeed 是 M3 的招牌——给一个品牌色,它按 Material 的算法生成一整套协调的明暗配色。用的时候永远走语义色,别硬编码:
final scheme = Theme.of(context).colorScheme;
Container(color: scheme.primaryContainer);
Text('标题', style: Theme.of(context).textTheme.titleLarge);
ColorScheme.fromSeed 对应 Compose 的动态配色 / lightColorScheme,Theme.of(context).textTheme 对应 MaterialTheme.typography。整套 Material 3 设计系统两边共用——你在 Compose 里对主题的理解直接生效,连「用语义色不用固定色」的纪律都一样。
平台通道:需要原生功能时的桥
Flutter 自己不实现相机、蓝牙、推送这些——它们是平台能力。绝大多数时候,pub.dev 上已经有插件替你封装好了(image_picker、geolocator、firebase_messaging、local_notifications……),你直接用 Dart API 就行,根本碰不到原生。
只有当你要调一个没有现成插件的原生功能时,才需要自己搭桥——平台通道(Platform Channel):
// Dart 侧
const channel = MethodChannel('school.app/device');
final id = await channel.invokeMethod<String>('getSchoolDeviceId');
// Android 侧(Kotlin,在 MainActivity 里)—— 你的主场
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "school.app/device")
.setMethodCallHandler { call, result ->
when (call.method) {
"getSchoolDeviceId" -> result.success(readDeviceId())
else -> result.notImplemented()
}
}
iOS 侧对应写一份 Swift。好消息:Android 那半边就是你本来会的 Kotlin——平台通道让你能在需要时随时下沉到原生,你的 Android 功力不但没浪费,还成了别人(纯 Flutter 背景的)搭不了的桥。
写平台通道前,一定先去 pub.dev 搜。学校 App 要的东西(相机、定位、通知、文件选择、分享、二维码)几乎都有成熟插件。自己写通道是最后手段,留给真正独特的原生需求。
测试:Flutter 的三层,比 Android 顺
Flutter 的测试体验是它的强项之一——尤其 Widget 测试,跑在纯 Dart 里、不需要模拟器、快得像单元测试。三层:
| 层 | 测什么 | Android 对应 | 速度 |
|---|---|---|---|
| 单元测试 | Notifier、Repository、纯逻辑 | JUnit | 极快 |
| Widget 测试 | 单个 Widget/页面的渲染与交互 | Robolectric(但更快更稳) | 快,无需模拟器 |
| 集成测试 | 真机/模拟器上跑完整流程 | Espresso | 慢 |
Widget 测试长这样,对写惯了测试的人极其亲切:
testWidgets('点加号后计数变 1', (tester) async {
await tester.pumpWidget(const MaterialApp(home: CounterPage()));
expect(find.text('0'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // 触发一帧重建
expect(find.text('1'), findsOneWidget);
});
你把逻辑放进了不认识 UI 的 Notifier、把数据放进了 Repository(第 17 章)——现在它们全都能脱离界面、脱离模拟器、用纯 Dart 单元测试覆盖,毫秒级跑完。这就是「Notifier 不 import Widget」那条纪律的现金回报。架构分层不是洁癖,是为了此刻能测。
那台 Mac:iOS 半边的现实
这是这一章标题盖「改一下」的核心原因。Android 打包你闭眼会,但 iOS 那半边有几个绕不开的现实,早知道免得临上架抓瞎:
1. 必须有一台 Mac。构建 iOS 包(.ipa)、跑 iOS 模拟器、用 Xcode 签名——这些只能在 macOS 上做。没有 Mac,就得靠云构建(Codemagic、GitHub Actions 的 macOS runner)或者 flutter build ipa 配 CI。团队里至少要能碰到一台 Mac。
2. 必须有 Apple Developer 账号(个人 99 美元/年,教育机构可申请免费的组织账号)。上架 App Store、甚至真机调试都要它。
3. 证书和描述文件(Provisioning Profile)。iOS 签名比 Android 复杂——这是 iOS 新手最容易卡住的一关。让 Xcode 的自动签名帮你处理,或用 fastlane match 管理团队证书。
4. 审核更严、更慢。App Store 人工审核(几小时到几天),对权限说明、隐私政策、设计规范的要求比 Google Play 严。学校 App 涉及学生数据,隐私合规要提前准备好。
构建与发布:两条平行的流水线
# 开发期 $ flutter run # 装到连着的设备/模拟器 $ flutter run --release # 测真实性能(debug 模式慢很多,别拿它评性能!) # Android 产物 $ flutter build appbundle # 传 Google Play 的 .aab $ flutter build apk --split-per-abi # 侧载/内部分发用的 apk # iOS 产物(需要 Mac) $ flutter build ipa # 传 App Store / TestFlight # 发版前必做 $ flutter analyze # 静态检查,CI 里挡住 lint 问题 $ flutter test # 跑单元 + Widget 测试 $ flutter build --release # 用 release 构建量体积、测性能
永远别在 debug 模式下评价 Flutter 的性能。debug 模式用 JIT、开着一堆断言和检查,比 release 慢好几倍。有人跑 flutter run 觉得「Flutter 好卡」,其实换成 --release 或 --profile 立刻顺滑。测性能只看 profile/release。
- 换掉默认应用图标和名字(
flutter_launcher_icons一键生成两平台图标)。 - 配好启动图(
flutter_native_splash)。 - Android 改
applicationId、配签名(key.properties);iOS 配 Bundle ID、签名。 - 权限文案:Android 的
AndroidManifest、iOS 的Info.plist(相机/定位/通知都要写用途说明,iOS 缺了会被拒)。 - 在最小的目标安卓机上跑一遍(第 11 章:小屏才暴露溢出)。
flutter analyze和flutter test全绿。
崩溃监控与日志:上线后你才真正需要的
App 发出去,真正的考验才开始——你看不到用户的屏幕,只能靠遥测。上线前务必接一个崩溃监控(firebase_crashlytics 或 Sentry),它会把线上的异常、堆栈、设备信息收集回来:
void main() {
runZonedGuarded(() {
FlutterError.onError = (details) => Crashlytics.recordFlutterError(details);
runApp(const ProviderScope(child: MyApp()));
}, (error, stack) => Crashlytics.recordError(error, stack)); // 抓 Dart 层未捕获异常
}
FlutterError.onError 抓 Widget/渲染层的错误,runZonedGuarded 抓 Dart 层未捕获的异步异常——两个都要挂,否则会漏一半。没有崩溃监控的 App 上线,等于闭着眼开车:学校 App 跑在几百种便宜安卓机上,你自己那台测不出的问题,只有遥测能告诉你。
包体积与混淆
两个上架前顺手做的事:
- 看体积:
flutter build appbundle --analyze-size会告诉你包里什么最占地方(通常是图片和字体)。Flutter 的基础包比纯原生大几 MB,但对功能型 App 通常无所谓。 - 混淆:
flutter build apk --obfuscate --split-debug-info=build/symbols——混淆 Dart 代码、把符号表单独存出来(崩溃监控需要它来还原堆栈)。别丢了那个 symbols 目录。
持续集成:让机器替你跑那三条命令
哪怕是小项目,配个最简单的 CI(GitHub Actions)都值——每次推代码自动跑:
- run: flutter analyze # 静态检查 - run: flutter test # 单元 + Widget 测试 - run: flutter build apk # 确认能构建出来
三条命令,挡住 90% 的「合并后才发现编译不过 / 测试挂了」。iOS 的构建要 macOS runner(第 24 章前面说的那台 Mac 的 CI 版本),GitHub Actions 提供,配一次一直用。这和你在 Android 项目里配的 CI 是同一件事,只是命令从 ./gradlew 换成了 flutter。
全书总表:从 Kotlin/Compose 到 Flutter
你读到这里,已经把一门框架从渲染原理到交付走了一遍。用一张对照表收束——这也是你以后可以随时翻回来的速查:
| Kotlin / Compose / Android | Dart / Flutter | 章 | |
|---|---|---|---|
| 语言 | Kotlin | Dart(近亲,八处绊脚) | 02-03 |
| UI 单元 | @Composable(函数调用) | Widget(不可变对象) | 05 |
| 中间层 | slot table(藏起来) | Element 树(摆出来) | 06 |
| 跳过重建 | 编译器判稳定性 | const(手动) | 04·14 |
| 列表身份 | items(key = {}) | Key / ValueKey | 08 |
| 布局协议 | Constraints(约束向下…) | BoxConstraints(同一套) | 10-11 |
| 就近取值 | CompositionLocal | InheritedWidget · .of() | 15 |
| 状态管理 | ViewModel + StateFlow | Riverpod Notifier | 16-17 |
| 并发 | 协程 + 多线程 | 事件循环 + 单线程 + Isolate | 18-20 |
| 异步类型 | suspend / Flow | Future(热·不可取消)/ Stream | 19 |
| 导航 | Navigation Compose | go_router | 21 |
| 网络 / JSON | Retrofit / Moshi | dio / freezed(要跑生成) | 22 |
| 列表 | LazyColumn | ListView.builder | 23 |
| 主题 | MaterialTheme(M3) | ThemeData(M3,共用) | 24 |
| 依赖注入 | Hilt | Riverpod 自带 | 16-17 |
整本书就一句话摊开:你每帧写下一张平摊的纸(Widget),框架照着长命的折痕(Element)把它折成真正会量会画的立体物(RenderObject)。
你现在回头看第 1 章那张「24 条直觉」表——那 6 条「直接放行」的(声明式、Expanded、Stack、CompositionLocal、ViewModel 分层、列表回收),你确认了它们真能带过来;那 9 条「得扔掉」的,你也看清了它们为什么错、正确的模型是什么。Compose 你原来也才懂一半——那藏起来的中间层,现在你两个框架都看得见了。
最后:给你那个学校 App 的起手式
合上书就能开工的顺序:
flutter create school_app,按第 17 章的 feature-first 骨架建目录。- 装四样:
riverpod+riverpod_generator(状态·第 16 章)、go_router(导航·第 21 章)、dio+freezed(数据·第 22 章)、flutter_secure_storage(存 token·第 22 章)。 - 先搭登录:一个
AuthNotifier+ go_router 的redirect守卫(第 21 章)。 - 课表页用
ListView.builder+AsyncValue三态(第 19·23 章)。 - 数据走 Repository,缓存优先(第 22 章)。
- 逻辑写进 Notifier,配 Widget 测试(第 24 章)。
- 发版前过一遍上面那张极简清单,别忘了那台 Mac。
你不是从零开始——第 1 章就说了。现在你手上是一张摊开的、每一处折痕都看得清的图纸。去把它折成那个 App 吧。
Flutter 和 Compose 是一对表兄弟。这本书从头到尾做的一件事,是帮你认出哪里是「同一件事换了口音」(大部分)、哪里是「真的不同、必须重学」(那棵摆出来的 Element 树、单线程的并发、要手动划的重建范围)。把这两类分清,你就不再是「会一点 Flutter」,而是能推理 Flutter 了。祝你的学校 App 顺利交付。
这一章的一句话
交付 = 主题一次定义全局取用 + 需要原生时用平台通道下沉(你的 Android 功力在这里反成优势)+ 分层带来的可测性 + 别忘了 iOS 那半边和一台 Mac;而这一路的每一步,都能在你 Compose 的经验里找到对应——你要做的只是把口音换过来。