否决 Flutter 提案那天,我发现自己也说不清 Framework 管什么

2026 年 8 月 31 号之后那周,我们把模块 M 的 targetSdk 从 34 提到 36。Play 那边的新规是当天生效:新应用和应用更新都要以 Android 16(API 36)或更高为目标,需要更多时间可以申请延到 11 月 1 号。我们不想申请,提前两周就开始改。

出问题的不是编译,是报告页。灰度第二天,客服转来一条反馈:一台大屏折叠机,展开状态下从报告页切到检验单再切回来,页面上正在填的复诊备注没了,同时后台收到两条一模一样的提交。我拿自己的 Pixel 6 怎么试都复现不了,后来借了台平板,adb shell wm size 改了窗口尺寸才撞上------Android 16 对大屏设备(最小宽度大于 600dp)默认让方向和尺寸变更引发 Activity 重建,target 36 还能在 manifest 里 opt-out,等到 2026 年之后那版(预计 API 37)就不给退了。我们把状态保存写在 onSaveInstanceState 里,可那段备注是异步回调里塞进 ViewModel 的,重建时回调还没回来。

评审会上有人说:这块早晚要两端一致,不如用 Flutter 重写,一套代码,Android 这些版本行为也不用跟了。我当时驳回,理由说得很顺:报告页要调系统的 PdfRenderer 渲染检验单、要走硬件 Bitmap、导出那份 PDF 之前还得按合规要求清一遍内存里的数据,这些链路换成自绘引擎都得重新做一遍桥接,省下来的人力不够填。

会开完我坐在工位上,把「不用跟 Android 这些版本行为」这句话又想了一遍,忽然有点心虚。Flutter 的 Engine 跑在哪儿?跑在 Android 进程里。它拿到的那块画布是谁给的?谁在管它的生命周期、权限、窗口 insets、被系统回收之后怎么恢复?我张口就能说「Binder」「WMS」「SurfaceFlinger」,可要是有人追一句「Activity 重建这件事具体是谁发的、为什么 targetSdk 能改变它」,我答不上来。

我干了六年 Android,天天在用 Framework 提供的东西,却没认真读过它一行。那天下午我给自己列了两个问题,后来花了小半年才把答案勉强写清楚:换框架能换掉 API,换不掉平台 ;以及,「还有没有必要学」这个问法把两样完全不同类型的资产放到了同一杆秤上。

先把「Framework」这个词掰清楚

这个词在国内技术交流里一直是混着用的,混着用就会吵出很多假问题。

有人说的 Framework 是 frameworks/base 那一层系统源码:ActivityManagerService、WindowManagerService、PackageManagerService、ViewRootImpl、Binder、Zygote、SurfaceFlinger、ART。有人说的其实是「应用架构」------MVP / MVVM / MVI、模块化、自己封装的 base 层和路由。还有人拿它指「某套公司内部框架」。

面试里那句「你熟不熟 Framework」,问的常常是第一种;简历上写「熟悉 Framework」,写的往往是第二种。后面这篇只讲第一种,也就是平台层 。不是因为第二种不重要,是因为这次让我卡壳的正是第一种:targetSdk 改一个数字,为什么屏幕上那个 Activity 的生死就变了,这件事跟架构模式一点关系都没有,答案在 ActivityTaskManagerService 和 ActivityRecord 之间。

把平台层和应用层摆在一起看,一次点击会穿过这些地方:

层 谁在负责 这一层能换掉吗 我这次踩的坑在哪一层
业务代码 我们写的 Kotlin / Dart 随时能换 状态保存写错了位置
UI 工具包 View / Compose / Flutter Widget 换栈就换掉了 无
应用框架 ActivityThread、ViewRootImpl、Choreographer 换栈换不掉 Activity 重建、生命周期回调顺序
系统服务 ATMS / WMS / PMS / InputManager 换栈换不掉 配置变更决策在这里
跨进程与运行时 Binder、ART、Zygote 换栈换不掉 无
图形与内核 SurfaceFlinger、HardwareBuffer、Linux 换栈换不掉 PDF 渲染走的那条链在这里
flowchart TB subgraph APP["应用侧:换栈能改变的区域"] A["业务代码 Kotlin / Dart"] --> B["UI 工具包 View / Compose / Widget"] end subgraph PLAT["平台侧:换栈改变不了的区域"] C["应用框架 ActivityThread / ViewRootImpl"] D["系统服务 ATMS / WMS / PMS"] E["Binder / ART / Zygote"] F["SurfaceFlinger / HAL / Kernel"] end B --> C --> D --> E --> F A -. "Platform Channel / FFI" .-> C B -. "Platform View 嵌原生" .-> D

Flutter 换掉的是最上面两格的一部分。它的 Widget 树、布局算法、渲染指令生成、动画驱动都在 Dart 侧,Engine 用 C++ 把结果画到一块 ANativeWindow 上。可只要它跑在 Android 上,下面四格一格没少:进程还是 Zygote fork 出来的,Activity 还是被 ATMS 管的,窗口还是 WMS 分的,权限和 insets 还是系统发的,被系统回收还是要靠 onSaveInstanceState 那套机制恢复。

那次评审会上我说的「不用跟 Android 这些版本行为」,其实只在一种情况下成立:这个页面彻底不碰任何平台能力、不需要处理窗口变化、也不会被系统回收后恢复。医疗健康类 App 里的报告页,一条都不满足。

一次展开,在六个地方长什么样

后来我拿那台平板把整个过程录了一遍 Perfetto,又对着源码和 dumpsys 各看了一遍,把同一件事在六个层面的形态抄进笔记。这张表比任何一句「要学底层」都更能说明问题。

观察位置 看到的形态 能回答什么 答不了什么
用户眼睛 展开折叠屏,备注没了,提交两条 现象是什么 谁弄丢了状态
应用日志 onCreate 又走了一遍,ViewModel 是新实例 组件被重建了 为什么被重建
dumpsys activity activities 原 ActivityRecord 被销毁,新 record 有同样的 token 序列 系统确实杀了旧实例 决策依据是什么
dumpsys window Configuration 里 smallestScreenWidthDp 从 420 变成 720 触发源是尺寸档位跨过 600dp 为什么跨档就要重建
AOSP ActivityTaskManagerService 配置变更走到 relaunch 分支,target 36 且大屏时 opt-out 不生效 是平台按 targetSdk 选的分支 状态该怎么存
我们自己的代码 备注在异步回调里写进 ViewModel,没进 saved state 修复点在哪 前面五层的事

六行里,最后一行是我能改的,前五行是我得看懂的。以前我总觉得「学 Framework」是为了去改前五行------改系统服务、做 ROM、写黑科技。那次之后我改了看法:对绝大多数一辈子不碰 AOSP 编译的应用开发来说,读它的收益是别人改了前五行的时候,你能在一小时内定位到第六行,而不是猜、不是等到灰度、不是靠「升级撞运气」。

这个判断后面会反复用到:Flutter 值不值得学、Framework 值不值得啃,得先问「我平时被卡在第几行」。

换栈到底绕开了什么,没绕开什么

Flutter 的原理资料很多,我只写和这次决策有关的那部分:它究竟在哪一层切断了与平台的耦合。

传统 View 体系里,一个 TextView 最终是平台的一个 View 对象,测量、布局、绘制都走 ViewRootImpl 发起的那棵 View 树,绘制指令再交给 RenderThread 和 GPU。Compose 换了描述方式,但落点还是 View 树里的 AndroidComposeView,Choreographer 给的 VSYNC 信号还是那一个。Flutter 不一样:Widget 只是配置,RenderObject 做布局,生成的 Layer 树交给 Engine,Engine 用 Skia 或者后来的 Impeller 自己画。它不向系统要一个 View 来显示文字,它要的是一块画布。

这条差别带来两个真实后果。

一是一致性强 。同一份 Dart 代码在 Android 和 iOS 上生成几乎一样的绘制指令,不用两边各调一遍字号、行高、圆角。二是平台能力必须过桥。相机、蓝牙、生物识别、系统 PDF 渲染、通知、后台任务、深浅色和字体缩放、刘海与手势区、多窗口,全都要通过 Platform Channel 或者 FFI 回到原生那侧。Flutter 官方后来把 Dart 的执行从独立 UI 线程挪到平台主线程,让同步 FFI 调用不用再跨线程,正是在压这条桥的成本------桥没有消失,只是变短了。

混合栈里更明显。要在一个 Flutter 页面里嵌原生地图或者原生 WebView,得用 Platform View;Android 侧的 Hybrid Composition 系列方案改过好几轮,本质上还是让原生 View 和 Flutter 那块画布在 SurfaceFlinger 里合成。我后来在一台中低端机上量过一次:报告页里嵌一个原生 PdfRenderer 输出,滑动时的帧 P90 比纯原生实现差 6ms 左右,样本只有十轮,机器也旧,这个数撑不起任何结论,但足够说明「桥不是免费的」。

sequenceDiagram participant D as Dart Widget 树 participant E as Flutter Engine (C++) participant A as Android 应用框架 participant S as 系统服务 / SurfaceFlinger D->>E: Layer 树与绘制指令 E->>S: 提交到 ANativeWindow / GPU D->>A: Platform Channel 请求 PDF 渲染 A->>S: 通过系统 API 申请 HardwareBuffer S-->>A: 返回缓冲 A-->>D: 结果回传(序列化) Note over A,S: 状态保存、权限、insets、生命周期仍由平台决定

所以那句「换掉 UI 栈」是准确的,那句「不用跟版本行为」是不准确的。Android 16 让大屏默认可调整尺寸、方向变更触发重建,这件事不认你页面上画的是 View 还是 Widget------它认的是 Activity 的 Configuration,而承载 Flutter 的那个 FlutterActivity 一样是 Activity。

我后来把这条写进了组里的选型清单,一句话:换栈能省的是第二层的重复劳动,省不掉第三层往下的任何一次系统行为变更。

一次 Channel 调用,在六处的形态

「换栈换不掉平台」这句话听着像结论,摊开看一次调用就不抽象了。我拿报告页里那条最普通的 Channel 做过一张表:Dart 侧调一个方法,让原生侧把检验单 PNG 解码成可分享的临时文件,返回路径。

位置 形态 谁定的规则 出过什么问题
Dart 业务代码 await platform.invokeMethod('decodeReport', {'id': ...}) 我们 方法名改过一次,忘了同步
标准消息编解码 参数序列化成二进制,返回值同理 Flutter 的 codec 传了个不支持的类型,运行时才炸
Engine / JNI 从 Dart 侧切到平台线程,经 FlutterJNI 进 Java 层 引擎 3.29 前后 Dart 执行线程合并,同步 FFI 的开销变了
应用框架 MethodChannel 的 handler 跑在主线程,解码落到 BitmapFactory Android 大图在主线程解码,掉过帧
系统服务 / Binder 拿临时文件要走 FileProvider 授权,跨进程给外部应用 平台 target 升级后 URI 授权范围收紧,分享失败
图形与内核 像素缓冲实际落在 native / HardwareBuffer 平台 内存峰值比 Java 堆曲线高,heap dump 看不出来

六行里,第一行和第二行是跨端栈带来的,第三行是引擎的,后三行全是平台的。换栈只重写了前两行,后面四行一个字没改,还多出一条跨语言边界。那次方法名不同步的事故,恰恰发生在唯一一处「新增」的地方。

这也是我对「学 Framework 有没有用」最具体的回答:承载 Flutter 页面的那个进程里,Bitmap 像素在 native 堆、URI 授权由系统服务管、大图解码别放主线程、被回收之后状态得自己存------这些跟页面上画的是 Widget 还是 View 没关系。你要是只熟悉前两行,出了事连该看哪份日志都不知道。

我后来在混合栈的代码评审里加了一条很笨的规矩:每加一条 Channel,必须在契约文件里写清「跨过边界之后落到平台哪一层」。写完发现,大半 Channel 根本不该存在------它们做的事本来就能在 Dart 侧用纯数据完成,跨边界只是因为写的时候顺手。

