
十几年前我第一次在IDE里配好环境、跑通一个只带按钮和文本框的页面那种兴奋劲儿到现在还记得。那时候Android开发的门槛低得离谱会写布局XML会findViewById会开个后台线程更新UI就能攒出一个能装到手机上的东西。十几年过去Android开发这四个字的内涵被彻底重写了。现在再聊Android开发现状与未来绕不开Kotlin、声明式UI、模块化、跨端方案、端侧智能这一堆话题也绕不开岗位数量收缩、能力要求上移这些现实。这篇东西不打鸡血也不唱衰我想把自己这些年做过的技术选型、带团队做栈升级时踩的坑、判断依据一条一条摊开讲。适合两类人看入行三年以内、想知道力气该往哪使的以及做了很多年、手里那套View体系经验不知道还能吃多久的。文章里的所有案例、数据、项目背景都做了脱敏处理只保留可复用的方法论和配置。1. 这十几年Android开发的三个断层式变化我习惯把Android开发的变化分成渐变和断层两类。渐变是每年加几个API、换个库、调个规范不影响你吃饭断层是那种一两年内把整个工作方式换掉的东西。十几年里真正算得上断层的只有三次语言换血、UI范式换血、工程结构换血。搞清楚这三次断层的逻辑你对现状的判断基本就不会跑偏。1.1 从Java到Kotlin语言层面的迁移账要算清语言迁移这件事很多团队当年是被官方推荐推着走的我见过不少团队迁移完只是把Java的写法翻译成Kotlin语法!!满天飞扩展函数一个没写协程碰都不碰最后得出结论Kotlin也就那样。这是典型的只换了语法没换思维。Kotlin真正改变工作方式的是三件事。第一是空安全把一大类线上崩溃从运行时随机炸提前到了编译期报错。我做过一个统计一个中型项目迁移完成后NPE相关的崩溃占比从崩溃总量的三成左右降到了一成以内剩下的基本都是平台层或者反射带进来的。第二是扩展函数和标准库让工具类这种东西基本消失了String.isValidPhone()这种写法比TextUtils.isEmpty(s)加一堆静态方法要清爽得多。第三是协程把回调地狱和线程切换的样板代码压掉了大半。但迁移有实打实的成本这个必须提前算。最直接的是编译时间混合编译阶段项目里同时存在Java和Kotlin增量编译的收益会被稀释我当时负责的一个项目全量编译从两分半涨到接近四分钟。缓解手段是控制混合期长度别让Java和Kotlin长期共存定一个明确的迁移窗口比如三个月内业务代码全部转完剩下的老模块单独隔离。第二个成本是团队习惯协程的作用域、异常传播、取消机制跟线程池完全是两套心智模型直接让人上手写很容易出问题。// 别这么写异常直接被吞掉调用方一无所知 fun load() { GlobalScope.launch { repo.fetch() } } // 推荐绑定生命周期的作用域 显式异常处理 fun load() { viewModelScope.launch { runCatching { repo.fetch() } .onSuccess { _state.update { s - s.copy(data it) } } .onFailure { e - _state.update { s - s.copy(error e.message) } } } }注意协程不是更轻量的线程它是可挂起的任务。把阻塞式IO直接塞进Dispatchers.Default里池子会被占满表现就是整个App莫名其妙卡住而且日志里什么都看不到。所有阻塞调用都得显式切到IO调度器。1.2 从命令式UI到声明式UICompose到底改变了什么老View体系的心智模型是拿到控件引用、改它的属性。页面状态和数据是两份东西靠开发者手动同步同步漏了就是bug。当年做列表刷新notifyDataSetChanged和局部刷新搞错位置导致状态错乱的事故我经手的项目里至少有三起。声明式UI把这件事反过来了你只描述当前状态长什么样框架负责把界面刷成那样。听起来只是写法变化实际影响的是整个调试思路——出了问题你不再去追谁改了这个控件的属性而是去看这个状态为什么是这个值。这个转变对老开发者来说反而是个坎因为排查路径完全变了。Compose真正让人头疼的是重组Recomposition。很多人写了半年还在抱怨Compose卡一查全是重组范围失控。核心判断标准只有一条传给组件的参数是不是稳定类型。List、Map这类接口类型在编译器眼里是不稳定的每次父组件重组都会认为参数变了子组件就跟着白重组一遍。// 不稳定每次重组都被判定为参数变化 Composable fun UserList(users: ListUser) { /* ... */ } // 稳定使用不可变集合配合 Immutable 标注数据类 Immutable data class User(val id: String, val name: String) Composable fun UserList(users: ImmutableListUser) { /* ... */ }更隐蔽的一类问题是lambda捕获。onClick { doSomething(item) }这种写法每次重组都会创建新的lambda实例如果接收方没做记忆化就会连带触发下游重组。正确做法是把lambda提到外面用remember包起来或者直接用方法引用。1.3 从单模块到多模块工程结构被逼着进化模块化不是架构洁癖是被逼出来的。我印象最深的一个项目单模块时期代码量大概二十万行改一行代码全量编译六分钟加上安装和冷启动验证改一个文案到看见效果要八分钟。这种节奏下没人愿意做重构技术债只会越滚越大最后形成死循环。拆模块的直接收益是编译提速和边界约束。编译提速好理解只改动业务模块时其他模块走缓存直接跳过。边界约束的价值更大模块之间的依赖是显式的谁引了谁一目了然不会出现改A模块把B模块搞崩了这种灵异事件。拆的粒度我一般按变更频率来定而不是按业务重要性。高频变动的模块拆细比如首页、详情页低频但被广泛依赖的模块合并比如各种工具类、网络封装、埋点。一个常见的错误拆法是按页面拆最后拆出几十个模块每个模块只有三五个类构建脚本的维护成本比代码本身还高。app壳工程只做装配 ├── feature-home 首页业务 ├── feature-detail 详情业务 ├── feature-profile 个人中心 ├── core-network 网络层封装 ├── core-storage 本地存储 ├── core-design 设计系统与通用组件 └── core-common 工具、扩展、常量依赖方向必须是单向的feature依赖corecore之间不互相依赖core绝不反向依赖feature。这条规则一旦破了模块化就名存实亡。我见过最离谱的情况是有人为了让core里的工具类能拿到某个业务实体直接在core里引了feature编译是过了但整个依赖图彻底乱掉后来排查循环依赖花了两天。2. Android开发现状盘点岗位、技术栈与真实工作内容聊现状不能只看技术还得看市场。这两件事在从业者身上是绑在一起的技术栈的迁移节奏决定了岗位需求的画像岗位画像又反过来决定你该学什么。我这几年参与过不少次技术面试也帮朋友看过简历有些现象挺能说明问题。2.1 招聘市场上的能力画像拆解现在的招聘要求跟五年前比最大的变化是单一技能不再值钱。以前会写页面、会用几个主流框架就能找到不错的坑位现在同样的简历会被一个问题打回原形你这个页面为什么这么写换一种方案会怎样我梳理了一下不同层级实际被考察的重点这个表是我按近两年参与面试的经验总结的不一定适用于所有团队但方向应该差不多层级核心考察点常见失分项初级1-3年语言基础、常用组件、能独立完成页面只会调API说不出为什么这么选中级3-5年架构设计、性能定位、模块划分优化只停留在加了缓存没有数据支撑高级5年以上技术选型、跨端方案权衡、工程效率讲不清成本收益方案缺乏落地路径有一点很值得说面试里问到你做过什么优化八成的人回答启动优化、内存优化然后就没有然后了。真正能过的是那种能把指标讲清楚的人——优化前冷启动多少毫秒用什么工具测的瓶颈定位在哪一段改完降了多少有没有副作用。数字比形容词有说服力得多。2.2 现在主流App的技术栈长什么样我给一个我实际用过的组合基本覆盖了中大型App的常规需求不是什么前沿试验品都是跑了很久比较稳的东西层次选型选择理由语言Kotlin空安全、协程、生态完整UICompose 少量View新页面全用Compose老页面渐进迁移状态ViewModel 单向数据流状态集中便于测试和回放依赖注入编译期DI框架运行期反射方案启动开销大网络OkHttp 序列化库拦截器机制成熟调试方便存储关系型数据库 键值存储结构化数据和非结构化数据分开图片主流图片加载库别自己写缓存策略坑太多构建Gradle 版本目录依赖统一管理避免版本打架这里我想多说一句少量View。很多团队一上来就全量迁移Compose结果被地图、播放器、相机预览这些重度依赖原生View的组件卡住最后还是要走互操作白白浪费了几个月。比较务实的做法是新页面用Compose写老页面按改动频率排序改到哪个迁哪个不专门开迁移项目。2.3 跨端方案的真实定位与取舍跨端这个话题每年都要吵一遍。我的看法比较朴素跨端解决的是人力复用问题不是技术先进问题。判断要不要上只看三个变量——团队规模、页面同质化程度、性能敏感度。方案适用场景不适合的场景原生双端团队规模够、性能要求高、交互复杂人力紧张、页面高度同质自绘引擎方案多端UI一致性要求高、动效多需要深度调用系统能力桥接式方案已有前端团队、页面偏展示型高频交互、长列表滚动多平台共享逻辑只想复用业务逻辑、UI各写各的团队没有Kotlin积累我个人最推荐的是共享逻辑层UI各写各的这条路。理由很实际业务逻辑数据模型、网络、缓存、状态机的复用收益是确定的而UI层的复用往往要在一致性和平台体验之间反复妥协沟通成本极高。把逻辑层抽到多平台共享模块里双端各写各的界面这个方案的投入产出比最稳风险也最低。3. 核心细节解析迁移与架构落地中的关键判断前面讲的偏宏观这一节落到具体判断上。这些年我带团队做技术栈升级发现真正难的从来不是会不会写而是什么时候写、写到什么程度停手。选型失误的代价往往要一两年后才显现那时候改起来的成本是当初的十倍。3.1 Compose和View混用的边界怎么划互操作有两个方向在View里嵌Compose在Compose里嵌View。两个方向的成本和坑完全不一样。View嵌Compose相对简单用ComposeView挂上去就行注意生命周期的owner要正确传否则rememberSaveable会失效、页面重建后状态丢失。这个问题我在两个项目里都遇到过表现是旋转屏幕后输入框内容清空查了半天以为是配置问题。Compose嵌View就麻烦多了用AndroidView包一层但里面涉及测量冲突、焦点传递、触摸事件拦截。地图组件、视频播放器这类东西嵌进去以后经常出现手势冲突——地图想拖拽外层列表也想滚动两边抢事件。解决思路是给外层容器加手势判断或者干脆在嵌原生组件时禁用外层滚动。我的经验边界是这样的纯展示、纯交互的页面用Compose强依赖Surface、需要精确控制渲染的组件用View两者之间用一层薄封装隔开别让业务代码直接感知到互操作的存在。这层封装的作用是将来某个组件准备好了要换成Compose实现时只改封装层业务不用动。3.2 状态管理MVI不是万能药MVI这几年被吹得有点过。它的核心是单一状态源加单向数据流好处是状态可追溯、可回放、便于测试。但它有个明显的代价状态对象会膨胀。一个复杂页面把所有UI状态塞进一个数据类很快就变成几十个字段的大对象每次更新都要copy一份字段多了以后性能和可读性都难看。我现在的做法是分场景表单类、列表类、状态机明确的页面用严格MVI一个UiState数据类加Event密封类逻辑清晰测试好写。纯展示、状态简单的页面用StateFlow装几个独立字段就够了没必要为了架构统一硬套。复杂动画、临时UI状态不要塞进全局状态用remember在组件内部管页面重建时能恢复的用rememberSaveable。// 严格MVI的骨架注意副作用和状态是分开的 sealed interface UiEvent { data class ClickItem(val id: String) : UiEvent data object Refresh : UiEvent } data class UiState( val loading: Boolean false, val items: ListItem emptyList(), val error: String? null, ) fun onEvent(event: UiEvent) { when (event) { is UiEvent.ClickItem - navigate(event.id) UiEvent.Refresh - load() } }提示别把导航动作写进状态对象里。导航是一次性副作用塞进状态会导致重组时重复触发表现就是点一次跳两个页面。这个坑很经典几乎所有刚上MVI的团队都会踩一遍。3.3 模块化拆分粒度与依赖治理拆模块最怕两件事拆得太碎、依赖乱引。前者让构建脚本维护成本飙升后者让模块化失去意义。粒度这块我有个可操作的判断标准一个模块的代码量如果少于五千行且变更频率跟其他模块差不多就别单独拆。五千行是个经验值低于这个量级拆出来的收益编译提速、边界约束抵不过维护成本构建脚本、依赖配置、发布管理。依赖治理主要靠工具兜底。我一般会在CI里加两道检查一是禁止core模块依赖feature模块用脚本扫依赖图二是禁止业务模块之间横向依赖需要通信就走事件总线或者路由表。这两条规则如果不机器化执行靠代码评审是守不住的人一忙就会图省事直接引。4. 实操过程一个中型项目的技术栈升级全流程这一节讲一个我实际主导过的升级项目背景是一个跑了四年多的App单模块、Java和Kotlin混编、View体系、构建时间接近七分钟团队六个人。目标是把技术栈拉到一个可持续的状态不是追求最新是追求改得动。4.1 第一步先量体温做技术债盘点任何升级动作之前都必须先有基线数据否则你无法证明改动有效也无法说服别人。我当时的盘点清单是这样的维度采集方式当时的结果全量构建耗时干净环境跑三次取中位数6分52秒增量构建耗时改一行业务代码2分10秒冷启动耗时真机三次取中位数2.4秒崩溃率线上统计0.31%代码总量静态扫描约21万行Java占比按文件统计38%这组数字后来成了整个项目的验收标准。我当时的判断是构建时间必须压到三分钟以内冷启动压到1.8秒以内Java占比清零。崩溃率没有硬指标因为影响因素太多不作为本次升级的考核项。盘点阶段还有个隐性收益你会真正看清自己的项目烂在哪。我在这个阶段发现了一个藏了两年的问题——有个工具模块被二十多个页面依赖里面居然引了图片加载库导致整个图片库无法升级。4.2 第二步依赖统一与构建提速依赖版本打架是中型项目的通病表现是明明升级了却还是旧版本因为传递依赖把版本拉回去了。解决办法是用版本目录统一管理所有版本号集中在一个文件里谁也不能在模块里写死版本。# gradle/libs.versions.toml [versions] kotlin 2.0.21 composeBom 2024.10.01 coroutines 1.9.0 [libraries] compose-bom { group androidx.compose, name compose-bom, version.ref composeBom } coroutines-core { group org.jetbrains.kotlinx, name kotlinx-coroutines-core, version.ref coroutines }// build.gradle.kts dependencies { implementation(platform(libs.compose.bom)) implementation(libs.coroutines.core) }构建提速这块我做的改动其实不复杂但收益很明显# gradle.properties org.gradle.jvmargs-Xmx4g -XX:UseParallelGC org.gradle.paralleltrue org.gradle.cachingtrue org.gradle.configuration-cachetrue kotlin.incrementaltrue这几行里配置缓存带来的提升最大我们项目从2分10秒降到了1分20秒左右。但它对构建脚本的写法有要求任何在配置阶段读取环境变量、执行外部命令的脚本都会让它失效。第一次开启大概率会失败报错信息会直接告诉你哪个脚本不兼容照着改就行。并行构建和构建缓存的效果取决于模块化程度单模块项目开了也没用。这也是我为什么把模块化排在构建提速前面——先拆模块再开这些开关收益才是叠加的。4.3 第三步增量迁移的灰度与回滚策略迁移这件事最忌讳大爆炸式重构。我采取的策略是分层推进每一层都有独立的回滚方案。第一层是语言迁移纯机械替换风险最低。做法是先加Kotlin插件新代码全部用Kotlin老代码按模块排期翻译。翻译过程中严禁顺手重构逻辑改逻辑和改语言必须分开提交否则出问题时你根本分不清是哪一步引入的。这条纪律我当时盯得很死代码评审看到夹杂逻辑变更的直接打回。第二层是UI迁移风险最高。我的策略是选一个独立性强的新需求页面作为试点用Compose完整实现跑一个版本观察。观察指标是页面卡顿率、崩溃率、用户停留时长跟同类老页面做对比。试点跑稳了再按页面逐个推。第三层是模块拆分按前面说的粒度标准执行。拆分过程中有一个容易被忽略的细节资源冲突。多个模块里同名的字符串、图片资源合并后可能互相覆盖处理方式是给每个模块加资源前缀。android { resourcePrefix home_ }回滚方案我要求每个阶段都必须有语言迁移靠Git分支隔离出问题直接回退整个模块UI迁移靠开关控制服务端下发配置决定走新页面还是老页面模块拆分靠依赖图快照拆分前后对比依赖关系异常直接回滚到拆分前。4.4 第四步性能基线与监控埋点升级完成后如果没有持续监控三个月内一切都会回到原点。我在项目里加了两类埋点一类是构建指标CI每次构建记录耗时超过阈值告警另一类是运行时指标启动耗时、页面首帧、卡顿次数按版本聚合。启动耗时的采集我用的是系统提供的时间戳打点在主进程的Application和首屏Activity的关键节点埋输出到日志和上报通道。这个方案精度够用成本几乎为零比接第三方监控库要轻。有一段时间我们启动耗时突然涨了三百毫秒就是靠这个埋点定位到的——某个初始化任务被挪到了主线程代码评审时没人注意到。5. 性能优化从能用到好用的几个关键指标性能优化这个话题容易写得很虚我尽量给可量化的东西。Android上的性能问题八成集中在启动、卡顿、内存三块其他都是衍生。5.1 启动耗时的拆解与优化路径冷启动可以拆成三段进程创建到Application创建、Application初始化、首屏渲染。定位方法是打时间戳看时间花在哪一段。我见过最常见的问题是Application里堆初始化任务。项目跑久了谁都能往Application的onCreate里加一行初始化加的时候都是就一行很快加起来就是几百毫秒。处理方式是引入启动任务调度把任务分成必须主线程同步、可以异步、可以延迟到首屏后三类。必须同步的其实很少崩溃上报、核心配置加载、依赖注入容器初始化。网络库、图片库、统计、推送这些全部可以异步或者延迟。我做过一个对比把非必要初始化挪走以后冷启动从2.4秒降到1.7秒改动量不到两百行。还有一个收益很大的手段是启动配置预编译。系统在新版本上支持生成启动配置文件把类加载的热点路径提前编译好能省掉一部分解释执行的开销。接入方式不复杂用基准测试模块生成配置文件随安装包发布就行。// 基准测试模块里的写法生成启动配置 RunWith(AndroidJUnit4::class) class StartupBenchmark { Test fun generateBaselineProfile() { rule.collect(packageName com.example.app) { startActivityAndWait() device.waitForIdle() } } }5.2 卡顿与内存工具链和判断标准卡顿的定位我一般分两步走先看现象是掉帧还是主线程阻塞。掉帧用系统自带的帧率工具看能直接看到每一帧的耗时。如果连续多帧超过16.6毫秒就是渲染压力大去查布局层级、过度绘制、图片解码。如果是偶发的长卡顿用系统级的追踪工具抓一段看主线程在哪个方法上停了超过50毫秒。主线程阻塞最常见的原因我列几个主线程读写数据库、主线程做JSON解析、主线程加载大图、锁等待。这几个占了我们线上卡顿归因的绝大多数。有一个特别隐蔽的是锁等待——某个单例的初始化用了同步锁而初始化里又做了网络请求表现就是首次进入某个页面必卡一秒。内存这块判断标准比工具更重要。我的经验是看两个数常驻内存和GC频率。常驻内存持续上涨不回落基本就是泄漏GC频繁触发尤其是年轻代说明对象分配速率太高通常是循环里创建对象或者字符串拼接。泄漏排查用内存分析工具抓堆转储重点看Activity和Fragment的引用链。最常见的三类泄漏静态变量持有Context、匿名内部类持有外部引用、注册了监听没反注册。第三类最难查因为代码上完全看不出问题只有从引用链上才能看到是哪个管理器忘了摘监听。注意别在优化阶段盲信工具给的建议。有些工具会建议你把某个方法内联、把某个字段改成静态这些微观优化在真实场景里收益接近于零反而可能引入问题。优化的顺序永远是先减少工作量再优化执行方式最后才是微观调整。6. 常见问题与排查技巧实录这一节是我这些年攒下来的问题清单都是文档里不太会写、但实际会反复遇到的东西。6.1 高频问题速查表现象大概率原因处理方向页面旋转后状态丢失状态没走可保存容器或宿主生命周期不对检查状态容器与宿主绑定关系点击一次跳两个页面导航写在状态里重组重复触发导航改成一次性事件列表滑动卡顿图片解码在主线程或条目内创建对象异步解码 复用池首次进入页面必卡单例初始化含阻塞操作初始化异步化 预加载构建突然变慢配置缓存失效或新增模块破坏了并行看构建扫描报告定位内存只涨不降监听未反注册、静态持有上下文抓堆转储看引用链升级依赖后崩溃传递依赖版本冲突用依赖树命令排查6.2 文档里不会写的坑坑一协程作用域选错导致任务被意外取消。用页面级作用域发起上报日志这种不该被取消的任务用户快速退出页面任务就没了。正确做法是把这类任务放到应用级作用域或者在页面作用域里用不会随取消而中断的方式。坑二Compose里的副作用执行次数和你以为的不一样。LaunchedEffect(Unit)只执行一次但如果它所在的组件因为key变化被重建就会再执行一次。我遇到过一个埋点重复上报的问题排查半天发现是列表项没加稳定key滚动时组件反复重建副作用跟着重复。坑三模块化以后资源ID错乱。多模块合并资源时如果不同模块里有同名资源后合并的会覆盖前面的而且不会有任何警告。表现是某个页面的图标突然变成了另一个页面的图标查起来毫无头绪。解决方案就是前面说的资源前缀早加早省事。坑四增量编译的假象。有时候改了代码没生效不是代码问题是增量编译的缓存出错了。判断方法很简单清一次构建再跑如果生效了就是缓存问题。别在这种事情上浪费半小时。坑五真机调试和模拟器表现差异巨大。尤其是启动耗时和内存模拟器的数据基本没有参考价值。性能相关的验证必须在真机上做而且要覆盖低端机我吃过一次亏——高端机上优化效果明显低端机上反而更卡因为优化引入了额外的线程切换。7. 未来三到五年Android开发者的能力该往哪补聊未来容易变成空谈我只讲我能看到的、有实际信号的方向。7.1 端侧智能与新形态设备带来的增量端侧模型推理这件事从概念变成可用的速度比我想象的快。现在已经有项目在本地跑轻量模型做图片分类、文本摘要、语音唤醒好处是隐私不出设备、响应快、不依赖网络。对Android开发者的影响是多了模型集成与推理优化这一层工作需要懂模型量化、内存占用控制、推理线程调度。这些技能组合目前市场上很稀缺会的人不多。另一个增量是新形态设备。折叠屏、车机、大屏、穿戴这些设备的共同特点是屏幕尺寸和交互方式差异大而声明式UI恰好擅长处理同一份状态、多种布局的场景。我用Compose做过一个适配折叠屏的页面用窗口尺寸断点切换布局代码量比用传统方案少了一半。这个方向的需求是实打实增长的。7.2 能力结构的重新排序如果把Android开发者的能力拆成几块我个人的排序是这样的第一梯队是工程能力——构建体系、模块划分、依赖治理、CI流水线。这块能力决定你能不能在一个大项目里长期高效工作而且它跟具体技术栈无关迁移成本最低。第二梯队是性能与稳定性——能定位问题、能给出数据、能做权衡。这是拉开差距的地方也是面试里最能体现深度的地方。第三梯队是UI实现能力——包括声明式UI和新形态适配。这是基本功但正在被工具和框架不断降低门槛。第四梯队是单一框架的熟练度。说实话这个的重要性在快速下降。框架三年一换把精力全押在某个框架的API细节上性价比越来越低。我个人的体会是过去十年里真正保值的从来不是会用某个库而是能判断该不该用某个库。前者靠看文档后者靠踩坑。所以如果让我给建议我会说多参与技术选型的决策过程哪怕只是旁观多做一些需要权衡的取舍而不是只做执行多记录数据别让优化停留在感觉上。最后分享一个我一直在用的习惯每做完一个项目花半小时写一份如果重来一次我会怎么做的清单只写三条写具体的事不写感想。这个习惯坚持下来比看十篇架构文章管用。