记一次 Android 15 适配

公司有个项目一直没有维护,Google Play 上收到用户投诉,提示目标版本过低,无法安装,于是需要进行一次适配,这里详尽记录一下经过

很多年前写过一篇 Android 9、10 的适配记录,但是过于凌乱,那时候基础也差,基本是搜到什么就粘贴进项目里,能跑起来就完事了(其实现在也差不多)。后面有一些开发笔记用于记录适配相关的事项,但是好像又比较零散,想着还是以记录完整经过的模式来写,写时候比较舒服,读起来也更通顺

项目背景

1
2
3
4
targetSdkVersion = 31
AGP Version = 7.0.4
Gradle Version = 7.0.2
Kotlin Version = 1.6.21

提升相关版本

最基础的一步是提升 targetSdkVersion,然后直接运行,发现报错提示还需要提升 AGP 版本,这里提升到 8.7.3

这个 AGP 版本指的是 Android Gradle 插件版本(Android Gradle Plugin),该插件版本适用于在相应 Android Studio 项目中构建的所有模块,可以在以下几个地方指定:

  1. Android Studio 的 File > Project Structure > Project 菜单中指定插件版本
  2. 在顶级 build.gradle.kts 文件中指定
    1
    2
    3
    plugins {
    id("com.android.application") version "8.7.3" apply false
    }
  3. 使用 build.gradle 的话,指的是 classpath ‘com.android.tools.build:gradle:8.7.3’ 这个
    1
    2
    3
    4
    5
    buildscript {
    dependencies {
    classpath 'com.android.tools.build:gradle:8.7.3'
    }
    }

提升 AGP 版本后,对应的,Gradle 版本要提升到 8.9,JDK 需要升级至 17 以上

后续因为 reverse() 方法的问题,提升了 Kotlin 版本到 1.9.22,又因为在 Kotlin 版本大于 1.7 时,Room 库 2.4.3 以下版本下无法识别挂起函数,编译报错,再把 Room 库提升到最新版本 2.6.1 后,又报如下问题

1
2
D:\WorkSpace\cpp-0914\common\build\tmp\kapt3\stubs\domesticDebug\com\ddpai\common\database\dao\VVideoDao.java:16: 错误: Methods annotated with [@Insert, @Upsert, @Update, @Delete] shouldn't declare nullable parameters (com.ddpai.common.database.entities.VVideo[]).
com.ddpai.common.database.entities.VVideo[] item, @org.jetbrains.annotations.NotNull()

意思是这几个注解下不能用可空的类型,需要改成非空类型,好办,改完再运行下,无问题了

参考文章

模块级别的 build.gradle 改动

这里的 build.gradle 指的是除了顶级 build.gradle 之外的 build.gradle,需要做以下改动

namespace

每个模块下的 build.gradle 都需要声明一个 namespace(下列代码举例子为了方便所以写到一起了,事实是分开的),新建项目会自带

1
2
3
4
5
6
7
8
9
android {
namespace = "com.hoshi.test" // 这个是 test 模块
}
android {
namespace = "com.hoshi.common" // 这个是 common 模块
}
android {
namespace = "com.hoshi.platform" // 这个是 platform 模块
}

这个东东是干嘛的呢?为什么另一个项目不需要?待研究后补充,现在已知的是新增了这个之后,会导致你以前的 R 文件引用产生变化

以前假设你在 test 模块下写代码,test 模块引用 common 模块,你只 import com.hoshi.test.R,就既可以使用 test 模块的资源,又可以使用 common 模块的资源

但是现在不一样了,你 import com.hoshi.test.R 只能使用 test 模块的资源,如果你想用 common 的资源,你就需要在资源文件前加上完整包名,如 context.getString(com.hoshi.common.R.string.common_today)

增加了 namespace 后,AndroidManifest 里面的 package="com.hoshi.test" 就可以删掉了

zipAlignEnabled