Android 9 那道分界线

学 Framework 这件事,2018 年前后是另一种含义,这里得单独讲,不然容易把两条完全不同的路混成一条。

早期不少「Framework 技巧」实际是反射调用隐藏 API:拿 ActivityThread.currentActivityThread()、改 AssetManager 加插件资源、Hook Instrumentation 做免注册启动 Activity、反射 PackageManager 装插件。那时候读源码的直接回报很硬------读完就能做出别人做不到的功能。国内一整代插件化、热修复、双开方案都长在这块地上。

Android 9(API 28,2018-08)开始,官方把这条路系统性地收了。平台对非 SDK 接口做限制,无论直接引用、反射还是 JNI 获取句柄都算。名单分成几类:blocked(原 blacklist,无论 target 多少都不能用)、max-target-x(原 greylist-max-x,按应用 target API 分级收紧)、unsupported(原 greylist,当前可用但不承诺)、sdk(公开 API)、test-api(从 Android 11 / API 30 起并入 blocklist)。每个版本官方会发布一份 hiddenapi-flags.csv,Android 16 那份连校验和都给了。运行时的 enforcement policy 从 kNoChecks、kJustWarn 到 kDarkGreyAndBlackList 分档,debug 包和正式包看到的行为不一样。

这意味着两件事同时发生。

一方面,「读 Framework 源码 = 能调隐藏 API」这条变现路径基本关了。目标 API 一年一抬,Play 那边 2026-08-31 起要求 36、预计 2027-08 要求 37,每抬一次名单就收一轮,靠反射撑起来的功能每年都得重新赌一次。我见过一个老项目的插件框架在 target 34 升 35 时全线崩掉,最后花三周改成标准的动态功能模块,那三周买的不是新能力,是还债。

另一方面,解释力那一半的价值反而涨了。名单越复杂、target 分支越多、系统行为越按版本分叉,「为什么这次会这样」就越难靠搜索和试出来。前面那张六行表里,前五行全在这一半。

我给自己画过一条很不严谨但挺好用的线:2018 年之前,Framework 知识里很大一块是「能做什么」;2018 年之后,剩下的是「为什么这样」。前者被平台主动关掉了,后者平台关不掉,也没打算关。现在有人拿「隐藏 API 都用不了了,学 Framework 没用」来劝退,其实是在用前半句否定后半句。

真正该问的是:你打算拿它来做什么。想进手机厂商做 ROM 定制、进车机做 AAOS、做系统性能优化,那前半句压根不成立------那些岗位本来就在平台内部工作,名单是给第三方应用定的,不是给系统自己定的。做应用开发,那后半句才是你每天用得上的部分。

两条线是怎么长到今天这个样子的

把两条时间线并排放,很多争论会自己消掉。它们起点不同、要解决的问题不同,只是这几年恰好在「跨端」这个路口撞上了。

时间 平台 / 版本 具体变了什么
2008-09-23 Android 1.0 / API 1 四大组件、Binder、Dalvik 那套底座定型
2015 Dart 开发者峰会 Flutter 前身 Sky 亮相,跑在 Android 上,目标宣称 120fps 稳定渲染
2017-05-12 Flutter alpha 0.0.6 第一次对外可下载的版本
2017 Kotlin 1.2 / KotlinConf 官方 Android 支持 Kotlin;多平台项目概念提出,当时只有 JVM 与 JS
2018-08-06 Android 9 / API 28 非 SDK 接口限制落地,反射调隐藏 API 开始按名单收紧
2018-12-04 Flutter 1.0 Flutter Live 伦敦发布,第一个稳定版
2019-05 Google I/O Android 宣布 Kotlin 优先;Jetpack Compose 首次亮相
2021-03-03 Flutter 2 Web 转正、桌面进入早期访问、Dart 2 引入空安全(可选)
2021-07 Compose 1.0 生产可用,官方称当时 Play 商店已有 2000+ 应用在用
2022-05-12 Flutter 3 / Dart 2.17 六大平台 stable;Impeller 作为 iOS 实验预览首次出现,官方原文写着「尚未生产就绪」
2023-11 KMP JetBrains 宣布 Kotlin Multiplatform 稳定
2024-04 --- Google 一轮涉及多个团队的裁员波及 Flutter / Dart 部分岗位
2024-05 Google I/O 官方支持用 KMP 共享 Android 与 iOS 业务逻辑;称 top 1000 Android 应用中 95% 含 Kotlin 代码
2024-10-22 HarmonyOS NEXT / 鸿蒙 5 中国大陆市场正式发布,去掉 AOSP 代码,不再兼容普通 APK
2024-10-27 Flock 部分社区开发者发起 Flutter 分叉,质疑上游投入,主线仍是 upstream
2025-01-29 Dart 做了两年、追踪 issue 近 1400 赞的 macros 被整体取消,理由是热重载性能退化不可接受
2025-05 Compose Multiplatform 1.8.0 iOS 端标记 stable
2025 Android 16 / API 36 大屏设备(最小宽度 > 600dp)在 target 36 下默认方向/尺寸变更引发重建,允许 opt-out
2026-03-27 AOSP 官方建议改用 android-latest-release 而非 aosp-main;2026 年起源码改为 Q2、Q4 一年两次发布
2026-08-31 Google Play 新应用与更新须 target 36;可申请延至 11-01。API 37 预计 2027-08 起要求
2026 年内 Flutter 保持季度稳定版节奏,Impeller 在 iOS 成为唯一渲染后端、Android API 29+ 默认,桌面端跟进

表里最值得盯的不是哪个版本号,是两条线的运动方向 。平台那条线这几年一直在做同一件事:把行为按 targetSdk 分叉、把隐式能力收进显式声明、把后台与隐私的口子一年年扎紧。工具那条线在做另一件事:把 UI 描述从平台 API 里抽出来,先抽到声明式,再抽到自绘,再抽到可以多端共用。

一个在往「更细的平台契约」走,一个在往「更厚的抽象层」走。抽象层越厚,越需要有人知道底下那份契约写了什么------因为抽象层漏出来的地方,永远是你最需要解释的地方。

我核版本号那次,翻车翻在二手资料上

写这份笔记之前,我想在开头加一句「截至 2026 年 11 月 Flutter 最新稳定版是 X」。查了四个来源,得到四个答案。

中文百科页面在同一个「2026-05-12」下面,一处写 3.41.9 是最新,另一处写 3.47。一份英文技术博客按发版记录列:3.41(2 月)、3.44(5 月)、3.47(8 月)、3.47.5 配 Dart 3.13(9 月 18 日)。另一份 2026 年度综述写「2026 年最新为 3.44 / Dart 3.12」,看起来是把 5 月那次发布当成年度版本。还有一份把「2025 年 11 月 30 日仓库仍活跃」和「2026 年 8 月 12 日更新」混在一段里。

我没挑一个写死。最后落在笔记里的是一句很笨但不会错的话:按季度发稳定版,2026 年仍在持续发布,具体版本号发稿时以官方 SDK archive 为准。厂商自己公布的 2026 年 5 月月活开发者 150 万、同比增长 50%,我也标了「官方口径,未公布测量方法」。

同一轮核查里还撞见另一件事。搜「国内大厂 Flutter 落地」,前五篇中文文章里的案例段落几乎逐字一样:闲鱼核心页面、企业微信、QQ 音乐、飞书、瓜瓜龙,「两端适配成本降 60%」「迭代周期缩 40%」这几个数字连标点都一致。我没找到能追溯到团队一手技术复盘的出处,最后没往正文里放任何一个百分比。

这件事本身挺说明问题的:判断一门技术值不值得投入,先得能分辨你读到的是发布记录、厂商自报、问卷样本,还是被转了五手的宣传稿。这跟学不学 Flutter 没关系,跟要不要啃 Framework 也没关系,它是另一项独立的能力。

那些老办法,哪几个失效了,哪几个又回来了

做法 当年为什么有效 现在
反射 @hide API 做插件化 / 热修复 平台没设防,读完源码就能拿到句柄 Android 9 起按名单收紧,target 每年抬一次,基本还债了
用 Instrumentation Hook 免注册启动 Activity 同上 同上,且插件化主流方案已转向标准动态交付
多进程「保活」 早期后台限制松 后台启动 FGS 抛异常、FGS 类型与配额,商店与合规都过不了
把 Framework 源码当面试题背 岗位集中在应用侧,考八股 车载 / 系统岗的 JD 里变成实操项:改 AMS/WMS、AIDL、HAL
手写双端 UI 各一套 跨端方案不成熟 富交互、强平台集成的页面反而被重新认为更稳
自绘引擎 早期 shader 编译卡顿是硬伤 Impeller 预编译着色器把这块补上,2018 年前后的判断得重写
读 AOSP 源码 得自己拉 100GB 编译 门槛没降,反而升了,但在线源码检索和 trace 工具好用得多

最后两行是同一枚硬币。我 2019 年试过一次 Flutter 0.x 的预览版,在当时的中端机上滑动复杂列表会一卡一卡的,就此判了它「不适合我们的业务」,六年没再看。那次评审会前重新装了一遍,Impeller 已经把首帧那批 shader 在构建期预编译掉了,官方当年在 Flutter 3 的发布文里给出的对照是 Gallery 过渡动画最差帧快约 20 倍------那个数字我当年也读过,只是没把它和「以后会变」联系起来。

一个基于 2019 年性能表现做出的判断,被我当成了长期结论用了六年。这比「没学新技术」更值得记一笔。

还有第三条路,只是它不在同一个轴上

写到这儿得交代一句,不然 2026 年谈「跨端」只谈 Flutter 会显得漏了半个版图。Compose 与 Kotlin Multiplatform 这几年走的是另一条路线,而它们和这篇文章的问题关系还挺特别。

三种策略的差别不在谁更快,在共享的是哪一层:

策略 共享什么 UI 落在哪 要接受的新东西 对已有原生团队
Flutter 逻辑 + UI 自绘引擎,两端同一套 Dart、第二套 UI 栈、引擎固定成本 换得最彻底
KMP 业务逻辑 各端原生(Compose / SwiftUI) 多平台模块、expect/actual、Kotlin/Native 构建 换得最少
Compose Multiplatform 逻辑 + UI 自绘,基于 Compose Kotlin 多平台工具链 中等,Android 侧心智可复用

KMP 的时间线前面表里排过:2017 年作为多平台项目概念提出,2023 年 11 月稳定,2024 年 5 月 Google 在 I/O 上宣布官方支持用它共享 Android 与 iOS 的业务逻辑,2025 年 5 月 Compose Multiplatform 的 iOS 端转正。JetBrains 2025 年那份开发者生态问卷里,KMP 在跨平台使用者中的比例从 2024 年的 7% 到 18%;同一份报告引 Appfigures 的数据说 top 10000 App Store 应用里 KMP 从 2024 年初不足 1% 到 2026 年中近 3%,头部应用里 KMP 应用的总营收追平了 Flutter 应用。后面这两个数来自 JetBrains 委托的第三方数据和厂商报告,不是独立审计,我照例标口径。

有意思的是它对这个问题的影响,跟很多人想的不一样。

KMP 不要求你换掉 UI 栈,所以它对平台层的要求一点没减。 expect/actual 划出来的那道口子,恰恰落在平台能力上:网络、存储、权限、生命周期、硬件访问,每一处都要在 Android 侧写一个 actual,而那个 actual 里写的就是平台 API。共享层越厚,留给平台侧的那层越薄,但越薄越不能出错------它没有第二个人兜底。

Compose Multiplatform 在 iOS 上也是自绘。 也就是说它面对的桥、insets、生命周期、被回收后恢复,和 Flutter 是同一组问题,只是换了语言和组件库。前面那张十二项清单,对它一样成立。

所以 2026 年真正的变化,我觉得不是「Flutter 输给了 KMP」,是**「共享多少」从一个二选一变成了可调节的滑块**:从一个共享的网络模块,到一半业务逻辑,再到连 UI 一起共享,粒度随项目挑。这让「要不要换栈」这个问法更不准了------现在连「换多少」都能按模块定。

