Android 版本适配:8.x Oreo(API 级别 26、27)

1,770 阅读9分钟
版权声明:本文为博主原创文章,未经博主允许不得转载
文章分类:Android知识体系 - 版本适配

一、前言

本文主要是从官方文档中筛选出一些常见的适配项,若有任何纰漏或需要补充的,欢迎大家在评论区指出。

二、版本适配

1. 运行时权限授予优化

Android 8.0 及以上系统对运行时权限的授予进行了优化,以下是官方文档的原文:

在 Android 8.0 之前,如果应用在运行时请求权限并且被授予该权限,系统会错误地将属于同一权限组并且在清单中注册的其他权限也一起授予应用。

对于针对 Android 8.0 的应用,此行为已被纠正。系统只会授予应用明确请求的权限。然而,一旦用户为应用授予某个权限,则所有后续对该权限组中权限的请求都将被自动批准。

例如,假设某个应用在其清单中列出 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE。应用请求 READ_EXTERNAL_STORAGE ,并且用户授予了该权限。如果该应用针对的是 API 级别 24 或更低级别,系统还会同时授予 WRITE_EXTERNAL_STORAGE,因为该权限也属于同一 STORAGE 权限组并且也在清单中注册过。如果该应用针对的是 Android 8.0,则系统此时仅会授予 READ_EXTERNAL_STORAGE;不过,如果该应用后来又请求 WRITE_EXTERNAL_STORAGE,则系统会立即授予该权限,而不会提示用户。

也就是说,我们的应用在 Android 8.0 之前,如果在权限注册清单中列出 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限:

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

那么在动态获取 READ_EXTERNAL_STORAGE 权限之后,直接使用 WRITE_EXTERNAL_STORAGE 权限相关的操作时并不会出现任何问题,因为系统已经将同一权限组并且出现在清单中的其他权限都一并授予给了我们。

但这一行为在 Android 8.0 及以上版本的系统中就会出现问题,系统会抛出缺失权限相关的异常。因此,我们在使用 WRITE_EXTERNAL_STORAGE 权限相关的操作之前需要再次动态向系统申请权限,然后系统会自动将权限授予给我们(由于我们之前已经获取了同一权限组中的其他权限,因此不需要再次弹出窗口让用户确认)。

有关权限组更多的介绍请至官方的权限说明文档,以下是从文档中截取的关于权限分组的表格:

2. 安装未知来源应用

参考资料:Making it safer to get apps on Android O

在 Android 8.0 之前的系统,用户若从官方应用商店之外的来源安装应用时,首先需要在系统设置中打开“允许安装来自未知来源的应用”选项:

这是属于全局的设置,打开之后所有的应用都可以随意地弹出应用安装界面来让用户安装。这一特性有可能会被某些恶意应用利用,这些应用为了上架应用市场可能本身并不会携带任何恶意代码,但它可以弹出一些伪装成重要安全更新的安装界面来欺骗用户,用户一旦点击了安装,就会将真正携带了恶意代码的应用安装到手机上了。

因此出于安全考虑,谷歌在 Android 8.0 中删除了这个全局永久授权的选项,用户需要对单个应用的“安装未知来源应用”权限进行授权:

对于开发者来说,若我们的应用中有自动更新安装的功能,就需要对此进行适配。官方文档中提供了以下方法:

  • 通过PackageManager.canRequestPackageInstalls()方法判断应用是否拥有REQUEST_INSTALL_PACKAGES权限(targetSdkVersion 需要大于等于26)

  • 通过跳转 Action Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES 跳转至“安装未知来源应用”授权页面引导用户授权REQUEST_INSTALL_PACKAGES权限

完整的适配流程如下:

  • 在 AndroidManifest.xml 中注册请求安装的权限
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
  • 判断是否拥有权限,若未拥有则跳转至授权界面申请权限
static final int CODE_MANAGE_UNKNOWN_APP = 100;

