开发笔记-版本适配

开发笔记主要是记录开发中遇到的一些问题和解决方案,以及引发的一些思考,不会太深入地记录问题,但是会尽可能广泛地记录涉及到的内容,方便之后整理归纳和查阅

No.1 startForegroundService 方法导致报错 RemoteServiceException 引起闪退

闪退机型:(均为 Android 9.0 系统)

  • 小米 MI NOTE 3
  • 华为
    • P30 Pro (VOG AL00)
    • P10 PLUS (VKY AL00 )
    • NOVA 4 (VCE AL00)
    • Mate10 (ALP AL00)
    • Mate20 (LYA AL00)
    • P30 (ELE AL00)
  • 荣耀
    • HONOR 9 (STF AL00)
    • HONOR 10 (COL AL10)
    • HONOR V10 (BKL AL20)
    • HONOR Note 10 (RVL AL09)
    • HONOR Play (COR AL00)
  • 索尼 G8142
  • 一加 pro 7

报错详细信息:

1
2
android.app.RemoteServiceException  
Context.startForegroundService() did not then call Service.startForeground()

Android O 后台执行限制

Android 8.0 为提高电池续航时间而引入的变更之一是,当您的应用进入已缓存状态时,如果没有活动的组件,系统将解除应用具有的所有唤醒锁

此外,为提高设备性能,系统会限制未在前台运行的应用的某些行为。具体而言:

  • 现在,在后台运行的应用对后台服务的访问受到限制
  • 应用无法使用其清单注册大部分隐式广播(即,并非专门针对此应用的广播)
  • 默认情况下,这些限制仅适用于针对 O 的应用,不过,用户可以从 Settings 屏幕为任意应用启用这些限制,即使应用并不是以 O 为目标平台

Android 8.0 还对特定函数做出了以下变更:

  • 如果针对 Android 8.0 的应用尝试在不允许其创建后台服务的情况下使用 startService() 函数,则该函数将引发一个 IllegalStateException
  • 新的 Context.startForegroundService() 函数将启动一个前台服务。现在,即使应用在后台运行,系统也允许其调用 Context.startForegroundService()。不过,应用必须在创建服务后的五秒内调用该服务的 startForeground() 函数,否则会导致 ANR

Android O 后台服务限制

在后台中运行的服务会消耗设备资源,这可能降低用户体验。 为了缓解这一问题,系统对这些服务施加了一些限制

系统可以区分 前台 和 后台 应用,(用于服务限制目的的后台定义与内存管理使用的定义不同;一个应用按照内存管理的定义可能处于后台,但按照能够启动服务的定义又处于前台)如果满足以下任意条件,应用将被视为处于前台:  

  1. 具有可见 Activity(不管该 Activity 已启动还是已暂停)
  2. 具有前台服务
  3. 另一个前台应用已关联到该应用(不管是通过绑定到其中一个服务,还是通过使用其中一个内容提供程序),例如,如果另一个应用绑定到该应用的服务,那么该应用处于前台:
    • IME
    • 壁纸服务
    • 通知侦听器
    • 语音或文本服务

如果以上条件均不满足,应用将被视为处于后台。

绑定服务不受影响,这些规则不会对绑定服务产生任何影响,如果您的应用定义了绑定服务,则不管应用是否处于前台,其他组件都可以绑定到该服务

处于前台时,应用可以自由创建和运行前台服务与后台服务,进入后台时,在一个持续数分钟的时间窗内,应用仍可以创建和使用服务,在该时间窗结束后,应用将被视为处于 空闲 状态。 此时,系统将停止应用的后台服务,就像应用已经调用服务的 Service.stopSelf() 方法,在这些情况下,后台应用将被置于一个临时白名单中并持续数分钟。 位于白名单中时,应用可以无限制地启动服务,并且其后台服务也可以运行,处理对用户可见的任务时,应用将被置于白名单中,例如:

  1. 处理一条高优先级 Firebase 云消息传递 (FCM) 消息。
  2. 接收广播,例如短信/彩信消息。
  3. 从通知执行 PendingIntent。