滑块底下那份平台契约始终在。这也是我最后没把 Compose / KMP 单开一章对比的原因:它们改变的是第二层的组织方式,不改变第六层那笔账。

顺带说个实际收获。今年我没上 KMP,但按它的思路把模块 M 盘了一遍:哪些代码真的与平台无关,哪些只是看起来无关。盘完发现报告页的状态层有一大半混着生命周期假设,被我拆成了纯状态与平台适配两块。这份清单没带来任何新技术,却让那次 target 36 的修复从「改一处补一处」变成了改一个地方。判断哪些逻辑属于哪一层,本来就是 Framework 视角给的。

角度一:这两样根本不是同一类资产

把「学 Flutter」和「学 Framework」放在同一杆秤上称,前提得是它们同类。我先拆一下各自装的是什么。

一门工具栈里的知识,粗分三层。最上面是用法 :某个类的构造参数、某个 Widget 的排列方式、某个 Gradle 配置项。中间是机制 :数据怎么流、谁触发谁、什么条件下会重来一遍。最下面是判断:这类问题该在哪一层解、代价由谁承担、什么时候不该用它。

Flutter 带给你的是:Dart 语法与 Widget API(用法)、自绘渲染管线与声明式重建(机制)、以及一套「一份逻辑如何覆盖多端」的工程判断。Framework 带给你的是:类名与调用顺序(用法)、Binder / 进程 / 生命周期 / 窗口 / 权限模型(机制)、以及「平台这次为什么改行为」的定位能力。

真正有意思的是它们的可迁移半径差得很远:

学到的东西 换个项目还在不在 大概能撑多久 我是怎么估的
某个 Flutter 版本的 Widget 参数 只在 Flutter 项目 一两个版本 3.x 里改过的组件属性我自己踩过
Dart 语言与异步模型 Dart 生态内 长 语言层变动慢
声明式 UI 的重建心智 Compose、SwiftUI、React 都吃得到 很长 我学完重组之后回头看 Compose 的 remember,几乎不用重学
自绘引擎与 GPU 管线 图形、游戏、车载 HMI 很长 和具体框架解耦
双端一致的协作方式 任何跨平台项目 中长 组织方式比工具活得久
某个 ActivityManagerService 方法的签名 只在对应 AOSP 版本 一两年 每个大版本都在改
反射调隐藏 API 的具体手法 基本没了 已经过期 Android 9 起按 target 收紧
进程、Binder、生命周期、窗口、权限模型 所有 Android 项目,包括 Flutter 承载层 十年以上 从 2008 年到现在没换过骨架
「按 trace 和源码定位平台行为」这套动作 所有端 长期 工具换了,动作没换

表里有个我自己以前没想明白的点:Flutter 里最值钱的那部分知识,恰好不是 Flutter 独有的 。声明式重建、状态与副作用分离、约束向下传递,这些东西在 Compose 上几乎原样成立。我 2023 年从 View 迁 Compose 时觉得重组绕,后来翻 Flutter 的 setState 与 Element 复用才把「为什么重组会跳过某些节点」想通。反过来也成立:Compose 写熟了的人去看 Flutter,卡住的多半是 Dart 和生态,不是渲染心智。

所以「学 Flutter」这笔账不能按「学会一个框架」算。它有一大块会存进你原有的 UI 账户里,就算这个项目最后没用 Flutter 也取不出来才算亏。而「学 Framework」那笔账,最值钱的部分也不在类名,在那套「平台是个状态机」的看法------这次 target 36 的重建问题,我不是靠背下 ActivityRecord 的字段解决的,是靠知道「该去系统服务那层找决策」解决的。

两者重叠区其实很小,小到「二选一」这个问题本身就有点怪。它们一个往横向铺(几个端怎么共用一份逻辑),一个往纵向挖(这一端为什么不按你想的跑)。你被卡住的地方决定该补哪个,跟哪个更火没什么关系。

角度二:把账算到工程里,别算在喜好上

评审会那天我驳回提案说的那几句,其实只在一种项目形态下成立。后来我把手上和见过的项目分了三类,各自的账完全不同。

A · 原生主 App B · 跨端新业务 C · 系统定制 / 车载
典型页面 报告页、检验单、隐私合规流程 运营活动页、内部工具、轻量表单 桌面、多屏、车控、音频策略
平台能力用量 高(PDF、相机、生物识别、通知、后台) 低 极高(改的就是平台)
双端一致的价值 中,但两端往往还有各自的合规差异 很高 不适用
换栈省下的人力 少,甚至为负 明显 无
换栈新增的成本 桥接、混合栈、两套崩溃栈 低 无
Framework 深度的需求 中(要能定位平台行为) 低 高(要能改平台)

我们那个健康类 App 属于 A。报告页要调系统 PdfRenderer 渲染检验单、要走硬件 Bitmap、导出之前还得按合规口径把内存里那份数据清干净。这些不是「加个插件」的事:PDF 渲染那条链落在图形与内核层,清理内存的时机落在生命周期层,两端合规要求的差异落在业务层。把 UI 换成自绘,省的是布局代码,这三处一处没少,还多出一层跨语言边界。

我在自己机器上做过一次很粗的对照:一个几乎空白的 Flutter release 工程和一个同等内容的最小原生工程,前者 APK 大出 7MB 上下(AGP 8.2.2、minSdk 24、只构建一次,样本量等于一,只能看量级)。这 7MB 不是缺点,是引擎和 Dart 运行时的固定成本;对一个日活不大、走应用市场分发的健康类 App,下载转化会真的在意这个数。可对一个企业内部工具,它连讨论项都算不上。

B 类我做过一次。去年一个运营活动页,双端样式要求像素级一致、生命周期只有三周、不碰任何系统能力。那次用跨端方案是对的,两天出两端,原生侧只留一个入口。要是那次我也搬出「报告页的教训」来否决,就是拿 A 类的账去管 B 类的项目------这类错我以前犯过,而且当时觉得自己挺有原则。

C 类完全是另一个世界。我翻过几份 2026 年的车机岗位 JD,要求写得比应用岗具体得多:AMS / WMS / SurfaceFlinger 的裁剪与定制、Binder IPC、Zygote 启动流程、HAL 与 AIDL、AOSP 编译与裁剪、CTS/VTS、功能安全流程,还有一条「协助 App 团队开发,拓展非原生系统接口以支持应用调用需求」。最后那句我盯了很久------它把应用层和平台层的关系写得比任何一篇技术文章都清楚:有人负责把平台改出一个接口,有人负责在接口上写业务。

真要往 C 类走,门槛得先摊开。AOSP 官方现在写的硬件要求是 400GB 可用磁盘(250GB 检出 + 150GB 构建)、至少 64GB RAM;72 核机器全量构建约 40 分钟,6 核机器同样 64GB 内存要跑约 6 个小时。2026 年起源码改成 Q2、Q4 一年两次发布,官方建议用 android-latest-release 而不是 aosp-main。这不是「买本书翻翻」的投入,是得给家里那台机器加内存、给项目排专门的时间。

我第一次拉源码是 2019 年,当时按一篇博客配了 16GB 内存加 200GB 盘,repo sync 跑了一夜,编译到一半 OOM。第二天才去读官方的 requirements 页。那次之后我学乖了:评估一门技术的成本,先去看官方的硬件要求和发布节奏,别先看教程的目录。

还有一笔账最容易被漏掉:混合栈不是「一半 Flutter 加一半原生」的和,是积。两套构建脚本、两套依赖升级节奏、两套崩溃栈与符号表、一条需要双方共同维护的 Channel 契约。我在一个项目里见过 Channel 方法名改了没同步,Android 侧静默走空分支,线上三天才发现------单测全绿,因为两端各自测各自的。这类成本不会出现在选型 PPT 的收益栏里,它会在半年后的排期里慢慢冒出来。

一次跨端评估,我最后只留了四个数

前面反复说「量三个数」,把去年那个活动页的完整记录贴一下。它是 B 类项目:三周寿命、双端样式要求一致、不碰系统能力。这次换栈是对的,但对的依据不是「跨端更快」,是下面四行数。

数 口径 换之前(原生) 换之后(Add-to-App)
包体积增量 release APK,单 ABI,同一台构建机,各构建一次 基线 +7.4MB
首次进入耗时 冷态(am force-stop 后),十轮,取 P50 / P90 210 / 260ms 380 / 520ms
状态丢失次数 旋转、切后台再回、被系统回收后恢复,各 5 次 0 初版 3 次,修完 0
跨语言边界数量 契约文件里登记的 Channel 方法数 0 初版 5 条,收敛到 2 条

四个数里有两个变差了:包体积大了一截,首次进入慢了近一倍。这两项写在表里不是为了自我批评,是因为这个项目的收益本来就不在这两格------双端样式只写一遍、三周内上线、没有后续维护负担,才是它换栈的理由。要是一个日活几十万、走应用市场分发的页面,+7.4MB 和 170ms 就得认真算进下载转化和首屏体验,结论可能反过来。

状态丢失那三次最值得记。初版我按原生页面的习惯,把状态放在跨端侧的内存里,旋转和回收没做对称处理,第一次测就丢了三回。修的地方不在 Dart 侧,是承载它的那个 Activity 的 saved state。这个坑和报告页那次其实是同一个:状态归谁管,由生命周期决定,不由 UI 用什么画决定。

Channel 从五条收到两条,靠的是前面那条「每加一条 Channel 就写清它落到平台哪一层」的规矩。写完之后发现三条可以直接用纯数据在 Dart 侧算完,跨边界只是当时顺手。

所有数字我都在记录里标了限制:一台设备、十轮、单次构建、没测 iOS、没做长时间老化测试。它们只够支撑「这个活动页可以这么干」,撑不起任何关于框架优劣的结论。

json 复制代码
{
  "case": "三周寿命的运营活动页,Add-to-App",
  "date": "2025-11",
  "devices": ["Pixel 6 / Android 13"],
  "samples": 10,
  "abi": "armeabi-v7a",
  "metrics": {
    "apk_delta_mb": 7.4,
    "cold_entry_p50_ms": [210, 380],
    "cold_entry_p90_ms": [260, 520],
    "state_loss_count": [0, 3, 0],
    "channel_methods": [0, 5, 2]
  },
  "limits": "单设备 / 十轮 / 单次构建 / 未测 iOS / 未做老化",
  "validUntil": "仅对这一类项目成立,报告页不适用",
  "reviewer": "组里另一位 Android",
  "reviewDate": "2026-02"
}

最后三行是那次之后补的。以前我记实测数据只记数字,半年后被人问「这个结论还成立吗」,答不上来,只能重新跑一遍。

四种组合,四种完全不同的活法

前面按项目类型分了 A / B / C 三类账,落到工程目录上,其实国内团队常见的是四种组合。它们的构建拓扑、崩溃归因路径、团队分工都不一样,混着谈「要不要上跨端」最容易吵不清。

组合 目录形态 崩溃归因 谁维护边界 适合什么
纯原生单栈 一个 Gradle 工程,一个符号表 一条栈 无边界 强平台集成、强合规、长生命周期主 App
Add-to-App 混合 原生工程 + 独立 Flutter module,产物作 aar 引入 两条栈,按构建号对齐 原生侧一人 + 跨端侧一人,共管契约文件 已有成熟 App,只把新页面交给跨端
跨端为主 + 原生能力模块 Flutter 工程为主,原生侧只写 plugin 两条栈,主栈在 Dart 跨端团队主导,原生侧提供插件 新业务、双端一致优先、平台能力轻
系统定制 + 上层应用 AOSP 树 + 独立 App 工程,接口用 AIDL 约定 系统侧 logcat/tombstone + 应用侧 平台团队提供接口,App 团队消费 车机、座舱、专属硬件

我们那个健康类 App 属于第一种,报告页那次提案其实是要把它挪向第二种。真要挪,目录会变成这样:

text 复制代码
health-android/
├── app/                                  # 原生主工程,承载 Activity 与合规链路
│   └── src/main/java/com/example/health/
│       ├── report/ReportActivity.kt      # 生命周期与状态保存在这里
│       └── bridge/ReportChannel.kt       # Channel handler,只做薄转发
├── flutter_module/                       # 独立 module,产出 aar
│   ├── lib/report_page.dart
│   └── pubspec.yaml
├── docs/
│   ├── stack-decision.md                 # 决策记录,带成立条件与复核日
│   ├── channel-contract.json             # 跨语言契约,带版本
│   └── platform-notes/                   # 平台层笔记十七页
├── tools/
│   ├── note_index.py
│   └── check_channel_contract.py         # CI 里比对两端契约版本
└── captures/                             # 按构建号归档 mapping + Dart symbols

多出来的不是几行代码,是四个以前不存在的东西:第二套构建产物、第二套符号表、一份必须双方共同维护的契约文件、一个比对契约版本的 CI 卡点。这四样我在那次评审会上一样都没提,当时的注意力全在「页面重写要几天」。

契约文件我后来写成这样,最短可用版:

json 复制代码
{
  "contractVersion": 3,
  "updated": "2026-10-18",
  "owner": "原生侧 / 跨端侧 各一人",
  "methods": [
    {
      "name": "decodeReport",
      "args": { "id": "String", "maxEdgePx": "int" },
      "returns": "String (content Uri)",
      "platformLayer": "应用框架 -> FileProvider 跨进程授权",
      "runsOn": "主线程收到,大图解码需切到后台线程",
      "sinceTargetSdk": 34
    },
    {
      "name": "requestLocationPurpose",
      "args": {},
      "returns": "bool",
      "platformLayer": "权限模型;先弹用途说明再申请",
      "runsOn": "ui thread",
      "sinceTargetSdk": 33
    }
  ],
  "rule": "任何一端改方法名或参数,contractVersion 加一,CI 比对两端声明,不一致直接失败"
}

platformLayer 这一列是我加的,前面说过,它逼着写 Channel 的人想清楚「跨过边界之后落到平台哪一层」。第二条的 requestLocationPurpose 现在看有点多余------用途说明弹窗本来就该在原生侧按合规口径独立完成,不该由 Dart 侧发起,这条留着是当时过渡期的产物,我标了待删,等下个版本一起收掉。

第三、第四种组合我没长期做过,只跟着别的项目走过评审。跨端为主那种最容易被低估的是「原生能力模块」的维护成本:plugin 一旦要跟进平台新版本行为,跨端团队得等原生侧排期,而原生侧往往只有半个人,于是边界就成了排期瓶颈。系统定制那种反过来,App 团队等平台团队给 AIDL 接口,接口一改两边一起返工,那份 JD 里「拓展非原生系统接口以支持应用调用需求」说的就是这个岗位。

四种组合没有高下,但别用第一种的目录结构去估算第二种的成本,我那次评审就是这么算错的。

角度三:招聘市场上那些数字,数的是四件不同的事

「还有没有必要学」背后真正想问的,多半是「学了还有没有岗位」。这个问题得先把几组被到处引用的数字拆开,它们压根不在同一个口径上。

数字 出处类型 数的是什么 不能拿来说明什么
Flutter 37%、React Native 37%、KMP 18%(2025) JetBrains 开发者生态问卷 受访开发者自报「在用」的跨平台框架比例 岗位数、应用数、市场份额
KMP 从 7%(2024)到 18%(2025) 同上,JetBrains 官方页面亦引用 同一批问卷的年度变化 绝对规模,基数不同
Flutter 9.4%、RN 8.4%(2024) Stack Overflow 开发者调查 全体受访者中使用比例 2025 年之后的趋势------2025 年问卷改了技术题分类,不再列这两个框架,没有可比数
月活开发者 150 万、同比 +50%(2026-05) 框架官方发布公告 厂商自报的活跃开发者 独立第三方验证,测量方法未公布
top 10000 App Store 应用中 KMP 从不足 1%(2024 初)到近 3%(2026 中) Appfigures 数据,JetBrains 报告引用 头部应用里的技术占比 全部应用、国内商店、Android 侧
top 1000 Android 应用 95% 含 Kotlin 代码(I/O 2024) 平台官方 头部应用代码构成 「还要不要学 Java」这种非此即彼的结论

我见过太多文章把这六行揉成一句「Flutter 已占 46% 市场」,然后据此推出该不该转。它们数的是开发者、应用、问卷回答、下载量、岗位数里完全不同的东西,任何一个单独拿出来都撑不起职业决策。

真正有点用的是岗位描述本身,因为那是有人愿意付钱换的具体工作。我按 2026 年能搜到的 JD 粗略归了下类,应用侧的活大致分三堆:

  • 业务 UI 与功能交付:写页面、接接口、跟版本、改 bug。量最大,也最容易被跨端方案和生成工具压缩。
  • 平台与工程侧:性能、内存、启动、崩溃归因、构建与 CI、多模块、SDK 治理、隐私合规链路。量小一档,但 JD 里开始明确写「能定位系统行为」。
  • 系统与终端侧:ROM 定制、AAOS 车载、HAL / AIDL、多屏与音频策略、CTS/VTS、功能安全流程。量最小,门槛最高,且明显在往车机和智能终端扩。

一个招聘站在 2026-11-04 更新的岗位抽样里,Framework 开发工程师有 72.5% 落在 20--50K/月,3--5 年经验要求占 45%,本科占 73.9%。这是招聘站自己的样本,不是全行业统计,我拿它只当一个方向性的读数:这类岗位不招应届练手,它要的是已经踩过坑的人。

国内还有个绕不开的变量。HarmonyOS NEXT 在 2024-10-22 正式发布,去掉 AOSP 代码、不再兼容普通 APK;2025 年起华为新品默认搭载,头部应用陆续完成原生版本。这对国内应用岗是实打实的分流------同一个功能可能要交两版。2025 年初公开报道里,原生鸿蒙上架应用和元服务 2 万个、注册开发者 720 万,这些是华为自己公布的数,同样得标口径。

但有个反直觉的推论:平台越碎,懂「平台契约」的人越有用,而不是越没用。ArkTS / ArkUI 的语法要重学,可「分布式软总线」处理跨设备通信时面对的问题,和 Binder 面对的是一类问题;多端部署的窗口与生命周期管理,换个名字还是那套状态机。我在一份鸿蒙适配的技术分享里看到作者用 Binder 类比软总线,底下评论说「一下就懂了」。能迁移的是机制层的理解,不能迁移的是 API 记忆,这句话对 Flutter、对鸿蒙、对以后任何新平台都成立。

岗位名字这两年确实在变:Android 开发工程师、智能终端开发、座舱 HMI、端侧 AI 应用。名字会一直变,三堆活的分类方式没怎么变。判断自己站在哪一堆,比猜明年哪个框架火要有用得多。

角度四:一百小时能换回什么

时间成本这块我不想写「都要学,终身学习」这种话。一百小时就是一百小时,得说清投在哪儿、多久回本、什么时候会贬值。

我按自己这两年实际花掉的时间,估了三种投法(都是我自己这种中等熟练度、以应用开发为主的情况,换个背景数字会差很多):

投法 前 20 小时 到 100 小时 主要回本方式 贬值风险
系统学 Flutter 并做一个混合栈 Demo 跑通 Add-to-App,把一个页面嵌进现有 App 能独立交付中等复杂度的跨端页面,处理 Channel 与 Platform View 有跨端需求时直接省人力;声明式心智反哺 Compose 版本 API 变动快,但机制层不贬
系统啃 AOSP 全量源码并编译 大概率卡在环境(400GB / 64GB 门槛) 刚能编译、跑起模拟器,还没读明白几个模块 只对 C 类岗位直接回本 低,但投入太重,半途放弃率极高
按需读 Framework:每次线上问题追到平台层就记一页 学会 dumpsys 三件套 + 在线源码检索 + 对着 trace 找分支 攒出十几页「现象 → 系统决策 → 代码修复点」的笔记 每次版本升级与疑难崩溃都省时间 几乎没有,记的是机制不是 API

第三行是我最后选的,也是我觉得对绝大多数应用开发最划算的一条。它不需要编译 AOSP,不需要 400GB 盘,甚至不需要通读任何一本书。那次 target 36 的事故之后,我给自己定的动作很土:每遇到一个「搜不到答案」的问题,强制往上追一层,把追到的系统决策写进 docs/platform-notes/,一页只记一件事。

十二周下来攒了十七页。里面有 Android 16 的配置变更分支、内容提供者安装早于 Application.onCreate、通知在 target 33 之后的行为差异、大屏 insets 与手势区重叠、某个 SDK 在 target 35 下被前台服务类型卡住。没有一页是「系统学习」的成果,全是事故逼出来的。但到第九周提另一个模块的 target 时,我第一次在改代码之前就能列出该验哪几处------那才是这十七页真正换回来的东西。

Flutter 那二十小时我也花了,只是目标定得很窄:不学状态管理全家桶,只做一件事------把现有 App 里一个纯展示页用 Add-to-App 嵌进去,然后量清楚三样东西:包体积增量、首次进入耗时、以及退出时状态怎么丢的。做完我就停了。这次投入没让我成为 Flutter 开发,但下次评审会再有人说「换掉就不用跟系统版本」,我能拿出具体数字而不是拍桌子。

有个判断我改过:以前总觉得「不学到能独立交付就等于没学」。现在觉得对这两样都成立的说法是------学一半的收益不在产出,在判断力。一百小时换不来一个跨端专家,换得来一份不会签错字的选型意见;一百小时也换不来一个 Framework 工程师,换得来一次不用灰度就定位到的崩溃。

四条不同步的时间线,才是升级真正的麻烦

聊风险时大家习惯盯着「框架会不会被砍」,可实际把项目搞焦头烂额的,多半是几条版本线不同步。我们那个 App 里同时跑着五套各自演进的东西:

组件 谁定节奏 大概频率 升级时改的是哪一层 最容易撞上谁
targetSdk / 平台行为 Google Play 与 AOSP 一年一抬,8 月底大限 运行时行为分叉 权限、后台、窗口、前台服务
AGP / Gradle / JDK Android 工具链 一年多次 构建产物与脚本 各家 Gradle 插件,含跨端侧
Kotlin / Compose compiler JetBrains 与 Google 一年多次,两者版本绑定 编译期 KMP、协程、序列化插件
Flutter SDK / Dart SDK Flutter 团队 季度稳定版 引擎与 UI 工具包 Android 侧构建脚本、插件生态
三方 SDK 各家厂商 不定 运行时 + 隐私链路 所有上面几项

这里有个特别容易被忽略的耦合:Compose compiler 版本和 Kotlin 版本是绑死的,Kotlin 一升,compiler 得跟着走;而 KMP 又吃同一套 Kotlin。于是「把 Kotlin 升一下」这件事,在同时用 Compose 和 KMP 的工程里会牵动两条链。Flutter 那条看起来独立,但它的 Android 产物照样由 AGP 构建,AGP 一升,Flutter 的 Gradle 插件如果没跟上就会在配置阶段报错。

我吃过一次亏。去年有回图省事,把 AGP 和 Flutter SDK 放在同一个 PR 里升,构建直接挂在配置阶段,报的是某个 Gradle API 找不到。当时两头都在改,我花了大半天才分清是构建侧的移除,不是引擎侧的问题。后来拆成两个 PR,一个只动构建、一个只动引擎,各自跑通再合,半天就完了。

从那以后升级顺序固定下来:

text 复制代码
1) targetSdk        ------ 先动运行时行为,因为它会改「同一份代码跑出什么结果」
2) AGP / Gradle / JDK ------ 再动构建,一次只升一个主版本
3) Kotlin / Compose compiler ------ 编译期,按官方对应表成对升
4) Flutter SDK / Dart ------ 引擎与工具包,单独一个 PR
5) 三方 SDK          ------ 最后,逐个升,每个跑一遍隐私链路自查

顺序背后的理由只有一条:每步只引入一个变量,出事时才知道该往哪一层追。这跟「学 Framework」其实是同一件事------知道系统在哪一层做决策,才知道该把哪条版本线单独拎出来验。