zipAlignEnabled 废弃,不再需要声明,删掉代码即可,直接是自动默认为开的

buildConfig

新版本 AGP,默认关闭 buildConfig,会导致你的 BuildConfig.xxxxx 全部出现异常,需要作如下配置

1
2
3
4
5
android {
buildFeatures {
buildConfig = true // 配置开启 buildConfig 构建特性,新版本 AGP 需要配置
}
}

其他写法调整

1
2
3
4
flavorDimensions 'isOverseas'

// flavor 配置的写法调整,上面是旧版的,改为下面这样
flavorDimensions = ["isOverseas"]

这种调整经常会有,如果遇到可以往类似方向调整,或者直接搜一下如何解决

存储权限适配

targetSdkVersion 大于等于 33,并且运行在 Android 13 的手机上时,通过代码判断是否拥有 READ_EXTERNAL_STORAGE 权限,一定返回 false

XXPermissions 库不再允许申请 READ_EXTERNAL_STORAGE 权限(会直接闪退并告知原因是申请 READ_EXTERNAL_STORAGE),而 ActivityResultLauncher 库中,取图片、视频的 PickContentLauncher 会判断是否拥有 READ_EXTERNAL_STORAGE 权限,所以需要做一下适配

首先彻底移除自己项目中所有的 READ_EXTERNAL_STORAGE,使用 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO 替代,然后把 PickContentLauncher 单独取出来,进行如下修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 当当前手机版本为 Android 13,并且 targetSdkVersion 为 33 时
// 根据文件类型分别取得 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO
// 低版本下一律取 READ_EXTERNAL_STORAGE
val applicationInfo = AppState.getApplicationContext().applicationInfo
val permissionStr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& applicationInfo.targetSdkVersion >= Build.VERSION_CODES.TIRAMISU
) {
if (input == "image/*") {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_MEDIA_VIDEO
}
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}

最后找一下用到原 PickContentLauncher 类的地方,将其替换成自己魔改过的 PickContentLauncher 类

MQTT 相关调整

因为以下众多原因,需要对 MQTT 库进行一些魔改

  1. SCHEDULE_EXACT_ALARM 权限需要和 Google 官方说明申请原因,还要拍摄录像告知使用场景,最好移除掉
  2. 前台 service 需要声明 android:foregroundServiceType
  3. 动态广播接收器必须指定导出的行为,也就是 registerReceiver 方法要加一个 flag 说明是 RECEIVER_EXPORTED 还是 RECEIVER_NOT_EXPORTED

Github 上有挺多方案,甚至还有 Kotlin 版本的,现在追求改动最小,所以沿用了这个方案。这个方案将之前官方 MQTT 用到的 AlarmManager 直接用 Worker 替换掉,且也做了 2、3 两点的适配,十分符合此次适配的需求

隐式 Intent 和 PendingIntent 的限制

官方的说法是,要启动非导出活动,应用程序应使用显式意图,具体可以直接看我之前写过的文章中的某段 —— Android 14 适配 Intent 相关

这个不难,如果自己写有统一的跳转方法的话,直接给 intent 加上 packageName 即可,这里需要特别注意的就是,第三方库里面的跳转如果没有加上 packageName 就会导致闪退,我这里又是 ActivityResultLauncher 库里面的跳转导致的,只能说开源不易,不能怪人,自己发现后及时修改即可

Edge to Edge 适配,沉浸式的新方案

之前一直用 ImmersionBar 库来做沉浸式,提升到 Android 15 后,发现在某些页面中,状态栏和应用顶部的控件又重叠了,好家伙全都白搞了,又要一轮全新的适配。虽然官方告知可以在 theme 中声明 <item name="android:windowOptOutEdgeToEdgeEnforcement">true</item> 来暂时规避,但是 Android 16 明确说了会废弃并停用这个选项,躲得了初一躲不过十五,还是乖乖适配好了