在很多情况下,您的应用都可以使用 JobScheduler 作业替换后台服务,例如 CoolPhotoApp 需要检查用户是否已经从朋友那里收到共享的照片,即使该应用未在前台运行,在之前的版本中,应用使用一种会检查其云存储的后台服务。 为了迁移到 Android 8.0,开发者使用一个计划作业替换了这种后台服务,该作业将按一定周期启动,查询服务器,然后退出,在 Android 8.0 之前,创建前台服务的方式通常是先创建一个后台服务,然后将该服务推到前台,而 Android 8.0 有一项复杂功能;系统不允许后台应用创建后台服务,因此,Android 8.0 引入了一种全新的方法,即 Context.startForegroundService(),以在前台启动新服务,在系统创建服务后,应用有五秒的时间来调用该服务的 startForeground() 方法以显示新服务的用户可见通知,如果应用在此时间限制内未调用 startForeground(),则系统将停止服务并声明此应用为 ANR

所以为了解决这个问题,应该:

  1. 将启动 Service 调用的 startService 改为 startForegroundService
  2. 调用了 startForegroundService 后需要在 Service 里继续调用 Service.startForeground(),有如下三个注意事项
    • 在 Service 的 onCreate 方法和 onStartCommand 方法中都调用 startForeground,因为 onCreate 方法不一定会每一次都调用,主要是针对后台保活的服务,如果在服务A运行期间,保活机制又调用 startForegroundService 启动了一次服务A,那么这样不会调用服务A的 onCreate 方法,只会调用 onStartCommand 方法
    • notification ID 必须不为 0,否则会报同样的错误
    • 调用 stopSelf 必须要在调用 startForeground 之后

这样应该就能够解决了

相关文章:Android Service 生命周期Android 通知渠道Android Oreo 通知新特性

No.2 Android Q 反射失效导致K线图缩放异常

在处理一些手势缩放事件时,可以用如下做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private val scaleGestureDetector = ScaleGestureDetector(context, ScaleListener())
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureModifyDetector?): Boolean {
return super.onScaleBegin(detector)
// 开始缩放
}

override fun onScale(detector: ScaleGestureModifyDetector): Boolean {
return super.onScale(detector)
// 缩放中的处理
}

override fun onScaleEnd(detector: ScaleGestureModifyDetector?) {
// 缩放完毕
}
}

只需要在 ScaleListener 中实现想要的逻辑就可以了

但是,在 中有一个属性 mMinSpan,在执行构造函数初始化时,会赋予一个从 viewConfiguration 对象获得的 mMinScalingSpan 值,又从 onTouchEvent() 方法中可以看到,当 span < mMinSpan 时,会调用 mListener.onScaleEnd(this),意思就是,当双指缩小到这个尺寸时,就不会再缩小了,所以造成了缩放不顺畅的效果,那么这个尺寸是多少呢,要怎么修改呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public ScaleGestureDetector(Context context, OnScaleGestureListener listener, Handler handler) {
···
mMinSpan = viewConfiguration.getScaledMinimumScalingSpan();
···
}
public int getScaledMinimumScalingSpan() {
if (!mConstructedWithContext) {
throw new IllegalStateException("Min scaling span cannot be determined when this "
+ "method is called on a ViewConfiguration that was instantiated using a "
+ "constructor with no Context parameter");
}
return mMinScalingSpan;
}
public boolean onTouchEvent(MotionEvent event) {
···
if (!inAnchoredScaleMode() && mInProgress && (span < mMinSpan || configChanged)) {
mListener.onScaleEnd(this);
mInProgress = false;
mInitialSpan = span;
}
···
return true;
}

跟踪进去看到,ViewConfiguration 类中的 get() 方法中,会用 ViewConfiguration 的私有构造函数创建一个 ViewConfiguration 对象

1
2
3
4
5
6
7
8
9
public static ViewConfiguration get(Context context) {
···
ViewConfiguration configuration = sConfigurations.get(density);
if (configuration == null) {
configuration = new ViewConfiguration(context);
sConfigurations.put(density, configuration);
}
return configuration;
}

而 ViewConfiguration 的私有构造函数中,会取到一个系统中写死的值

1
2
3
private ViewConfiguration(Context context) {
mMinScalingSpan = res.getDimensionPixelSize(com.android.internal.R.dimen.config_minScalingSpan);
}

这个 mm 是指屏幕的物理毫米尺寸,换算成 dp 大概是三四百像素左右,这么大,难怪卡顿了,必须要改掉

1
<dimen name="config_minScalingSpan">27mm</dimen>

首先用了反射的方法,将这个值直接给他改成 50 像素