每个组合我留一份机器可读的快照,出问题时能一眼看出「上次跑通的是哪套」:

json 复制代码
{
  "capturedAt": "2026-09-03",
  "commit": "9f3c1a7",
  "agp": "8.2.2",
  "gradle": "8.2",
  "jdk": "17",
  "kotlin": "1.9.22",
  "composeCompiler": "1.5.8",
  "compileSdk": 34,
  "targetSdk": 36,
  "minSdk": 24,
  "flutter": "未引入",
  "devices": ["Pixel 6 / Android 13", "平板类设备 / Android 16"],
  "state": "升级窗口期快照,每次 PR 更新一次"
}

flutter 那栏现在写着「未引入」。留着它不是为了将来一定会填,是提醒自己:这张表描述的是当下的组合,不是立场。

角度五:技术会死,身份别绑在上面

风险这一面得写足,但两边都得写,不然就成了劝退或者站台。

Flutter 这边真实发生过的几件事:2024 年 4 月,Google 一轮涉及多个团队的裁员波及 Flutter / Dart 的部分岗位,按当时 Google 方的说法被裁的多是基础设施与运维角色、路线图未变;2024-10-27,部分社区开发者发起 Flock 分叉,公开质疑上游投入不足;2025-01-29,Dart 团队宣布整体取消 macros------这个特性做了两年多,追踪 issue 有近 1400 个赞,取消理由是编译期深度语义分析让热重载性能退化到不可接受,官方的原话是「在合理时间内离解决问题还太远」;2025 年 Google 停止维护 flutter_markdown,交给社区接手。

这些都是真的。另一面也是真的:2025 年 Dart 与 Flutter 团队按季度发了八个稳定版(四个 Flutter、四个 Dart);Impeller 完成迁移,iOS 移除 Skia、Android API 29+ 默认;Canonical 主导桌面端多窗口维护;官方贡献者数在 2026 年那份公告里过了 1700;车机上也有量产落地。分叉发起两年后,主流社区反应平淡,主线仍是 upstream。

我的读法是:「没死」和「可持续性曾被认真讨论过」是两件可以同时成立的事。选型时该知道后面那件,但不该拿前面那件去否认后面那件。尤其别把「Google 会不会砍」当成个人学习决策的主变量------Dart 官方押了两年注的 macros 说砍就砍,说明连框架自己都无法给路线图打包票;平台那边也一样,AOSP 2026 年起把源码发布改成一年两次,target API 一年一抬,隐藏 API 名单一版本一收。

真正能锁死人的从来不是技术死了,是把判断外包给了路线图。我见过 2015 年那批把全部身家押在某个插件化框架上的人,框架没死,是 Android 9 的名单让它没法用了。他们不是学错了东西,是把「我会什么」定义成了「我熟哪个框架」。

平台侧的风险也得摆出来,免得显得我只在挑跨端的刺:国内鸿蒙分流、target 年年抬、反射路径关闭、生成工具把基础 UI 编码压得很便宜、初级岗简历通过率走低。这些是真变量。可它们压缩的恰好是「用法」那一层,也就是前面半衰期表里最短的那两行。

把身份绑在「机制层 + 判断层」上,比绑在任何一个名字上都稳。这句话听着像口号,但落到动作上很具体:我现在的笔记里,ActivityTaskManagerService 的方法名一个都没背,记的全是「配置变更的决策在系统服务层,target 决定走哪个分支」这类不会随版本改掉的话。

角度六:这笔账最后落在谁头上

前面五个角度都围着技术打转,可这类决策在真实团队里卡住的往往不是技术。它卡在「谁对这一端的行为负责」。

跨端方案最早要解决的是人力问题,不是渲染问题。2008 年之后那几年,一个产品要上两个商店,就得两拨人各写一遍;需求改一次,两端排期各排一次,上线时间还得对齐。2015 年 Sky 在 Dart 开发者峰会上亮相时打的是「稳定 120fps」,看着像性能指标,背后其实是另一句:如果自绘能达到这个帧率,一套代码覆盖两端才在商业上成立。硬件能不能扛住自绘,决定了那个组织方案能不能落地。

十几年过去,抽象层厚了好几层,可组织问题换了个形式还在:抽象层把工作分成两半之后,出了事,谁去回答「这一端为什么这样」。

我把见过的分工摆成一张表,最后一列是很少被写进方案里的:

分工方式 谁提方案 谁能否决 出事时先找谁 容易烂在哪
原生团队兼管跨端模块 原生侧 原生侧 原生侧 没人真正维护 Dart 侧生态,依赖一路拖着
独立跨端小组 跨端组 平台 / 合规 跨端组,再转原生 责任在边界上往返,一次事故两次转手
按模块认领,边界写进契约文件 模块负责人 平台 + 合规复查 契约里登记的 owner 契约没人更新就形同虚设
系统定制 + 上层应用分团队 平台团队给接口 平台团队 按 AIDL 接口分段 接口一改两边返工

我们组那次争论的就是第二种。有人提议成立跨端小组,把新页面都交出去,理由是「原生这边不用再跟双端样式」。争到最后没成立,不是因为谁说服了谁,是因为有人问了一句:报告页在 Android 16 大屏上重建导致重复提交,工单派给谁?

那一刻大家发现,跨端小组能接管的是页面代码,接管不了这一端的行为 。配置变更由系统服务决定,状态恢复由 Activity 生命周期决定,权限时机由合规口径决定。真出了事,还是得有人能打开 dumpsys、能读源码分支、能跟复查方解释清楚。这个人不在跨端小组的编制里,问题也不会因此消失。

后来我们落的是第三种:按模块认领,边界写进 channel-contract.json 和 stack-decision.md,每条 Channel 登记 owner,每份决策记录写复核人和到期日。没解决所有问题,但至少工单派得出去,也不会出现「我以为你处理了」。

这跟这两样该不该学,其实是同一个问题的另一面。学 Framework 的人,在组织里承担的是「对平台行为给出解释」这个角色;学跨端的人,承担的是「让一份逻辑覆盖多端」这个角色。两个角色都得有人干,但只有一个能在平台改行为的那天被叫到会议室里。哪个更稀缺,不用看招聘数据也能感觉到------前面那三堆活的分类里,越往下走,能接的人越少。

还有一处很现实:这个角色通常不写在 JD 里。JD 会写「熟悉 AMS/WMS」「有性能优化经验」,不会写「升级出问题时不靠猜」。可组里真到发版那天,找的就是这个人。

有次跟一位做车机的朋友聊,他们那边更直接:平台团队改一个系统服务,App 团队第二天来问「为什么我的页面不显示了」,中间没有第三方可以推。他说他们招人时最看重的不是会不会写某个框架,是「能不能自己把问题追到我们这层来,然后清楚地问出该问的那句话」。这句话我记了很久,它比「深入 Framework 源码」这种说法准确得多。

平台能力清单,十二项逐条过

决策框架第二问里那份清单,评审会上我只列了三项,事后补到十二项。后来每次有人提换栈,我就把这十二行打印出来逐条勾「保留 / 免掉 / 新增」。它比任何对比文章都更能预测这次重写会不会变成填坑。

# 项 换栈后 决策落在哪一层 我们项目里对应哪段
1 权限与用途说明时机 保留 应用框架 + 系统服务 定位前的用途说明弹窗,合规复查项
2 生命周期与状态恢复 保留 应用框架 报告页备注,这次事故的正主
3 配置变更与大屏窗口 保留 系统服务按 target 分叉 sw600dp 跨档触发重建
4 insets 与异形屏 保留 应用框架 全屏报告页的刘海与手势区
5 通知与后台启动限制 保留 系统服务 复诊提醒的点击链路
6 前台服务类型与配额 保留 系统服务 报告导出服务
7 精确闹钟 保留 系统服务 复诊提醒的定时
8 图形与硬件缓冲 保留,另增一层桥 图形 + 内核 PdfRenderer 与硬件 Bitmap
9 文件与跨进程 URI 授权 保留 系统服务 报告分享给外部应用
10 隐私链路与内存清理 保留 生命周期 + 业务 导出前清内存里的数据
11 崩溃栈与符号表 新增一套 工具链 mapping 与 Dart symbols 按构建号归档
12 构建与依赖节奏 新增一套 工具链 Gradle 与 pubspec 两条升级线

逐条说几个最容易被当成「加个插件就行」的。

第 1 项不是能不能调 API 的问题。 用途说明得在申请系统权限之前弹,这个时机由生命周期和合规口径共同决定,跟 UI 用什么画没关系。换成自绘之后弹窗是能画出来,但「谁先谁后」仍然是 Activity 的生命周期说了算;我在别的项目见过把说明写在跨端侧、结果被原生侧某个 SDK 抢先初始化,本质和前面那次事故是一类错误。

第 3、4 项这次同时踩了。 大屏配置变更是系统服务按 targetSdk 选的分支,insets 是窗口给应用的一组边界值。承载页换成 FlutterActivity 之后,这两项一样要处理------官方后来推的 foldable 支持、多窗口姿态适配,做的正是把平台这几项重新包装给上层用,不是把它们消掉。

第 8、9 项决定了我那次驳回的理由成不成立。 检验单走系统 PDF 渲染,像素缓冲落在 native / 硬件侧;分享出去要 FileProvider 授权,是一次跨进程 URI 授予。这两件事在跨端侧都得先过 Channel 回原生,再由平台去完成。也就是说换栈之后它们不仅还在,还各自多了一跳。评审会上我说「省下来的人力不够填」,凭的是直觉;填完这张表,才有了能拿出手的依据。

第 10 项最不该交给桥。 导出前清内存里那份数据的时机,得贴着生命周期和一个明确的业务边界,中间多一层跨语言调用就多一处可能漏掉的地方。医疗健康类 App 里这类链路要能被复查方逐行问,多一层就说不清。

第 11、12 项是纯增量,以前完全不存在。两套崩溃栈意味着同一次构建得同时归档 mapping 和 Dart 符号;两条依赖线意味着任何一次升级都得看前面那张版本矩阵。这两项不会在选型会上被讨论,但会在往后每个季度慢慢收利息。

勾完之后我自己用的规则很粗暴:十二项里勾「保留」和「新增」合计超过八项,这个页面就别换栈;免掉大半、且剩下的都能用纯数据完成,才值得进入混合栈评估。报告页那天我数出来是十一项,只有一项能免掉------双端样式一致的那点重复劳动。

这张表还有个副作用:它把「要不要学 Framework」变得很具体。十二行里有九行的决策落在应用框架或系统服务层,你不一定要能改它们,但至少得知道它们存在、知道出问题该看哪份 dumpsys、知道 targetSdk 会在里面选分支。这九行就是那笔躲不掉的账。

与其二选一,不如先问三个问题

把前面五个角度收一收,我后来在组里用的是一套很土的判据。它不回答「Flutter 好不好」,只回答「这次这个页面该怎么写、我这个季度该补哪块」。

第一问:我要换掉的是哪一层? 拿开头那张六层表定位。如果只是嫌两端 UI 写得重复,要换的是第二层;如果是嫌 Android 版本行为难跟,那根本不在前两层,换栈解决不了。

第二问:换完之后哪些账还在? 把平台能力列一遍,逐项打勾:权限与用途说明、生命周期与状态恢复、窗口 insets 与折叠展开、后台任务与通知、相机/生物识别/PDF 这类系统能力、图形缓冲与内存清理、崩溃栈与符号表、隐私合规的数据链路。勾掉的越少,跨端收益越实;勾满的项目,换栈只是把工作量挪到桥上。

第三问:未来三年我在哪类问题上被考核? 业务交付速度、平台工程质量、还是系统级定制能力。这一问决定学习投入往哪儿放,跟项目选型反而不是一回事------可以在一个纯原生项目里补跨端视野,也可以在跨端项目里补平台层理解。

