每年预览版邮件推送过来,我第一件事从来不是打开看新 API 列表,而是直接翻到 Behavior changes 那一章。原因很简单:新特性你不用,App 照样跑;行为变更你不理,用户第二天就来投诉了。Android 17 这一版给我的整体感受是——它没有搞什么惊天动地的大重构,但在窗口适配、后台限制、原生库对齐这几条线上,把前几代埋下的伏笔一次性收紧了。这篇东西不讲发布会式的功能罗列,而是按一个真实维护着几个中大型 App 的开发者视角,拆一拆 Android 17 里真正会让你改代码的地方,顺带把我自己在预览版上踩到的坑、验证方法、排查命令都摊开讲。不管你是刚入行的 Android 开发,还是带团队做版本规划的技术负责人,下面这些内容应该都能直接拿去用。需要提前说明的是,预览版阶段的行为变更随时可能调整,最终请以你手上那份官方版本说明为准,我这里给的是适配思路和验证手段,不是照抄清单。
1. 预览版到手先看行为变更,而不是新 API:Android 17 适配的起手式
1.1 为什么 targetSdkVersion 每升一档就有人加班
很多团队对 targetSdkVersion 的态度是"能拖就拖",直到应用市场发通知说不再接受旧目标版本才动手。这个策略在 Android 12 到 14 那几年勉强能混过去,因为大部分变更只影响边缘场景。但从 Android 15 开始,edge-to-edge 强制、前台服务超时这些改动是全局性、无开关的,你拖到最后一个月再改,测试回归量会大到失控。
Android 17 延续了这个思路:新增的严格行为基本都绑定在目标版本上,也就是说只要你把 targetSdkVersion 提上去,它们就自动生效。这是好事也是坏事——好事是你有选择权,坏事是你一旦提上去就没法"只提一半"。
我的建议是把升级拆成两个独立动作:先把compileSdkVersion提上去(这一步只是让你能调用新 API,行为不变,风险极低),隔一两个迭代再提targetSdkVersion。中间这段时间用来做行为变更的灰度验证,比一次性提完再救火要舒服得多。
1.2 把一次大升级拆成三轮可验证的小步
我自己固定用三轮走的节奏,实测下来比"一把梭"稳妥得多。
第一轮是静态扫描。升级 AGP 和编译 SDK 之后,先让 Lint 跑一遍全量检查,重点看NewApi、EdgeToEdge、MissingPermission这几类;同时用./gradlew lintDebug把报告导出来,按模块分组。这一轮不修业务逻辑,只把"编译能过、明显违规"的问题清掉。
第二轮是灰度埋点。在提 targetSdk 之前,先在内测包里把关键路径的埋点加密,尤其是页面曝光、加购、支付回调、推送到达这几条。等正式提上去之后,对比同一批设备在新旧版本上的埋点数量差异。我遇到过最阴的一次事故,是升级后某个页面的曝光量掉了将近两成,最后定位到是沉浸式布局改了之后,列表的可见性判断失准——这种问题靠肉眼点几下是发现不了的,只有数据能说话。
第三轮才是全量回归加线上监控。这一轮要盯的是崩溃率、ANR 率、冷启动耗时和内存峰值这四项指标,灰度比例建议按 1%、5%、20%、全量的梯度推进,每一档至少观察一个完整自然日。
1.3 一张适配优先级表
不同改动对业务的影响面差别巨大,别把它们平铺在同一个待办列表里。下面是我按"出事概率 × 修复成本"排出来的一张表,可以直接拿去当迭代排期参考。
| 变更类型 | 影响面 | 修复难度 | 建议优先级 |
|---|---|---|---|
| 沉浸式布局强制生效 | 几乎所有页面 | 中 | 最高,先做 |
| 后台任务与前台服务限制 | 推送、同步、定位类功能 | 高 | 高 |
| 原生库页大小对齐 | 含 so 的 App | 低(找 SDK 升级) | 高,但排查快 |
| 权限与数据访问收紧 | 涉及媒体、附近设备 | 中 | 中高 |
| 图形后端与动效变更 | 自定义 View、动效重的页面 | 中 | 中 |
| 新 API 接入类特性 | 自愿使用 | 低 | 低,按需 |
这张表的用法是:横着看优先级,纵着看谁负责。沉浸式那一条几乎一定会牵动 UI 组全部人力,提前两周排进去都不算早。
2. edge-to-edge 从"建议"变成"底线":Android 17 的窗口与 Insets 应对
2.1 沉浸式之后,那些被状态栏压住的页面到底怎么改
edge-to-edge 这套东西从 Android 15 开始就已经是强制项了,到 Android 17 基本没有回旋余地。它的本质是:系统不再帮你预留状态栏和导航栏的位置,你的内容会铺满整块屏幕,所有该留白的地方都要你自己用 Insets 补回来。
改法其实很统一。传统 View 体系里,先在 Activity 里调用:
enableEdgeToEdge()然后给根布局加上 Insets 监听:
ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) view.updatePadding(top = bars.top, bottom = bars.bottom) insets }Compose 里更省事,Scaffold自带了contentWindowInsets,容器层面会自动处理;如果你的页面是自定义布局,就手动加Modifier.windowInsetsPadding(WindowInsets.systemBars)。
真正的坑不在这些模板代码,而在于局部元素的处理。比如底部悬浮的购物车按钮、吸底的 Tab 栏、全屏视频播放器上浮的控制条,这些元素如果统一用根布局的 Insets 去做内边距,会出现"明明留了白还是被挡住"的情况。原因是根布局的 Insets 和子元素的 Insets 消费时机不一样。我的做法是给这类元素单独挂监听,或者直接用WindowInsets.safeDrawing的派生值去算偏移,不要图省事复用根布局算出来的数字。
2.2 用窗口尺寸类替代屏幕宽高判断
大屏适配这件事,很多老项目还停留在"读屏幕宽度,超过 600dp 就走平板布局"的阶段。这套判断在折叠屏和分屏环境下会频繁失效——设备物理宽度没变,但你的窗口宽度可能只有一半。
Android 17 上正确的做法是看窗口尺寸类,也就是WindowSizeClass。它把窗口按宽高分成三档,你只需要按档位做布局决策,不用关心底层是折叠屏展开、分屏还是桌面窗口化:
- 宽度是 COMPACT:单栏布局,底部导航。
- 宽度是 MEDIUM:双栏可折叠,导航可收缩。
- 宽度是 EXPANDED:永久双栏,配合侧边导航轨道。
依赖引入之后,代码大致是这样:
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass val useTwoPane = windowSizeClass.windowWidthSizeClass != WindowWidthSizeClass.COMPACT要注意的是这个值会随着窗口尺寸变化而重新计算,所以别把它存到全局单例里缓存,直接在 Composable 或者布局回调里读取就行。我见过有人把它存进 ViewModel,结果分屏拖动之后界面一直不刷新,查了半天以为是布局缓存问题。
2.3 折叠屏、桌面窗口化与输入设备的实测记录
折叠屏和桌面模式这块,Android 17 上我实际测下来有三点值得单独提。
第一是铰链区域。折叠屏展开后中间那条物理折痕,系统会通过WindowInsets里的 displayFeature 暴露出来。如果你的内容跨了这条线,比如视频播放器正好卡在中间,观感会非常差。建议对需要连续显示的区域(视频、地图、图表)做避让,对可分割的区域(列表加详情)主动去利用它。
第二是配置变更的频率。桌面模式下用户拖动窗口边缘是逐帧变化的,如果你在onConfigurationChanged里做了耗时操作,比如重新请求数据、重建整个页面,界面会明显卡顿。该回调里只做布局相关的轻量刷新,数据请求交给 ViewModel 里的 Flow 去处理。
第三是输入设备。接了鼠标之后,onGenericMotionEvent会收到悬停事件,按钮应该给出 hover 态反馈;接了触控笔,MotionEvent.getToolType()会返回 STYLUS,你可以据此启用压感笔迹或者忽略触摸以防误触。这两点做不做都不影响功能,但做了之后大屏上的体验档次会明显不一样。
3. 编译链路与运行时:16 KB 页大小、ART 和 JDK 版本的对齐
3.1 16 KB page size 到底影响谁
这个话题从 Android 15 就开始喊,到 Android 17 基本是硬性要求了。原理不复杂:内存分页的粒度从 4 KB 变成 16 KB,带来的是内存分配效率提升、整体性能小幅改善,代价是所有直接打包进 APK 的原生库(.so 文件)必须按 16 KB 对齐,否则加载会失败。
我见过不少人的第一反应是"我又没写 C++,跟我没关系"。这个判断是错的。只要你的 APK 里带了 so 文件,不管是你自己写的、第三方 SDK 带的,还是某个音视频库顺带塞进来的,都在影响范围内。
排查方法很简单,解压 APK 之后用 NDK 里的工具看对齐情况:
# 检查 APK 整体对齐 zipalign -c -P 16 -v 4 app-release.apk # 检查单个 so 的段对齐 llvm-readelf -l lib/arm64-v8a/libxxx.so | grep LOAD如果输出的对齐值是 0x4000,说明已经对齐;如果是 0x1000,就得去催 SDK 厂商升级版本了。这个问题的好处是定位极其明确,一旦有不对齐的库,在 16 KB 页大小的设备上会直接崩,日志里能看到明确的加载失败信息,不会出现那种查半天查不出来的玄学问题。
3.2 AGP、Gradle、JDK 版本踩坑对照
版本对齐是升级路上最容易翻车的地方,而且报错信息经常指向莫名其妙的位置。我把实际遇到过的情况整理成了下面这张表,供参考。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 编译报 desugar 相关错误 | JDK 版本与 AGP 不匹配 | 统一到 AGP 要求的 JDK 版本 |
| 增量编译失效、每次全量 | Gradle 与 AGP 版本跨度太大 | 逐级升级,别跨大版本 |
| 单元测试报 NoClassDefFound | 测试依赖没跟着 Groovy DSL 改 Kotlin DSL | 检查测试依赖声明范围 |
| 打包后 so 缺失 | abiFilters 与 SDK 提供架构不一致 | 显式声明需要的 ABI |
| 混淆后反射崩溃 | 新版本默认混淆规则变化 | 补 keep 规则并回归 |
我自己的做法是:先升 JDK,再升 Gradle,最后升 AGP,每升一步跑一次完整构建。反过来做的话,报错会堆在一起,你根本分不清是哪一步引入的。另外团队里最好统一用同一份 JDK 发行版,有人用 A 家有人用 B 家,构建结果会有细微差异,缓存也容易失效。
3.3 第三方 SDK 的排查手法
第三方 SDK 是版本升级里最大的不确定性来源。我的排查顺序是这样的:先看依赖树,用./gradlew :app:dependencies把传递依赖全部打印出来,找出所有带 native 库的模块;然后对这些模块逐个查它们的 release notes,看是否声明支持新的页大小和新的目标版本;最后才是编译、装机、跑冒烟测试。
如果某个 SDK 短期内不打算升级,而它又确实带了不对齐的 so,那就要评估它是不是核心功能。非核心的可以先摘掉,核心的只能等,同时要给这个等待留出时间缓冲,别把它压到发版前一周才发现。
还有一个经验:升 SDK 之前先把版本号写死在版本目录文件里,不要用动态版本号(比如1.2.+)。动态版本号在升级期间会带来完全不可复现的构建,出了问题你连对比基准都没有。
4. 权限与数据访问:那些"不报错但拿不到数据"的新规则
4.1 局域网访问与后台启动的收紧
Android 17 在权限这块的思路很明确:把那些"用户根本不知道你在干什么"的访问路径全部显性化。最典型的就是局域网访问——以前 App 扫描同一网段下的设备、投屏、跟智能家居通信,是不需要额外授权的,现在这类访问会被纳入权限管控。
这类变更的麻烦之处在于它不会抛异常。你的代码不崩、日志不报错,但网络请求就是超时,Socket 就是连不上。所以在适配时,你需要给所有局域网相关的调用加上明确的失败分支和引导提示,而不是让它在后台默默超时。
后台启动方面,从后台拉起 Activity 的限制进一步收紧,一些以前能"曲线救国"的方式现在被堵上了。如果你的业务里有从通知、从广播、从定时任务直接跳页面的逻辑,一定要在预览版上实测。真需要拉起界面的场景,正路是走全屏意图通知,或者用前台服务配合用户可感知的方式,别再试图绕过去。
4.2 媒体访问从 Photo Picker 到部分授权
媒体文件访问这几年一直在往"最小必要"的方向走。Android 17 上,最稳妥的路径是把所有"选一张图"的需求都迁到系统照片选择器上,它由系统托管,用户选什么你拿什么,不需要申请任何存储权限,审核和授权通过率都更好。
代码上就是一个标准契约:
val pickMedia = registerForActivityResult( ActivityResultContracts.PickVisualMedia() ) { uri -> uri?.let { handlePickedImage(it) } } pickMedia.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) )如果你确实需要浏览用户整个相册(比如做本地图片管理类工具),那就要面对部分授权的场景:用户可能只授权了部分照片。这时候你读取到的集合是不完整的,界面上必须明确告诉用户"还有一部分照片未授权",并给出补充授权入口。很多 App 在这里会直接展示空白列表,用户以为是自己照片丢了,体验非常差。
4.3 拒绝授权之后的降级体验怎么设计
权限适配做到最后,拼的其实是拒绝之后的产品设计。我在项目里推行的三条原则,效果还不错。
一是每个权限都有可用的降级路径。相机权限被拒,就允许从相册选择;定位权限被拒,就允许手动选择城市;通讯录权限被拒,就允许手输邀请码。绝不出现"不授权就没法用"的死路。
二是不要在启动瞬间弹权限。用户刚打开 App 还没搞清楚你要干什么,弹窗的拒绝率极高。正确的时机是在用户触发某个需要该权限的具体操作时再弹,并且弹之前用一句话说明用途。
三是区分"首次拒绝"和"永久拒绝"。首次拒绝之后可以再引导一次;一旦用户勾选了不再询问或者系统标记为永久拒绝,就不要再弹系统弹窗了(弹了也不会显示),直接引导到设置页,并说明清楚为什么需要。
5. 渲染、动效与卡顿:Vulkan 路线和预测式返回的接入细节
5.1 图形后端迁移对老项目意味着什么
图形渲染这条线,整个生态是在从 OpenGL ES 逐步往 Vulkan 迁移的,中间还会经过一层转换层。对大多数用标准控件和 Compose 写的项目来说,这件事基本透明,你不需要改代码。真正需要关注的是两类项目:一类是自己用 OpenGL ES 写渲染逻辑的,比如滤镜、地图覆盖物、3D 展示;另一类是依赖了比较老的图形库的。
判断方法很简单:搜一下代码里的 GL 相关调用,看看有没有直接操作 EGL 上下文、自定义 SurfaceView 渲染线程的地方。有的话,就要在预览版设备上重点验证画质、帧率和上下文丢失时的恢复逻辑。我遇到过的一个典型问题是渲染线程在不同后端下的初始化耗时差异明显,导致首帧显示变慢,后来通过把上下文创建提前到页面预加载阶段才解决。
5.2 预测式返回动画的三个接入点
预测式返回这个特性推了好几代了,Android 17 上基本可以当成默认能力来用。它的价值在于用户可以"预览"返回之后会到哪,手势中途松手还能取消,对多层级页面的体验提升很明显。
接入点主要有三个。
第一个是开关。需要在清单里显式声明:
<application android:enableOnBackInvokedCallback="true">第二个是回调迁移。传统 View 体系里把onBackPressed()的拦截逻辑换成OnBackInvokedCallback注册;Compose 里如果用了导航组件,大部分场景会自动处理。
第三个是动画衔接。如果你自定义了页面转场,要在返回进度回调里驱动动画,而不是等返回完成了再播。进度参数是一个 0 到 1 的值,手势移动过程中会持续回调,用它去控制位移和透明度,用户松手取消时动画要能顺着回弹,这一块一定要手动测,自动测试覆盖不到。
有个容易忽略的点:如果某个页面的返回逻辑里有二次确认弹窗,接预测式返回时会出现动画已经播完但弹窗才出来的割裂感。这种页面建议在进度回调里做拦截,手势一开始就给出轻微反馈,让用户知道这里有拦截。
5.3 把"感觉流畅"换算成可对比的数字
性能这块我最怕听到的一句话是"我本地跑着挺顺的"。本地顺不代表低端机顺,更不代表升级前后没退化。
我的验证套路固定是三件套:基准测试、帧率统计、启动耗时。基准测试用 Macrobenchmark 做,把冷启动、滚动列表、页面跳转这几个场景写成可重复执行的用例,升级前后各跑一次,数字直接对比。帧率统计用dumpsys gfxinfo抓掉帧情况,重点看 95 分位和 99 分位,平均数没有意义。启动耗时则看冷启动到首帧的时间,这个指标对版本升级特别敏感。
# 抓取指定应用的帧统计 adb shell dumpsys gfxinfo com.example.app framestats这三套数据攒下来,你就能在版本评审会上拿出有说服力的结论,而不是靠"我觉得变快了"。
6. 后台任务、通知与功耗:限制再次收紧之后的活路
6.1 前台服务类型与超时的实际约束
前台服务的限制是这几年最持续的一条线。Android 17 上,各类前台服务都必须声明明确的类型,而且部分类型有时长上限——到点了系统会回调超时方法,你必须在这个回调里主动停止服务,否则会触发异常。
这意味着那种"起个前台服务一直挂着等消息"的老写法彻底走不通了。正确的拆法是:按任务生命周期决定用哪个机制。用户能感知、需要持续运行的(导航、录音、运动记录),用对应类型的前台服务,并在界面上给出明确的状态提示;用户不可感知、可以延后执行的(日志上报、数据同步、缓存刷新),交给 WorkManager。
我看到过的翻车案例是把音乐播放塞进了数据同步类型的前台服务,结果在部分机型上跑一段时间后被系统掐掉,用户抱怨"听着听着就停了"。这种问题排查起来非常费劲,因为不是必现,而且日志里只有一条超时回调。
6.2 能交给 WorkManager 的就别自己起线程
后台任务这块,我的原则是能用 WorkManager 就不用别的。它帮你处理了 Doze 模式、任务重试、约束条件、进程被杀后的恢复,这些东西你自己写一套,工作量不小而且很难做对。
几个实际用到的配置点:
- 约束条件:需要联网的任务加上网络约束,需要充电的加上充电约束,别让它在不合适的时候跑。
- 重试策略:指数退避,并且设置最大重试次数,否则失败任务会一直堆着。
- 加急任务:用户等待结果的任务可以用加急执行,但要注意配额,用超了会降级成普通任务。
- 唯一任务:同类型的同步任务用唯一名称,避免用户反复点触发按钮时排出一堆重复任务。
val request = OneTimeWorkRequestBuilder<SyncWorker>() .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() WorkManager.getInstance(context) .enqueueUniqueWork("daily_sync", ExistingWorkPolicy.KEEP, request)这段代码里ExistingWorkPolicy.KEEP是个细节,它保证已有任务在跑的时候不会重复入队,比你自己加一堆状态判断要可靠。
6.3 用日志和工具链验证省电效果
功耗这块最容易被忽略,因为它不影响功能,只影响用户口碑。我的验证方式是三个维度一起看:待机耗电、前台耗电、唤醒次数。
待机耗电用系统的电量统计看,重点关注夜间待机时段(比如凌晨两点到六点)的耗电曲线,正常情况下应该是接近一条平线。如果这条线在往下掉,说明你有后台任务在被反复唤醒。
唤醒次数看 Alarm 和 Job 的调度记录,把不必要的周期性任务周期拉长。我见过把心跳间隔设成 30 秒的配置,这种在实验室环境看不出问题,用户用一天就会在评论里骂。
前台耗电则结合帧率和 CPU 占用一起看。如果某个页面帧率正常但 CPU 一直高位,多半是有循环或者频繁 IO,用 Profiler 抓一段时间就能看到。
最后说一个我在实际项目里养成的习惯:每次版本升级,都单独建一份性能基线文档,记录这个版本在固定几台设备上的启动耗时、内存峰值、帧率分布、待机耗电。文档不用写得多正式,一张表就行。等到下个版本升级时,这份基线就是你判断"是不是这次改动导致的退化"最快的依据。我吃过没有基线的亏——某次灰度期间发现内存涨了,团队花了三天争论是升级引入的还是本来就这样,最后因为没有历史数据,只能不了了之。从那以后,基线文档就成了我们迭代流程里的固定动作。