首先把 ImmersiveBar 相关的解决布局与状态栏重叠问题的代码(比如 ImmersionBar.fitsSystemWindows() 这些)移除掉,避免重复处理,然后写相关的 View 扩展代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/**
* Android 15 Edge to Edge 适配
* @receiver View
*/
fun View.statusPaddingEdge() {
val layoutParams = this.layoutParams // 首先取得 LayoutParams
var oldHeight = layoutParams.height // 然后先用 LayoutParams 取得控件的高
var isMatchParent = false
if (oldHeight == ViewGroup.LayoutParams.MATCH_PARENT) {
isMatchParent = true
} else if (oldHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
// 如果是 MATCH_PARENT 或者 WRAP_CONTENT,要再测量取实际高度
val width = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
val height = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
this.measure(width, height)

oldHeight = this.measuredHeight // 然后先在外部取得控件的高,放在监听里面可能会多次回调导致高度累加,显示异常
}
if (oldHeight <= 0 && (oldHeight != ViewGroup.LayoutParams.MATCH_PARENT)) {
VLog.e("statusPaddingEdge", "取得控件的高小于 0,且不是 MATCH_PARENT,不正确,请检查,对应控件:" + this.javaClass.name)
return
}
ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars())
val statusBarsTop = statusBars.top
if (!isMatchParent) {
layoutParams.height = oldHeight + statusBarsTop // 如果不是 MATCH_PARENT,整个 View 要增高
}
v.layoutParams = layoutParams
v.setPadding(statusBars.left, statusBarsTop, statusBars.right, statusBars.bottom)
// VLog.e("statusPaddingEdge", "statusBars = $statusBars, oldHeight = $oldHeight")
insets
}
}

// 在实际使用过程中,有一些界面/控件不会触发 setOnApplyWindowInsetsListener 里面的 OnApplyWindowInsetsListener,这时候就用下面这个老方法来处理

/**
* 增加 View 的上内边距, 增加高度为状态栏高度, 防止视图和状态栏重叠
* 如果是 RelativeLayout 设置 padding 值会导致 centerInParent 等属性无法正常显示
*
* !----注意----! 这个方法在某些手机(目前发现荣耀 magic3)的横屏界面上会出问题,表现为顶部多出一段间距
* 可以尝试采用其它沉浸式方法处理,如 statusPaddingEdge、ImmersionBar
*
* @param remove true: paddingTop = 状态栏高度
* false: paddingTop = 0
*/
@JvmOverloads
fun View.statusPadding(remove: Boolean = false) {
if (this is RelativeLayout) {
throw UnsupportedOperationException("Unsupported set statusPadding for RelativeLayout")
}
val statusBarHeight = context.statusBarHeight
val lp = layoutParams
if (remove) {
if (paddingTop < statusBarHeight) return
if (lp != null && lp.height > 0) {
lp.height -= statusBarHeight // 减高
}
setPadding(
paddingLeft, paddingTop - statusBarHeight,
paddingRight, paddingBottom
)
} else {
if (paddingTop >= statusBarHeight) return
if (lp != null && lp.height > 0) {
lp.height += statusBarHeight // 增高
}
setPadding(
paddingLeft, paddingTop + statusBarHeight,
paddingRight, paddingBottom
)
}
}

在状态栏和控件重叠的页面,让重叠的控件调用上面代码即可

参考文章

_Collection.reversed() 扩展方法闪退问题

报错如下

1
java.lang.NoSuchMethodError: No interface method reversed()Ljava/util/List; in class Ljava/util/List; or its super classes (declaration of 'java.util.List' appears in /apex/com.android.runtime/javalib/core-oj.jar)

这里报错的是集合类的扩展方法 reversed(),以前用得好好的,突然就不行了,感觉可能也和 APG 版本、JDK 版本有关。我这里直接替换为 asReversed() 即可,下面说下它们之间有什么不同