flowchart TD Q1{"要换掉的是哪一层"} Q1 -->|"第二层:UI 重复劳动"| Q2{"平台能力勾掉几项"} Q1 -->|"第三层往下:系统行为"| R1["换栈无效,去读平台决策"] Q2 -->|"勾掉大半"| R2["跨端方案合适,做混合栈评估"] Q2 -->|"几乎勾不掉"| R3["留原生,别把工作量挪到桥上"] R1 --> Q3{"未来三年被考核哪类问题"} R2 --> Q3 R3 --> Q3 Q3 -->|"业务交付"| S1["补跨端与多端协作视野"] Q3 -->|"平台 / 工程质量"| S2["补机制层:进程、生命周期、性能归因"] Q3 -->|"系统 / 终端定制"| S3["补 AOSP 实操,接受 400GB / 64GB 门槛"]

评审会上要落字的话,我用这份模板,一页纸,写完存档:

text 复制代码
<!-- docs/stack-decision.md -->
# 决策记录 · 模块 M 报告页 · 2026-09-03

## 一、要换掉的是哪一层
现象:双端样式不一致 + target 36 下大屏重建导致状态丢失
定位:前者在第 2 层(UI 描述),后者在第 3~4 层(应用框架 / 系统服务)
结论:这是两个问题,别用一次重写解决

## 二、换栈后仍在的账(逐项写「保留 / 免掉 / 新增」)
权限与用途说明        保留(合规复查要求先弹说明再申请)
生命周期与状态恢复    保留(FlutterActivity 也是 Activity)
窗口 insets 与折叠    保留(大屏方向自由化对两种栈一视同仁)
系统 PDF 渲染         保留,且新增一层 Channel 桥接
图形缓冲与内存清理    保留(导出前清数据的时机由生命周期决定)
崩溃栈与符号表        新增一套 Dart 侧栈
CI 与依赖节奏          新增一套

## 三、可度量的数(写清口径,不许写「应该更快」)
包体积增量 | 首次进入 P50 / P90 | 滑动帧 P90 | 跨语言边界数量

## 四、复核人与到期日
复核:组里另一位 Android + 一位 iOS,2026-12-03 回看
到期:此结论只在 target <= 37、当前合规口径下成立

最后那两行是吃过大亏才加的。以前我写技术决策只写结论,半年后平台行为一变,没人记得当时为什么这么定,只能重新吵一遍。现在每次都写「这个结论在什么条件下成立」和「什么时候回来复核」,把判断也做成有版本的东西。

那次评审,我用这套判据重跑了一遍

十月中我把那天会上的话整理成了一页,逐条对,结论比当时更清楚,也顺便承认自己哪句说错了。

判据 当时的说法 重跑之后
换掉的是哪一层 含糊地说「换一套代码」 样式一致在第 2 层,状态丢失在第 3~4 层,是两件事
平台能力账 列了 PDF、硬件 Bitmap、内存清理三项 补齐到七项,其中生命周期与 insets 是这次事故的正主
「不用跟版本行为」 我在心里当成了跨端的优点,没反驳 明确写「不成立」:承载层仍是 Activity
包体积 没提 补了量级对照,标明单次构建、样本为一
混合栈成本 没提 补了 Channel 契约、双套崩溃栈、依赖节奏
复核条件 没写 写死 target 上限与合规口径,三个月后回看

重跑的结论分三条,都不带情绪:

报告页留原生,把状态保存从异步回调挪到 saved state 能拿到的地方,并给大屏方向变更补一组 UI 测试。这一条和 Flutter 好不好完全无关,是第六行的修复。

那个三周寿命的运营活动页可以按 B 类走跨端,双端样式一致的价值大于一切,不碰系统能力。这个提案我在会上漏了,因为当时满脑子在防守报告页。

我该补的不是「学不学 Flutter」,是把「平台这次为什么改行为」从靠搜索变成能自己追到源码分支。这跟我用不用 Dart 一点关系都没有。

最扎人的是第三问的答案。我原以为自己被考核的是「交付速度」,翻了翻那一年的工单:target 升级、隐私合规清点、大屏适配、灰度崩溃归因、SDK 行为差异------没有一条是「页面写得不够快」。我一直在为一份不存在的工作调整学习方向,还调整得挺认真。

两条学习路径,都从验证动作开始

前面说了一堆判断,落到「那到底怎么学」,我给自己定过两条路,共同点是都不从书的第一章开始,而从一个能验的动作开始。

平台层:追一次问题,记一页

L1(大约 20 小时,应用岗够用)只做三件事。学会 dumpsys activity / dumpsys window / dumpsys meminfo 三条命令的输出怎么读;学会在官方在线源码站里按类名检索,跟着调用链往下点三层;学会把一次线上现象拆成「用户看到什么 / 应用日志 / 系统状态 / 源码分支 / 我的修复点」五行。产出是 docs/platform-notes/ 里几页笔记,不要求连贯。

L2(再来 40 小时)开始把机制连成图:Zygote fork 到 Application.onCreate 的完整顺序(内容提供者安装排在 Application.onCreate 前面这条,我到第三遍才记住)、Binder 的一次跨进程调用怎么走、VSYNC 到首帧的链路、targetSdk 在源码里是以什么形式分叉的。产出是三张自己画的 Mermaid 图,画不出来的地方就是没懂的地方。

L3 才碰 AOSP 全量源码与编译,接受 400GB / 64GB 的门槛,跑一次完整构建,改一行系统服务看效果。这一步只对想往 C 类岗位走的人有必要,我到现在没做完,只拉过 frameworks/base 的局部来读。

每次记一页的模板长这样,抄走就能用:

text 复制代码
<!-- docs/platform-notes/2026-09-03-config-change.md -->
现象:大屏展开后备注丢失,后台收到两条提交
设备 / 系统:Pixel Tablet 类设备,Android 16,target 36
复现:wm size 改窗口尺寸,最小宽度跨过 600dp
应用侧证据:onCreate 二次,ViewModel 新实例
系统侧证据:dumpsys activity activities 里旧 record 被销毁;
            dumpsys window 里 smallestScreenWidthDp 420 -> 720
源码分支:配置变更走到系统服务的重建分支;target 36 大屏允许 opt-out,
          API 37 起预计不可退
修复点:状态写入 saved state,不用异步回调里的中间态
还答不上的:为什么跨档判定用 600dp 这个值,下次追
复核:改完跑十轮配置变更,两轮人工,八轮脚本

最后那行「还答不上的」是我后来加的。以前笔记只记结论,看着很完整,其实把没懂的地方悄悄盖住了。

跨端侧:嵌一个页面,量三个数

L1(大约 20 小时)不碰状态管理全家桶,只做 Add-to-App:在现有 App 里嵌一个纯展示页,走通进入、退出、旋转、被系统回收后恢复。量三个数------包体积增量、首次进入耗时、状态丢失次数。这三个数正好对应前面「换栈后仍在的账」里的三格。

L2 补平台桥:写一条 Channel 调系统的某个能力,量一次序列化和线程切换的开销;再嵌一个原生 Platform View,看滑动帧的变化。到这一步,你对「桥不是免费的」会有体感,而不是从文章里读来的。

L3 才是独立交付一个完整跨端模块、接 CI、处理依赖升级和崩溃符号表。很多人直接从 L3 开始,卡在构建和生态上,然后得出「Flutter 不行」的结论------我 2019 年就是这么干的。

两个 L1 加起来四十小时,都不需要转岗、不需要新项目、不需要说服任何人。它们换不来任何一项可以写进简历的技能名,但下次评审会再开,你手里会有一页自己量过的数字,而不是一句从别处听来的判断。

命令速查:我要看的其实是输出里那几行

平台层的命令不多,难的是知道该看哪个字段。下面这些都是我在那台平板和 Pixel 6 上跑过的,输出片段按真实格式抄了关键行,判读写在后面。

bash 复制代码
# 配置变更与 Activity 生死
adb shell dumpsys activity activities | grep -E 'mResumedActivity|Hist #|state=|config='
#   * Hist #0: ActivityRecord{... u0 com.example.health/.report.ReportActivity t123}
#     state=RESUMED  ...
#   configuration={1.0 ?mcc?mnc ... 720dp sw720dp w720dp h1152dp ...}
adb shell dumpsys window | grep -E 'mCurrentFocus|smallestScreenWidthDp|mRotation'
#   mCurrentFocus=Window{... com.example.health/.report.ReportActivity}
#   mGlobalConfiguration={ ... smallestScreenWidthDp=720 ... }

sw<N>dp 这一项跨过 600 就是大屏档位变化的信号,比看屏幕物理尺寸靠谱------它数的是窗口,不是设备。这也是前面「适配的对象是窗口」那条在平台层的回声。

bash 复制代码
# 状态与进程
adb shell dumpsys activity processes | grep -A6 'com.example.health'
#   PSS / USS / oom adj 与进程优先级都在这段里
adb shell dumpsys meminfo com.example.health | head -20
# 跨进程调用
adb shell dumpsys binder_calls_stats 2>/dev/null | head -20
# 隐藏 API 命中(某些版本会有 toast 或 logcat 行)
adb logcat -d | grep -iE 'Accessing hidden|hiddenapi|blocked|max-target'
#   Accessing hidden method Landroid/app/ActivityThread;... (greylist-max-p, reflection)

最后那条日志当年救过我一次:一个老 SDK 在 target 34 升 35 时静默失效,日志里明明打了 max-target 的提示,我们没人看 logcat -d 的全量,只盯着自己打的 tag。平台其实一直在说话,多数时候是我们没听。

bash 复制代码
# Flutter 侧最小验证(我在自己机器上用的组合)
flutter --version
flutter analyze
flutter build apk --release --split-per-abi
#   ✓ Built build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk (7.4MB)
adb shell am start -W com.example.health/.FlutterHostActivity | grep -E 'LaunchState|TotalTime'

LaunchState 得是 COLD 才能算冷态那一轮,跟前面量启动一样,态没造对,数字再漂亮也没意义。

bash 复制代码
# AOSP 在线源码的本地索引(不下全量源码也能用)
grep -rn "shouldRelaunch" frameworks/base/services/core/java/com/android/server/wm/ 2>/dev/null | head
# 只拉单个目录做局部阅读
git clone --depth=1 --filter=blob:none --sparse \
  https://android.googlesource.com/platform/frameworks/base fw-base
cd fw-base && git sparse-checkout set services/core/java/com/android/server/wm

--filter=blob:none --sparse 这条是后来同事教我的,比 2019 年那次整夜 repo sync 省事太多。读机制不用先拥有全部源码,这算是这几年工具侧为数不多真正降了门槛的变化。

让工具帮忙读源码时,边界得先写死

这半年我确实让编辑器里的 agent 帮我查过几次 AOSP 调用链,但踩过一次之后就固定用四段提示词。它不负责给结论,只负责把证据摆出来;版本口径一律我自己核。

text 复制代码
【只读体检,不改代码】
项目:com.example.health,AGP 8.2.2,compileSdk 34,targetSdk 升级到 36 中。
请只读地列出所有可能受「大屏配置变更触发 Activity 重建」影响的代码位置,
按文件路径 + 行号输出,附一句「为什么这里相关」。
不要提出修改方案,不要猜我没贴过的代码,不确定的地方标「未验证」。
text 复制代码
【把版本口径拆开,别替我取舍】
我在四个来源里看到互相矛盾的说法:
A 官方文档写 target 36 大屏允许 opt-out,API 37 起不可退;
B 某中文百科把两个不同的 Flutter 版本号都标成 2026-05-12 最新;
C 招聘站抽样写 Framework 岗 72.5% 落在 20-50K;
D 厂商公告写月活开发者 150 万、同比 +50%。
请把每条按「来源类型 / 数的对象 / 能不能用于职业决策 / 缺什么验证」分四栏列出,
不要合并成一句结论,不要挑一个当事实。
text 复制代码
【读源码分支,只画链路】
请沿着 AOSP 的配置变更路径,画出从「系统检测到 Configuration 变化」
到「Activity 被重建」的调用链,每一跳标出类名与方法名以及所在文件路径。
注明哪些是按 targetSdk 分叉的分支。
不写修复建议,不写总结,跳不过去的地方直接标「这里我没查到」。
text 复制代码
【换身份,找我没想到的成本】
假设你是负责这个健康类 App 合规复查的人,以及一位维护过两年混合栈的工程师。
请分别列出「把这个报告页换成自绘跨端实现」之后,你们各自最担心的三件事,
每件写清触发条件与可观察现象。
不要评价方案好坏,只列风险。