1
2
3
4
5
val declaredFields = ScaleGestureDetector::class.java.declaredFields
declaredFields.first { it.name == "mMinSpan" }?.apply {
isAccessible = true
set(scaleGestureDetector, 50)
}

在 Android Q 之前的版本,运行完美,但是一但用户更新了 Android Q,就又会出现缩放不灵敏的感觉,就是说,我们的反射失效了

查阅文档,可以在 Android Q 中受限的灰名单中的非 SDK 接口列表中看到下面这一条:

1
2
Landroid/view/ScaleGestureDetector;->mMinSpan:I   # To retrieve the min scaling span value in pixels, you can now use ViewConfiguration.get([context]).getScaledMinScalingSpan().
# 翻译:要检索最小缩放范围值(以像素为单位),现在可以使用 ViewConfiguration.get([context]).getScaledMinScalingSpan()

也就是说,在 Android 9 中还是不建议使用的这个变量,现在直接受限了,使用反射是取不到改不了的了,其实不单只是这个变量,类里的其他变量都多多少少无法使用了,说明通过反射来修改一些私有变量在 Android Q 中其实并不是太可靠了,那没办法了,只能用最后一步了,直接复制整个类,然后改掉想要改掉的部分,然后替换掉原来的 ScaleGestureDetector

1
2
3
4
5
6
public ScaleGestureModifyDetector(Context context, OnScaleGestureListener listener, Handler handler) {
···
// mMinSpan = viewConfiguration.getScaledMinimumScalingSpan();
mMinSpan = 50; // 直接赋予为 50,单位为像素
···
}

No.3 Android 14 适配 Intent 相关

最近把 HoshiCore 库进行了一下 Android 14 的适配,起因是我的另一个项目里,提升 targetSdk 到 14 后,所有页面跳转都失效了,查了一下资料,原来是 Intent 有一些安全性调整,有如下一大段描述(原文是这篇文章的 Security 子标题下的 Restrictions to implicit and pending intents 部分)

对于面向 Android 14 的应用,Android 通过以下方式限制应用向内部应用组件发送隐式 intent:隐式 intent 仅传递给导出的组件,应用必须使用明确的 intent 来交付给未导出的组件,或者将组件标记为已导出(exported)。

如果应用创建一个 mutable pending intent ,但 intent 未指定组件或包,系统现在会抛出异常。

这些更改可防止恶意应用拦截只供给用内部组件使用的隐式 intent,例如:

1
2
3
4
5
6
7
8
<activity
android:name=".AppActivity"
android:exported="false">
<intent-filter>
<action android:name="com.example.action.APP_ACTION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

如果应用尝试使用隐式 intent 启动该 activity,则会抛出异常:

1
2
// Throws an exception when targeting Android 14.
context.startActivity(Intent("com.example.action.APP_ACTION"))

要启动未导出的 Activity,应用应 改用显式 Intent 给 Intent 对象设置 package

1
2
3
4
5
6
// This makes the intent explicit.
val explicitIntent = Intent("com.example.action.APP_ACTION")
explicitIntent.apply {
package = context.packageName
}
context.startActivity(explicitIntent)

上述描述中,原版的描述是 “改用显式 Intent”,而我认为应该是 “给 Intent 对象设置 package” 即可,在一开始我们的认知里,显式 Intent 指的是明确写出了 Activity 的 Intent,应该也可以说是用了以下这个构造函数的情形:

1
public Intent(Context packageContext, Class<?> cls) { ... }

而隐式 Intent 指不像显式那样直接指定需要调用的 Activity 的一种 Intent,它是设置 Action、Data、Category 等参数,让系统来筛选出合适的 Activity 的一种 Intent,筛选是根据 <intent-filter> 来进行的

官方的例子里很明显就是调用了以下这种构造函数,是用了 Action 的,应该是属于隐式声明的

1
public Intent(String action) { ... }

所以我认为并没有改成了显式声明,只是在原来的隐式声明上作了一些调整,也可能是我理解出了偏差,官方可能把这种指定了 package 的 Intent 认为是显式的,因为它指定的东西足够多了

当然,咬文嚼字多说无益,解决问题最重要,这里记录一些思考,旨在避免这类随意的文档描述混淆视听,搞乱了原来的理解,如果以后有新的认识,可以再作记录


开发笔记-版本适配
https://enderhoshi.github.io/2024/03/05/开发笔记-版本适配/
作者
HoshIlIlI
发布于
2024年3月5日
许可协议