ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android 17 适配指南:行为变更、窗口与 16 KB 页

2026/9/18 7:58:09 拓冰建站 浏览量
Android 17 适配指南:行为变更、窗口与 16 KB 页 每年预览版邮件推送过来我第一件事从来不是打开看新 API 列表而是直接翻到 Behavior changes 那一章。原因很简单新特性你不用App 照样跑行为变更你不理用户第二天就来投诉了。Android 17 这一版给我的整体感受是——它没有搞什么惊天动地的大重构但在窗口适配、后台限制、原生库对齐这几条线上把前几代埋下的伏笔一次性收紧了。这篇东西不讲发布会式的功能罗列而是按一个真实维护着几个中大型 App 的开发者视角拆一拆 Android 17 里真正会让你改代码的地方顺带把我自己在预览版上踩到的坑、验证方法、排查命令都摊开讲。不管你是刚入行的 Android 开发还是带团队做版本规划的技术负责人下面这些内容应该都能直接拿去用。需要提前说明的是预览版阶段的行为变更随时可能调整最终请以你手上那份官方版本说明为准我这里给的是适配思路和验证手段不是照抄清单。1. 预览版到手先看行为变更而不是新 APIAndroid 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:enableOnBackInvokedCallbacktrue第二个是回调迁移。传统 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 OneTimeWorkRequestBuilderSyncWorker() .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 抓一段时间就能看到。最后说一个我在实际项目里养成的习惯每次版本升级都单独建一份性能基线文档记录这个版本在固定几台设备上的启动耗时、内存峰值、帧率分布、待机耗电。文档不用写得多正式一张表就行。等到下个版本升级时这份基线就是你判断是不是这次改动导致的退化最快的依据。我吃过没有基线的亏——某次灰度期间发现内存涨了团队花了三天争论是升级引入的还是本来就这样最后因为没有历史数据只能不了了之。从那以后基线文档就成了我们迭代流程里的固定动作。