四段里我最常用的是第二段。技术判断出错往往不是不懂原理,是把四种不同口径的东西揉成了一句话还觉得挺顺。这四段也只占这次学习的一小块,主角从来不是工具------这篇没铺成十二轮流水线,是因为这个题目的难点在判断,不在生成。

一份把笔记串起来的脚本

docs/platform-notes/ 攒到十几页之后,翻找变麻烦了,尤其是「还答不上的」那几行。我写了个很短的脚本,只做三件事:抽标题、抽未解项、按月份聚一下,输出一份索引贴到笔记首页。

python 复制代码
#!/usr/bin/env python3
"""tools/note_index.py ------ 扫描平台层笔记,生成索引与未解项清单。"""
import re
import sys
from pathlib import Path

ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else "docs/platform-notes")

rows, unresolved = [], []
for path in sorted(ROOT.glob("*.md")):
    text = path.read_text(encoding="utf-8")
    title = next((l[3:].strip() for l in text.splitlines()
                  if l.startswith("# 现象:")), path.stem)
    tags = re.findall(r"^系统侧证据:(.+)$", text, re.M)
    rows.append((path.stem, title[:44], "有系统侧证据" if tags else "缺系统侧证据"))
    for line in text.splitlines():
        if line.startswith("还答不上的:"):
            unresolved.append((path.stem, line.split(":", 1)[1].strip()))

print(f"# 平台层笔记索引({len(rows)} 页)\n")
print("| 日期 | 现象 | 证据完整度 |\n| --- | --- | --- |")
for stem, title, flag in rows:
    print(f"| {stem} | {title} | {flag} |")
print(f"\n## 还答不上的({len(unresolved)} 条)\n")
for stem, item in unresolved:
    print(f"- [{stem}] {item}")
text 复制代码
# 我这边跑出来的一段(节选)
# 平台层笔记索引(17 页)
| 2026-09-03 | 大屏展开后备注丢失,后台收到两条提交 | 有系统侧证据 |
| 2026-09-11 | 内容提供者里某 SDK 比 Application.onCreate 更早 | 有系统侧证据 |
| 2026-10-02 | 通知点击后走了中间页,target 33 起不生效 | 缺系统侧证据 |

## 还答不上的(4 条)
- [2026-09-03] 为什么跨档判定用 600dp 这个值,下次追
- [2026-10-02] 系统服务里那条限制是哪个版本引入的,还没定位到分支

「缺系统侧证据」这一列是我最看重的。它把「我以为我懂了」和「我拿得到系统侧的读数」分开,前者攒得再快也不算数。

一次完整的 target 升级,我按这个顺序走了两周

前面讲了不少「要能追到平台层」,落一次完整的才知道这句话有多重。34 提到 36 那两周的排期和命令我留着,后来 35→36 和另一个模块的升级都照着它改。

D-14 · 先列清单,不动代码。 打开官方的行为变更列表,只挑跟自己项目有关的,每条后面写「我们哪行代码可能受影响」。那次列了 11 条,最后真正出事的只有 3 条,但没列的 7 条里后来补进 2 条。这一步最容易被跳过,因为「看了也不一定用得上」,可它的价值是把「赌」变成「验」。

text 复制代码
<!-- docs/target-36-checklist.md(节选) -->
[ ] 大屏配置变更默认引发重建            -> ReportActivity / saved state
[ ] 通知 trampoline 限制                -> 推送点击后的中间页
[ ] PendingIntent 必须声明 mutability    -> 闹钟与分享两处
[ ] 前台服务必须声明 type               -> 报告导出服务
[ ] 精确闹钟权限                        -> 复诊提醒
[ ] 非 SDK 接口名单再收一轮             -> 三方 SDK 待扫
[ ] 边到边与 insets                     -> 全屏报告页
[ ] 权限模型与用途说明时机              -> 定位弹窗链路(合规复查项)

D-10 · 造窗口,不换机器。 借了台平板类设备,另外在 Pixel 6 上用 wm size / wm density 造几组窗口尺寸,重点造出 sw 跨过 600dp 的那档。这一步是最省事的,可我以前总以为「没有大屏设备就没法测」,白拖了两年。

bash 复制代码
adb shell wm size 1080x2200
adb shell wm density 420
adb shell dumpsys window | grep -E 'smallestScreenWidthDp|mGlobalConfiguration'
#   mGlobalConfiguration={... sw600dp w600dp h1080dp 420dpi ...}
adb shell wm size reset && adb shell wm density reset

D-7 · 扫非 SDK 接口与三方 SDK。 拿官方那份 hiddenapi-flags.csv 对了一遍,又在 release 包上跑全量 logcat -d 捞 Accessing hidden。结果捞出一个埋了很久的 SDK 调用,日志里明明白白标着 max-target,我们以前一直只过滤自己的 tag。

D-5 · 改代码。 三处真正的修改:状态保存从异步回调挪到 saved state 能覆盖的位置、PendingIntent 补 mutability、导出服务补 type。都没碰架构,加起来不到 60 行。

D-3 · 回归。 十轮配置变更(两轮人工、八轮脚本)、两台设备、冷温热三态各跑一次。这里踩了个小坑:一开始把热启动的数据混进了冷启动样本,读数忽大忽小,重造态之后才稳。测量口径这事,前面几篇里我反复栽,这次总算先对齐了。

D-1 · 灰度。 5% 放了一天,看两个数:报告页的重建次数与重复提交率。重建次数符合预期,重复提交归零。第三天扩到 30%,遇到一次回滚------某个三方 SDK 在 target 36 下读设备信息的行为变了,合规复查那边要求先查清楚再放量。我们把那个模块单独退回 35 的兼容分支观察,等 SDK 更新,其余部分照常走。不是所有问题都该当场修,有些该先退回去看清是谁的责任。

两周里真正花在读源码上的时间不到四小时,可这四小时决定了后面十天不用靠试。我以前遇到升级的做法正好反过来:先改、先跑、崩了再搜,一搜就是一堆互相矛盾的博客,最后靠灰度兜底。两种做法的差别不在技术水平,在知不知道该去哪一层找答案。

这件事也顺便把开头那个问题回答得更实了一点:这两周里,Flutter 一次都没出现。不是它不好,是这次的问题从头到尾都不在它的地盘上。

我用来判断自己有没有长进的四个数

学习这类事最容易自欺,说「感觉更有底了」等于什么都没说。我从九月开始记了四个数,口径先定死,之后没改过,记在 docs/learning-metrics.md 里。

数 口径 前 后 样本
定位一次平台行为的中位耗时 从拿到现象到写出「系统侧证据 + 源码分支」 3 小时 40 分 52 分 前 6 页笔记 / 后 6 页笔记
target 升级时提前列出的验收点数 改代码之前写进清单的项,事后补的不算 4 项(事后补 7 项) 11 项(事后补 2 项) 34→35 一次,35→36 一次
评审意见里带证据的判断占比 能指出命令输出、源码路径或实测数字的条数 / 总条数 1 / 6 5 / 7 两次跨端方案评审记录
疑难工单里靠灰度才定位的比例 需要发版观察才能确定根因的单数 / 季度疑难单数 9 / 14 3 / 12 两个季度,模块 M 范围内

四个数全朝好的方向走,但有几个得说清楚,不然就成了自夸。

样本太小,十七页笔记、两次评审、两个季度,撑不起任何统计结论,只能当自己的趋势看。第二个数改善最明显,可它有点取巧------我把「提前列清单」这个动作本身变成了流程,数字自然好看,不代表我对平台的理解真的翻了两倍。第三个数的分母变小了,因为第二次评审我少说了两句话。

还有一个数没有变好 :页面交付速度。九月到十一月,我每周边平均交付的功能点数几乎没动,甚至因为多写了一页笔记、多跑一轮 dumpsys 还慢了一点。这件事我犹豫过要不要记,最后还是写进了文件:这批投入买的是「少出一次说不清的事故」和「升级时不用赌」,不是「写得更快」。要是拿交付速度考核这三个月,它是负收益。

我把它留在表里,是为了下次再有人问我「学这个到底有没有用」时,能先说清它在哪个维度上有用、在哪个维度上没用。含糊的收益说多了,自己都会信。

同样一个问题,在面试的三个层级里长得不一样

聊到「学不学」,绕不开它怎么被考核。我把这两年被问到、也问过别人的题按三层归了一下,会发现「背不背得出来」只在最浅那层有用。

层级 典型问法 答对的样子 靠什么准备
用法 PendingIntent 的 flag 有哪几种;Flutter 里 StatefulWidget 怎么触发重建 说得出名字与参数 文档与写过
机制 改了 targetSdk 为什么 Activity 会重建;一次 Channel 调用穿过了哪几层 能画出链路、指出系统侧证据 追过一次源码与 trace
判断 这个报告页该不该换成跨端;这次升级先动哪条版本线 说清代价、成立条件、复核时间 有过一次要签字的决策

Flutter 方向的岗位,用法层和机制层问得多,尤其状态管理、渲染管线、Engine 与平台线程那几条。平台与系统方向的岗位,几乎全是机制层:ActivityRecord 什么时候被销毁、Binder 一次调用经过哪些对象、窗口尺寸变化如何传到 View 树。到了高级岗和晋升答辩,用法层基本消失,剩下的是清一色判断层------而且问法往往不带技术名词,就一句「你为什么这么定」。

我自己答崩过一次,印象很深。对方问 compileSdk 和 targetSdk 的区别,我背得很顺:一个决定编译时能用哪些 API,一个声明应用适配到哪个行为版本。他接着问:「那为什么把 targetSdk 从 35 改到 36,同一个 Activity 在大屏上就从复用变成重建了?」我愣了几秒,开始讲配置变更、讲 onConfigurationChanged、讲 manifest 里的 configChanges,越说越绕。

真正的答案在平台读 targetSdk 选分支的地方。这道题跟背不背得下定义完全无关,它考的是「你知道有个地方在做决策,并且知道去哪儿找它」。三年后我在那次评审会上被自己问住,其实是同一道题换了个场景。

所以准备这两块知识的方式也不一样。用法层靠写,机制层靠追------每追一次就往 docs/platform-notes/ 添一页,画不出链路的地方就是还没懂;判断层只能靠真做过一次要签字的决定,然后把「当时为什么这么定、什么条件下会失效」写下来,过半年回看。

有件事我后来才意识到:**三层里最不容易被替代的是判断层,可我们花时间最多的往往是用法层。刷框架新特性、记新 API,反馈快、有成就感;追一次系统服务里的分支,可能一下午只弄明白一行代码为什么存在。这笔时间账跟前面角度四那张表是同一笔。

有三个说法,我这两年改过

「学 Framework 是为了进大厂做 ROM」。我入行时组里前辈这么讲,我信了很多年,于是认定自己一个写业务 App 的用不上,心安理得地没读。现在觉得这句话把「能改平台」和「能解释平台」混成了一件事。前者只对 C 类岗位成立,后者是 A 类岗位每次版本升级、每次疑难崩溃都在用的能力。前六年的空白不是省下了时间,是把每次排查的成本摊到了后面。

「Flutter 性能不如原生」 。这句我 2019 年说过,基于当时一台中端机上复杂列表的卡顿,还挺笃定。后来 Impeller 把着色器编译挪到构建期,官方在 Flutter 3 的发布文里给过 Gallery 过渡动画最差帧快约 20 倍的对照。重新装了一遍之后,我得把那句话改成分场景的:常规列表与动画,它已经不是当年的表现;嵌原生 Platform View、走深层硬件能力、包体积敏感的场景,桥和引擎固定成本仍然要算。改的不是结论的对错,是「一次观测能管几年」这个前提------我把 2019 年的一次手感当成了长期物理定律。