public void installApk(Context context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        boolean hasInstallPermission = context.getPackageManager().canRequestPackageInstalls();
        if (!hasInstallPermission) { // 未拥有权限
            Uri parse = Uri.parse("package:" + context.getPackageName());
            Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, parse);
            startActivityForResult(intent, CODE_MANAGE_UNKNOWN_APP);
        } else { // 拥有权限
            installApk();
        }
    } else { // 低于 Android 8.0
        installApk();
    }
}

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    
    if (resultCode == RESULT_OK && requestCode == CODE_MANAGE_UNKNOWN_APP) {
        installApk();
    }
}

3. 通知渠道

Android 8.0 新增了对通知渠道的支持,具体是指开发者可以自定义应用消息通知的类别(渠道),这样用户就可以在应用的通知管理中根据类别筛选出自己需要的消息,从而把不想要的消息屏蔽掉

以高德地图为例(MIUI系统),其消息通知共分为了 4 个渠道组,其中每个渠道组下又有不同的渠道类别:

这样用户就可以根据自己的需求屏蔽掉不想要的消息通知了:

此功能体现在代码中的变化就是原来的NotificationCompat.Builder(Context context)构造方法被废弃,而新的构造方法中多了一个channelId参数:

/**
 * @deprecated use
 * {@link NotificationCompat.Builder#NotificationCompat.Builder(Context, String)} instead.
 * All posted Notifications must specify a NotificationChannel Id.
 */
@Deprecated
public Builder(Context context) {
    this(context, null);
}

public Builder(@NonNull Context context, @NonNull String channelId) {
    ...
}

channelId即自定义通知渠道的id值,创建通知渠道的简单实现如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    NotificationManager notificationManager = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE);

    String channelId = "渠道Id";
    String channelName = "渠道名称";
    int importance = NotificationManager.IMPORTANCE_HIGH;// 通知的重要性级别

    NotificationChannel channel = new NotificationChannel(channelId, channelName, importance);
    channel.setDescription("渠道描述");
    channel.enableLights(true);// 是否允许指示灯闪烁
    channel.enableVibration(true);// 是否允许振动
    notificationManager.createNotificationChannel(channel);// 创建通知渠道

    Notification notification = new NotificationCompat.Builder(context, channelId).build();
}

为通知渠道设置渠道组的简单实现如下:

String channelGroupId = "渠道组Id";
String channelGroupName = "渠道组名称";

NotificationChannelGroup channelGroup = new NotificationChannelGroup(channelGroupId, channelGroupName);
notificationManager.createNotificationChannelGroup(channelGroup);// 创建渠道组

channel.setGroup(channelGroupId);

如果随着应用版本更新,某些通知渠道的配置需要进行修改,建议删除原channelId的渠道后重新新建一个渠道,以便一些配置能够正常生效。删除通知渠道的方法为:

notificationManager.deleteNotificationChannel(channelId);// 删除此channelId的通知渠道

4. 限制隐式广播的接收

从 Android 8.0 开始,出于节省电量、提升用户体验等方面的考虑,自定义以及系统大部分的隐式广播将无法被静态注册的 BroadcastReceiver 接收到。解决的方法如下:

  • 将静态注册的 BroadcastReceiver 改为动态注册

  • 虽然自定义显式广播不受此限制,但如果要实现隐式广播的效果,让所有注册接收此广播的应用都可以顺利接收到,那么可以通过PackageManager.queryBroadcastReceivers()方法来实现:

    Intent broadcastIntent = new Intent();
    broadcastIntent.setAction("自定义广播Action");
    
    PackageManager packageManager = context.getPackageManager();
    List<ResolveInfo> matchList = packageManager.queryBroadcastReceivers(broadcastIntent, 0);
    for (ResolveInfo resolveInfo : matchList) {
        Intent intent = new Intent();
        intent.setPackage(resolveInfo.activityInfo.applicationInfo.packageName);
        intent.setAction("自定义广播Action");
        context.sendBroadcast(intent);
    }
    
  • 使用 JobScheduler 替代隐式广播实现“满足某个特定条件时去执行某个任务”的功能。

