卷 VI · 交付CH 24深度 24/24

交付清单:主题、平台通道、测试与上架

直觉过境 「打包上架我闭着眼睛都会。」 改一下

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);
✎ 这就是你熟的 MaterialTheme

ColorScheme.fromSeed 对应 Compose 的动态配色 / lightColorSchemeTheme.of(context).textTheme 对应 MaterialTheme.typography整套 Material 3 设计系统两边共用——你在 Compose 里对主题的理解直接生效,连「用语义色不用固定色」的纪律都一样。

平台通道:需要原生功能时的桥

Flutter 自己不实现相机、蓝牙、推送这些——它们是平台能力。绝大多数时候,pub.dev 上已经有插件替你封装好了image_pickergeolocatorfirebase_messaginglocal_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);
});
◆ 第 17 章的分层,在这里兑现回报

你把逻辑放进了不认识 UI 的 Notifier、把数据放进了 Repository(第 17 章)——现在它们全都能脱离界面、脱离模拟器、用纯 Dart 单元测试覆盖,毫秒级跑完。这就是「Notifier 不 import Widget」那条纪律的现金回报。架构分层不是洁癖,是为了此刻能测。

那台 Mac:iOS 半边的现实

这是这一章标题盖「改一下」的核心原因。Android 打包你闭眼会,但 iOS 那半边有几个绕不开的现实,早知道免得临上架抓瞎:

⚠ 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。

✎ 学校 App 发版前的极简清单
  • 换掉默认应用图标和名字(flutter_launcher_icons 一键生成两平台图标)。
  • 配好启动图(flutter_native_splash)。
  • Android 改 applicationId、配签名(key.properties);iOS 配 Bundle ID、签名。
  • 权限文案:Android 的 AndroidManifest、iOS 的 Info.plist(相机/定位/通知都要写用途说明,iOS 缺了会被拒)。
  • 最小的目标安卓机上跑一遍(第 11 章:小屏才暴露溢出)。
  • flutter analyzeflutter test 全绿。

崩溃监控与日志:上线后你才真正需要的

App 发出去,真正的考验才开始——你看不到用户的屏幕,只能靠遥测。上线前务必接一个崩溃监控(firebase_crashlyticsSentry),它会把线上的异常、堆栈、设备信息收集回来:

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 / AndroidDart / Flutter
语言KotlinDart(近亲,八处绊脚)02-03
UI 单元@Composable(函数调用)Widget(不可变对象)05
中间层slot table(藏起来)Element 树(摆出来)06
跳过重建编译器判稳定性const(手动)04·14
列表身份items(key = {})Key / ValueKey08
布局协议Constraints(约束向下…)BoxConstraints(同一套)10-11
就近取值CompositionLocalInheritedWidget · .of()15
状态管理ViewModel + StateFlowRiverpod Notifier16-17
并发协程 + 多线程事件循环 + 单线程 + Isolate18-20
异步类型suspend / FlowFuture(热·不可取消)/ Stream19
导航Navigation Composego_router21
网络 / JSONRetrofit / Moshidio / freezed(要跑生成)22
列表LazyColumnListView.builder23
主题MaterialTheme(M3)ThemeData(M3,共用)24
依赖注入HiltRiverpod 自带16-17
◆ 折纸:回到那个比喻

整本书就一句话摊开:你每帧写下一张平摊的纸(Widget),框架照着长命的折痕(Element)把它折成真正会量会画的立体物(RenderObject)。

你现在回头看第 1 章那张「24 条直觉」表——那 6 条「直接放行」的(声明式、Expanded、Stack、CompositionLocal、ViewModel 分层、列表回收),你确认了它们真能带过来;那 9 条「得扔掉」的,你也看清了它们为什么错、正确的模型是什么。Compose 你原来也才懂一半——那藏起来的中间层,现在你两个框架都看得见了。

最后:给你那个学校 App 的起手式

合上书就能开工的顺序:

  1. flutter create school_app,按第 17 章的 feature-first 骨架建目录。
  2. 装四样:riverpod + riverpod_generator(状态·第 16 章)、go_router(导航·第 21 章)、dio + freezed(数据·第 22 章)、flutter_secure_storage(存 token·第 22 章)。
  3. 先搭登录:一个 AuthNotifier + go_router 的 redirect 守卫(第 21 章)。
  4. 课表页用 ListView.builder + AsyncValue 三态(第 19·23 章)。
  5. 数据走 Repository,缓存优先(第 22 章)。
  6. 逻辑写进 Notifier,配 Widget 测试(第 24 章)。
  7. 发版前过一遍上面那张极简清单,别忘了那台 Mac。

你不是从零开始——第 1 章就说了。现在你手上是一张摊开的、每一处折痕都看得清的图纸。去把它折成那个 App 吧。

⇄ 全书完 · 一句临别的话

Flutter 和 Compose 是一对表兄弟。这本书从头到尾做的一件事,是帮你认出哪里是「同一件事换了口音」(大部分)、哪里是「真的不同、必须重学」(那棵摆出来的 Element 树、单线程的并发、要手动划的重建范围)。把这两类分清,你就不再是「会一点 Flutter」,而是能推理 Flutter 了。祝你的学校 App 顺利交付。

这一章的一句话

交付 = 主题一次定义全局取用 + 需要原生时用平台通道下沉(你的 Android 功力在这里反成优势)+ 分层带来的可测性 + 别忘了 iOS 那半边和一台 Mac;而这一路的每一步,都能在你 Compose 的经验里找到对应——你要做的只是把口音换过来。