简单概括就是:两个类都会返回一个 List,改变 list.reversed() 返回的 List 中的元素,不会影响到原 List,而改变 list.asReversed() 返回的 List 中的元素,会影响到原 List

后来想到还是要看看为什么突然就不行了,留意到我的 Kotlin 版本是 1.6.21,看了下扩展方法里面的具体实现,处于位置 kotlin-stdlib-common-1.6.21-sources.jar!\generated_Collections.kt,代码如下

1
public expect fun <T> MutableList<T>.reverse(): Unit

这里的这个 expect 关键字是用于跨平台的,这里用 expect 声明了方法之后,其他平台再使用对应的 actual 关键字实现对应的方法,每个目标平台(如 JVM、JS、Native 等)都可以有自己的 actual 实现

那么可以猜测,应该是提升了 JDK 版本为 17 后,对应的 Native 实现没有实现到这个方法了,所以导致报错,于是升级 Kotlin 版本试试,发现升级后,reverse 扩展方法位置变了,在 _CollectionsJVM.kt 里面,且代码变为如下

1
2
3
public actual fun <T> MutableList<T>.reverse(): Unit {
java.util.Collections.reverse(this)
}

看起来是个 actual 方法,有实现了,应该可行了,编译运行,执行相关的方法,没有报错了

参考文章

R8 混淆相关

上面的步骤都处理完了,提交代码,进行远程打包,直接报错

1
2
3
app:minifyReleaseWithR8 FAILED

Missing classes detected while running R8. Please add the missing classes or apply additional keep rules that are generated in D:\WorkSpace\myApp\app\build\outputs\mapping\domesticRelease\missing_rules.txt.

打开提示中的对应目录,有如下内容,说明我们缺少了这些,需要加上。这里的 -dontwarn 是关闭警告的意思,为什么需要加上这些,具体原理不明,估计是 JDK 版本提升后,R8 的一些相关配置改变了

1
2
3
4
-dontwarn com.google.android.exoplayer2.decoder.OutputBuffer$Owner
-dontwarn com.google.android.exoplayer2.decoder.OutputBuffer
-dontwarn com.google.android.exoplayer2.decoder.SimpleOutputBuffer
-dontwarn com.google.android.exoplayer2.drm.ExoMediaCrypto

复制粘贴到 proguard-rules.pro 中,再上传打包,这次没问题了,打包 apk 成功,但是安装好后一运行就闪退了,好好好,再看闪退日志

1
2
3
java.lang.ClassCastException: com.google.gson.internal.LinkedTreeMap cannot be cast Student
// 另外还有一些关于 ParameterizedType 的问题,这里没有记录到就不贴了,大致是说取不到具体泛型类型,只能取到一个 Class 之类的
// 因为我这个项目的 ViewBinding 是用反射的方式来处理的,所以还有一些 ViewBinding 工具无法取到泛型产生的报错

搜索资料得知,如果使用了 R8 混淆,JDK 17 默认会开启 R8 fullMode 混淆模式,完整模式下,对于没有 keep 的类,将会擦除泛型信息。而我的项目中 ViewBinding 是用反射的方式在基类中处理的,并且使用了类似于 BaseResponse<T> 的类,有自定义解析器,所以精准踩中了这个雷。一个个去手动配置混淆豁免规则过于麻烦,所以选择直接手动关闭完整模式,在 gradle.properties 文件中添加以下代码即可

1
android.enableR8.fullMode=false

参考文章

总结

至此,这次的 Android 15 告一段落了。不得不说 Android 的适配每次都能给你来点新花样,每次有新版本时,及时对 APP 进行适配,能够减轻将来再有新版本时适配的工作量,同时也能更快利用上一些新版本 Bug 特性,还是值得重视的


记一次 Android 15 适配
https://enderhoshi.github.io/2025/03/13/记一次 Android 15 适配/
作者
HoshIlIlI
发布于
2025年3月13日
许可协议