此外,之前提到过系统大部分的隐式广播会受限制,那么就意味着仍有小部分不受限制,以下是这些例外的隐式广播汇总(搬运自官方文档隐式广播例外):

  • ACTION_LOCKED_BOOT_COMPLETEDACTION_BOOT_COMPLETED,原因:这些广播只在首次启动时发送一次,并且许多应用都需要接收此广播以便进行作业、闹铃等事项的安排。

  • ACTION_USER_INITIALIZEandroid.intent.action.USER_ADDEDandroid.intent.action.USER_REMOVED,原因:这些广播受特权保护,因此大多数正常应用无论如何都无法接收它们。

  • android.intent.action.TIME_SETACTION_TIMEZONE_CHANGED,原因:时钟应用可能需要接收这些广播,以便在时间或时区变化时更新闹铃。

  • ACTION_LOCALE_CHANGED,原因:只在语言区域发生变化时发送,并不频繁。 应用可能需要在语言区域发生变化时更新其数据。

  • ACTION_USB_ACCESSORY_ATTACHEDACTION_USB_ACCESSORY_DETACHEDACTION_USB_DEVICE_ATTACHEDACTION_USB_DEVICE_DETACHED,原因:如果应用需要了解这些 USB 相关事件的信息,目前尚未找到能够替代注册广播的可行方案。

  • ACTION_HEADSET_PLUG,原因:由于此广播只在用户进行插头的物理连接或拔出时发送,因此不太可能会在应用响应此广播时影响用户体验。

  • ACTION_CONNECTION_STATE_CHANGEDACTION_CONNECTION_STATE_CHANGED,原因:与 ACTION_HEADSET_PLUG 类似,应用接收这些蓝牙事件的广播时不太可能会影响用户体验。

  • ACTION_CARRIER_CONFIG_CHANGED, TelephonyIntents.ACTION_*_SUBSCRIPTION_CHANGEDTelephonyIntents.SECRET_CODE_ACTION,原因:原始设备制造商 (OEM) 电话应用可能需要接收这些广播。

  • LOGIN_ACCOUNTS_CHANGED_ACTION,原因:一些应用需要了解登录帐号的变化,以便为新帐号和变化的帐号设置计划操作。

  • ACTION_PACKAGE_DATA_CLEARED,原因:只在用户显式地从 Settings 清除其数据时发送,因此广播接收器不太可能严重影响用户体验。

  • ACTION_PACKAGE_FULLY_REMOVED,原因:一些应用可能需要在另一软件包被移除时更新其存储的数据;对于这些应用,尚未找到能够替代注册此广播的可行方案。

  • ACTION_NEW_OUTGOING_CALL,原因:执行操作来响应用户打电话行为的应用需要接收此广播。

  • ACTION_DEVICE_OWNER_CHANGED,原因:此广播发送得不是很频繁;一些应用需要接收它,以便知晓设备的安装状态发生了变化。

  • ACTION_EVENT_REMINDER,原因:由日历提供程序发送,用于向日历应用发布事件提醒。因为日历提供程序不清楚日历应用是什么,所以此广播必须是隐式广播。

  • ACTION_MEDIA_MOUNTEDACTION_MEDIA_CHECKINGACTION_MEDIA_UNMOUNTEDACTION_MEDIA_EJECTACTION_MEDIA_UNMOUNTABLEACTION_MEDIA_REMOVEDACTION_MEDIA_BAD_REMOVAL,原因:这些广播是作为用户与设备进行物理交互的结果(安装或移除存储卷)或启动初始化(作为已装载的可用卷)的一部分发送的,因此它们不是很常见,并且通常是在用户的掌控下。

  • SMS_RECEIVED_ACTIONWAP_PUSH_RECEIVED_ACTION,原因:这些广播依赖于短信接收应用。

5. 新的悬浮窗类型

Android 8.0 之前,应用注册了SYSTEM_ALERT_WINDOW权限之后,便可以使用以下类型的悬浮窗:

  • TYPE_PHONE
  • TYPE_PRIORITY_PHONE
  • TYPE_SYSTEM_ALERT
  • TYPE_SYSTEM_OVERLAY
  • TYPE_SYSTEM_ERROR

而在 Android 8.0 及以上系统,若继续使用以上类型的悬浮窗,就会抛出android.view.WindowManager$BadTokenException异常。因此若想继续在其他应用上显示悬浮窗,就必须使用新的悬浮窗类型TYPE_APPLICATION_OVERLAY,开发者可以通过以下方式进行适配:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}else {
    layoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
}