「跨端会取代原生岗」。这句现在最常听到,也最容易被拿来制造焦虑。我现在的看法是:它取代的是「把设计稿变成页面」这类任务,没有取代「对这一端的行为负责」这个角色。前面把岗位分成三堆,跨端压缩的是第一堆里最基础的部分;第二堆的平台与工程质量、第三堆的系统定制,反而因为抽象层变厚更吃机制理解。平台还多了一个变量:鸿蒙让国内同一个功能可能要交两版,Flutter 让一份逻辑覆盖多端,这两件事同时发生,需要的不是更少的人,是更能分清哪一层在收费的人。

三个说法有个共同点:都是我在没有具体项目逼着的时候,用一句顺口的话替自己做了判断。后来推翻它们的,没有一次是读到了更好的观点文章,全是撞上了具体的事故、具体的机型、具体的一次评审。

给不同处境的人

前面那些判断,换个处境答案会不一样。我把跟组里和几个旧同事聊过的情形收成六类,每类只写能今天就动手的一件事,不写「打好基础」这类话。

入行一到三年,主要写业务页面 :先别碰 AOSP 全量编译,也别急着转跨端。把 dumpsys activity 和 dumpsys window 的输出读熟,下次遇到「偶发白屏 / 状态丢了 / 切回来数据没了」,强制往系统侧追一层,记一页。你被卡的绝大多数问题在第 3~4 层,不在第 6 层。跨端那二十小时的 Add-to-App 可以做,目标是看懂「哪些账换栈后还在」,不是找新方向。

三到七年,开始接性能和架构的活 :这时候最值钱的是把机制连成图。挑三条链路画出来------Zygote 到首帧、一次 Binder 调用、VSYNC 到一帧落地。画不出来就在线源码里补。同时把 targetSdk 当成一条版本分叉线来读,官方每版的行为变更列表里,挑跟自己项目有关的五条,各写一句「我们哪行代码会受影响」。这件事每年做一次,比追十个新框架的收益稳。

七年以上,带人或者做技术决策:该把判断写成有版本的文档了。决策记录里写清「在什么条件下成立」「谁复核」「什么时候回看」,别让半年后的自己重新吵一遍。学不学某个框架对你已经不是能力问题,是排期问题------把 L1 那二十小时交给组里更年轻的人做,让他们带回来三个数,比你自己学完再传达更有效。

想往车载 / 系统定制转 :接受门槛,别绕。400GB 盘、64GB 内存、Ubuntu、repo 与 android-latest-release、一次完整构建,这些省不掉。挑一个模块扎进去就行(AMS/WMS/SurfaceFlinger 任选其一),JD 里也写了「不要求全模块精通」。另外补两样应用岗不常碰的:AIDL/HIDL 的接口设计和 CTS/VTS 的验证流程。鸿蒙那边把 ArkTS / ArkUI 当新语法学,机制层你已经有底子了。

小团队、一个人要管两端:跨端方案对你大概率是正收益,别被前面 A 类的账劝退。但把混合栈的成本提前写进排期:Channel 契约谁维护、两端依赖谁升、Dart 侧崩溃栈谁看。做一次 L1 的量三个数,比读十篇对比文章有用。

做医疗健康、金融这类强合规 App:平台能力清单里那些「保留」项对你几乎全是硬约束。权限说明弹窗的时机、导出前内存清理、后台链路的可审计性,这些不因为换 UI 栈而消失。选型的第二问要逐项过合规复查的口径,别只过技术口径。我这次的事故表面是配置变更,根子上是异步回调写状态时没考虑「重建后这段数据还在不在」------合规链路里这类中间态尤其多。

有几件事我劝组里别做

第一件,别为了简历上多一行去拉 AOSP 全量源码。我 2019 年那次拉完编译失败,简历上没写,笔记里也只有一行 OOM。真正让人信的不是「我编译过 AOSP」,是「上次那个崩溃你半小时就说出是哪层的事」。

第二件,别把「这个框架会不会被砍」当主变量。Dart 团队两年多的 macros 说停就停,Google 自己也没法给路线图打包票。该看的是你投入的这一百小时里,有多少落在半衰期长那一列。

第三件,别拿别人文章里的落地百分比做自己的预算。「两端适配成本降 60%」那组数字我查了五个来源,文字几乎一样,找不到一手复盘;就算它是真的,那也是某个团队在特定项目里的结果,跟你的合规链路、混合栈现状、GC 密度都没关系。

第四件,别把选型结论写成永久有效。我那页决策记录最底下有一行「此结论只在 target 36、当前合规口径下成立」,三个月后回看,平台行为一变就得重跑。写成永久有效的结论,等于把未来每一次变化都变成一次打脸。

踩过的坑,按当时看到的现象排

这半年两边的坑混着踩,挑二十四条按第一次撞见的样子记下来。前六条是同一场事故的六个侧面,当时拆了两天才分清。

现象 根因 修复
折叠展开后备注没了,后台两条提交 target 36 大屏配置变更触发重建,状态只在异步回调里 状态落到 saved state 能拿到的地方
以为换成自绘栈就不用管重建 FlutterActivity 仍是 Activity,配置变更由系统服务决策 平台能力清单里把生命周期标「保留」
平板能复现、Pixel 6 怎么试都不出 判据是窗口 smallestScreenWidthDp 跨 600dp,不是物理尺寸 wm size 造窗口,盯 dumpsys window 的 sw 值
加了 opt-out 就以为长期无忧 该退路按版本给,API 37 起预计不可退 现在做自适应,不依赖开关
dumpsys activity activities 里看见新 record,却说不清为什么 只看了应用框架层,没往系统服务的配置变更分支追 五行笔记法,逼自己补「源码分支」那行
修复只改了页面,下个模块又踩一遍 没把结论写成流程 target 升级前先列验收点清单
Channel 方法改名后 Android 侧静默走空分支,线上三天 跨语言契约没版本,两端各自测各自的 契约文件 + 启动时双方能力自检
混合栈崩溃只看到 Dart 栈,native 侧一片地址 两套符号表没按构建号归档 同一次采集里存 mapping 与 Dart symbols
嵌了原生 Platform View 之后滑动帧 P90 变差 合成层数增加,中低端机更明显 先量十轮再决定用不用,别凭印象
老 SDK 在 target 34→35 后静默失效 非 SDK 接口按 max-target-x 收紧 迁公开 API,别赌名单
logcat 里明明有 hidden API 提示,没人看见 只过滤自己的 tag 升级窗口期固定跑一次全量 logcat -d
debug 包能跑、release 包崩 运行时 enforcement policy 分档,叠加混淆 所有升级验证走 release 包
repo sync 跑一夜,编译到一半 OOM 按博客配了 16GB / 200GB 官方门槛 400GB 盘 + 64GB 内存
拉 aosp-main 拿不到预期的最新代码 2026 年起 Q2、Q4 发布,主线改 android-latest-release 按官方清单分支初始化
在线源码按类名搜,找不到行为分叉点 分叉在调用链第三跳之后 --filter=blob:none --sparse 拉局部,顺着调用点读
拿 2019 年那次卡顿当长期结论 Impeller 在构建期预编译着色器,首帧那批 jank 已不是同一问题 隔几年重测一次旧判断
空工程 APK 大 7MB 就写成定论 单次构建、样本为一 标成量级参考,写清 AGP 与 minSdk
学跨端从状态管理全家桶开始,两周卡在生态 跳过了最小验证闭环 从 Add-to-App 和三个数起步
学平台层从第一本书的第一章开始,三周放弃 没有即时反馈 改成「追一次线上问题,记一页」
以为重组和 setState 是两套东西 声明式重建心智基本可迁移 对照着学,只补语法与生态差异
把 Stack Overflow 2024 的比例当 2025 趋势 2025 问卷改了技术题分类,不再列这两个框架 用可比数据,注明年份与口径
把 37%、9.4%、46% 揉成「市场占有」一句话 数的分别是问卷自报、全体受访者、另一种下载/应用口径 按来源类型分四栏列,不合并
引用「两端适配成本降 60%」被追问出处 五个中文来源文字几乎逐字一样,找不到一手复盘 删掉数字,只写可核的发布记录
决策结论半年后没人记得为什么定 没写成立条件与复核日 每页底部写到期日和复核人
拿页面交付速度考核这三个月的学习 买的是定位能力,不是写得更快 换四个维度的口径,并留下没改善的那项

还能往下挖的地方

按我自己排的投入产出比,从最值得做的往下。前三条都不要新项目、不用说服谁,一个季度内能做完。

方向 解决什么 代价
把「配置变更 → 重建」那条链完整读到底,补上笔记里还答不上的 600dp 判据 下一次大屏/多窗口问题不用再猜 约 8 小时在线源码检索
给 target 升级做一份可复用的验收清单,按版本分叉列出受影响代码位置 每年一次的升级从赌变成验 一次性 1 天,之后每年更新半天
把混合栈 Channel 契约做成带版本的机器可读文件,进 CI 做双方自检 静默走空分支那类事故 半天,需两端配合
量清 Platform View 在低端机上的真实帧成本 决定哪些页面能嵌原生 View 需要一台低端真机 + 十轮以上采样
读一次 Binder 的完整调用链,自己画一张图 跨进程问题不再靠背八股 约 10 小时
补 AIDL / HIDL 接口设计与 CTS/VTS 验证流程 往系统定制方向走的第一块砖 需要 AOSP 环境与一次完整构建
用 hiddenapi-flags.csv 扫一遍自己项目与三方 SDK 的非 SDK 依赖 提前发现下一次 target 升级会炸在哪 半天,需按每个目标版本分别跑
把「声明式重建」在 Compose 与 Flutter 两侧做一组对照实验 让可迁移的那部分知识真正变成自己的 一个周末,两边各写一个同构页面
跟一次鸿蒙原生适配,用机制类比而不是 API 对照的方式记笔记 验证「平台碎了更吃机制理解」这个判断 得有实际适配任务才划算
把四个度量数再攒三个季度,看它是不是只是新鲜劲 判断这套学法到底有没有长期收益 时间,且可能得到否定的答案

最后一条我留着没划掉。四个数现在朝好的方向走,可样本只有两个季度,很可能是「刚开始认真记」带来的短期效应。要是明年这时候定位耗时又抬回去了,那说明我这套按需记笔记的办法撑不住更杂的问题,得换。

后来

十二月初回看那页决策记录,底下的「此结论只在 target 36、当前合规口径下成立」被我用笔划了一道,旁边补了一行日期,写成 37。旁边那句「换栈能换掉 API,换不掉平台」我没动。

那台借来的平板还放在工位抽屉里,屏幕上有道浅浅的划痕。同事路过问起,我说那是去年最贵的一台测试机,其实没花部门一分钱,只是让我在评审会上少说了几句没底的话。

相关推荐
传奇开心果编程13 小时前
【Flutter踩坑排雷】第25课:ScrollController 误用坑|跳转失效、监听泄漏与“控制器未附加”
android·flutter·ios·移动开发·跨平台·dart·声明式ui
慕容雪_16 小时前
Capacitor8-Android打包完整手册
android·打包·capacitor
阿杰m20 小时前
Android源码第十三章:Bluetooth——沿一次发现走到控制器
android
booboosui20 小时前
派活给另一个 AI:前后两次文件夹快照,就是交付清单
android
普通人61 天前
MySQL_FIND_IN_SET函数详解
android·数据库·mysql
好的佩奇2 天前
Dart 之集合元素
android·flutter·dart
Easyhood2 天前
Android 开发入门实践(十四):Compose 副作用入门与四个常用 API
android·开发语言·kotlin·android jetpack·compose
FlightYe2 天前
音视频修炼之容器与协议(四):RTSP详解
android·linux·ffmpeg·音视频·dash
疯华整猫2 天前
删掉 LeakCanary 那天,我用 Trae 自己撸了一套:最难的不是分析堆,是决定什么时候 dump
android·人工智能·性能优化