Android 8.0 适配

96
皮球二二
5.4 2018.01.31 23:06* 字数 4702

去年好多国产机型已经升级到Android 7.0了,所以我的App对7.0也做了相关适配。7.0需要适配的地方也相对比较少,主要就是在应用间共享文件时授予URI临时访问权限,即FileProvider的使用。
但是今年8.0好像不是那么简单了。本想偷个懒等到大规模普及之后再做,没想到测试同事的华为手机居然率先升级到8.0了,而且还出bug了。。。
本文相关代码可以在我的github中进行下载学习

你可以从谷歌官网查询到8.0新增功能与行为变更的说明。我将对开发涉及到需要适配的地方进行说明

1. 自适应启动图标

之前的启动图标都是mipmap中的静态图片ic_launcher。到后来7.1的时候谷歌开始推广圆形图标,在原来android:icon的基础上又添加了android:roundIcon属性来让你的app支持圆形图标

圆形启动图片

到了8.0,情况又变了,我们来创建一个新项目看看发生了什么变化

mipmap-anydpi-v26

多了一个mipmap-anydpi-v26文件夹,里面也是启动图,但是不是一张图片,而是xml文件
打开这个文件看看

<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@drawable/ic_launcher_background" />
    <foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

大体上看好像是设置前景色跟背景色的意思。
继续打开对应的两个drawable文件

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportHeight="108"
    android:viewportWidth="108">
    ............................
</vector>

原来是两张SVG图
看看这两张图片的预览


ic_launcher_foreground
ic_launcher_background

看到这里,你应该明白了吧,8.0通过定义背景和前景这2层视图来自适应启动器图标的外观。这个功劳归属于<adaptive-icon>元素。我们可以使用该元素为图标定义前景层和背景层的绘图,其中的<foreground>和<background>内部属性都支持android:drawable属性

注意图标图层的大小,两层的尺寸必须为108x108dp,前景图层中间的72*72dp图层就是在手机界面上展示的应用图标范围。这样系统在四面各留出18dp以产生有趣的视觉效果,如视差或脉冲(动画视觉效果由受支持的启动器生成,视觉效果可能因发射器而异)

[站外图片上传中...(image-cd9dab-1517411184545)]
[站外图片上传中...(image-7893ef-1517411184545)]

如果要将图标应用于快捷方式中,我们可以通过以下两种方式去使用:

  1. 对于静态快捷方式,使用该xml
  2. 对于动态快捷方式,使用createWithAdaptiveBitmap()创建相应的Bitmap

2. 权限

在Android 8.0之前,如果应用在运行时请求某个权限并且被授予,系统会错误地将属于同一权限组并且在清单中注册的其他权限也一并授予该应用。对于Android 8.0的应用,此行为已被纠正。系统只会授予应用明确请求的权限。然而,一旦用户为应用授予某个权限,则所有后续对该权限组中权限的请求都将被自动批准,而不会提示用户

这一行文字摘选自官方文档,读起来有点拗口。我用最简单的例子来解释一下:8.0之前你申请读外部存储的权限READ_EXTERNAL_STORAGE,你会自动被赋予写外部存储的权限WRITE_EXTERNAL_STORAGE,因为他们属于同一组(android.permission-group.STORAGE)权限,但是现在8.0不一样了,读就是读,写就是写,不能混为一谈。不过你授予了读之后,虽然下次还是要申请写,但是在申请的时候,申请会直接通过,不会让用户再授权一次了

3. 通知

这个是本次更新的重头戏,不做适配的话叫你的App在8.0里notify的时候直接送给你一个toast,上面写着Failed to post notification on channel "null"...,呵呵。
谷歌为了便于管理通知行为和设置推出了一个新的概念:渠道和组,它允许你为要显示的每种通知类型创建自定义的类别。对于我们开发人员来说理解很容易,想想树跟叶子的关系就懂了吧
不管怎么说先把通知能显示出来才是正经事,我们先把channel加上去吧。这里要注意下我们是对8.0单独适配的,所以无论是NotificationChannelGroup还是NotificationChannel都要与其他版本区分使用,因此我们时不时的要加上这个判断

if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT)

意思是要不低于8.0的时候才使用

NotificationChannel channel = new NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH);
// 开启指示灯,如果设备有的话
channel.enableLights(true);
// 设置指示灯颜色
channel.setLightColor(ContextCompat.getColor(context, R.color.colorPrimary));
// 是否在久按桌面图标时显示此渠道的通知
channel.setShowBadge(true);
// 设置是否应在锁定屏幕上显示此频道的通知
channel.setLockscreenVisibility(NotificationCompat.VISIBILITY_PRIVATE);
// 设置绕过免打扰模式
channel.setBypassDnd(true);
manager.createNotificationChannel(channel);

通过channelId我们可以设置不同类别的渠道
渠道可以放到渠道组里面,比如我这里面设置了2组渠道组,一种是普通消息提醒,另外一种是下载服务消息提醒

ArrayList<NotificationChannelGroup> groups = new ArrayList<>();
NotificationChannelGroup group = new NotificationChannelGroup(groupId, groupName);
groups.add(group);
NotificationChannelGroup group_download = new NotificationChannelGroup(groupDownloadId, groupDownloadName);
groups.add(group_download);
manager.createNotificationChannelGroups(groups);

有了组之后,直接把渠道丢相应组里面就行了,比如我把刚才的channelId这个组丢到groupId所在的组中

channel.setGroup(groupId);

最后只要在原来NotificationCompat.Builder的参数里面加上这个渠道Id即可

NotificationCompat.Builder builder=new NotificationCompat.Builder(context, channelId);

谷歌还顺带提供了群删Channel或Groups的方法

manager.deleteNotificationChannel(channelId);
manager.deleteNotificationChannelGroup(groupId);

剩下的操作与之前一样啦

这里有一个我认为是初次接触Channel并且容易遗漏的地方,就是渠道的重要性级别。你别认为什么IMPORTANCE_HIGH这种就是一个常量那么简单,实际上他包含通知的声音和视觉提醒级别。直接看常用的几种级别的注释代码

    /**
     * Low notification importance: shows everywhere, but is not intrusive.
     */
    public static final int IMPORTANCE_LOW = 2;

    /**
     * Default notification importance: shows everywhere, makes noise, but does not visually
     * intrude.
     */
    public static final int IMPORTANCE_DEFAULT = 3;

    /**
     * Higher notification importance: shows everywhere, makes noise and peeks. May use full screen
     * intents.
     */
    public static final int IMPORTANCE_HIGH = 4;
  • IMPORTANCE_MIN 开启通知,不会弹出,但没有提示音,状态栏中无显示
  • IMPORTANCE_LOW 开启通知,不会弹出,不发出提示音,状态栏中显示
  • IMPORTANCE_DEFAULT 开启通知,不会弹出,发出提示音,状态栏中显示
  • IMPORTANCE_HIGH 开启通知,会弹出,发出提示音,状态栏中显示

你可以通过长按某一条具体的通知来查看App中的所有渠道级别的展现方式


渠道重要级别

说完要适配的部分,我们来看看新增功能部分

通知标志

这次Android终于学乖了,好好的模仿并扩展了IOS角标这个使用功能

角标

这个角标其实不简单,你长按一下launcher或者快捷方式看看

Baged

它会显示当前App最新一条通知,下方一排还有App的全部通知缩略图。你可以直接在Badge上面对通知进行操作,比如滑动删除。

这个角标是之前在channel设置显示的

channel.setShowBadge(true);

与此同时,右上角还有一个数字角标,这是你在Notification.Builder中设置的

Notification.Builder builder=new Notification.Builder(this, channelId);
builder.setNumber(10);
.........

如果你对其中一条通知进行数量的设置,那么后续通知就算不加这个属性,这个消息总数依然会一条一条的进行累加

你要是不喜欢这种效果的话,直接setShowBadge为false就行了

通知超时

这个很好理解,就是时间到了通知自己消失。这个有比较广泛的使用场景,如果你8点钟通知上飞机,但是现在已经12点了,这个通知已经对用户无用了,可以取消掉了。这个功能也是配置一下Notification.Builder中的参数即可

Notification.Builder builder=new Notification.Builder(this, channelId);
builder.setTimeoutAfter(timeout*1000)

背景颜色

没错这个版本确实可以设置通知的背景色,而且系统会默认对这个背景色下的文字展现进行适配

设置背景颜色

表面上看上去完美和谐,但是如果你这么想就错了

首先代码上还是NotificationCompat.Builder没啥好说

builder.setColorized(true);
builder.setColor(Color.BLACK);

但是你要是直接notify,我保证你不会有任何效果发生,为什么?你想想,这个setColor是设置通知标题等的颜色的,背景色也用这个相同的参数进行设定本身就有玄机。那么我们看看官网的描述:“只能在用户必须一眼就能看到的持续任务的通知中使用此功能”。原来是要持续任务,你想想你的notify是持续的吗?肯定不是。再翻翻api文档,setColorized方法的注释里面有一句“For most styles, the coloring will only be applied if the notification is for a foreground service notification”,原来说的是前台服务。什么是前台服务?这是8.0里重新定义的概念。因为后台中运行的服务会消耗设备资源,为了缓解这一问题,8.0系统对这些服务施加了一些限制,就是在一定空闲时间之后系统将停止应用的后台服务,而前台服务得以保留。前台服务绑定了一个通知,这样这个通知看起来就是一个持续任务
如何使用前台服务,我们稍后会进行说明,这里先卖一个关子

通知清除回调

系统现在可区分通知是由用户清除,还是由应用自己移除。要查看清除通知的方式,应实现NotificationListenerService类的新onNotificationRemoved()方法

@RequiresApi (api = Build.VERSION_CODES.O)
public class RemoveNotificationService extends NotificationListenerService {
    @Override
    public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
        super.onNotificationPosted(sbn, rankingMap);
        Log.d("onNotificationPosted", sbn.toString());
    }

    @Override
    public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, int reason) {
        super.onNotificationRemoved(sbn, rankingMap, reason);
        Log.d("onNotificationRemoved", sbn.toString());
    }
}

AndroidManifest里面不要忘记加配置

<service android:name=".service.RemoveNotificationService"
    android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
    <intent-filter>
        <action android:name="android.service.notification.NotificationListenerService" />
    </intent-filter>
</service>

这么做,我还是可以保证你没有任何回调会发生,因为这里面有个别别窍,要手动开启通知访问权限。来看模拟器上的操作流程,设置>应用和通知>高级>特殊应用权限->通知使用权

通知使用权

通过判断第三个参数reason是REASON_CANCEL还是REASON_LISTENER_CANCEL就可以知道是用户删除还是系统删除了

爱尔兰有个大神写了Android 8.0中Notifications新增功能的Demo,大家可以在文章最后点击查看

4. 后台执行限制

很多人说Android卡,就是因为App在后台的时候还有很多东西可以开着,然后占用内存,耗电又多,所以为了提升用户体验,Android 8.0对应用在后台运行时可以执行的操作施加了限制,这个限制主要体现在2个地方

  1. 后台服务限制
  2. 广播限制

后台服务限制

在大多数情况下,App都可以使用JobScheduler作业克服这些限制,Android 8.0提供针对JobScheduler的多个改进,让您可以更轻松地使用计划作业取代服务和广播接收器。有关JobScheduler的使用可以参照我之前的文章,这里不再赘述

在后台中运行的服务会消耗系统资源,这可能降低用户体验。 为了缓解这一问题,系统对这些服务施加了一些限制。那么什么情况下应用被视为处于前台?

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

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

处于前台时,应用可以自由创建和运行前台服务与后台服务。 进入后台时,在一个持续数分钟的时间窗内,应用仍可以创建和使用服务。在该时间窗结束后,应用将被视为处于空闲状态。此时,系统将停止应用的后台服务,就像应用已经调用服务的“Service.stopSelf()”方法。在Android 8.0之前,创建前台服务的方式通常是先创建一个后台服务,然后将该服务推到前台;但在Android 8.0中系统不允许后台应用创建后台服务。因此,Android 8.0引入了一种全新的方法,即Context.startForegroundService(),以在前台启动新服务。在系统创建服务后,应用有五秒的时间来调用该服务的startForeground()方法以显示新服务的用户可见通知,如果应用在此时间限制内未调用startForeground(),则系统将停止服务并声明此应用为ANR

来看看代码,我这里是在fragment里面去创建服务的

if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
    getActivity().startForegroundService(intent);
}
else {
    getActivity().startService(intent);
}

调用完startForegroundService之后,我们就需要立即设置前台服务通知了,我这边代码做了封装

    /**
     * 8.0开启前台服务
     * @param service
     * @param ticker
     * @param title
     * @param content
     * @param color
     * @param smallIcon
     * @param largeIcon
     * @param id
     */
    public void showStartForeground(Service service, String ticker, String title, String content, int color, int smallIcon, int largeIcon, int id) {
        NotificationCompat.Builder builder = getSimpleBuilder(
                ticker,
                title,
                content,
                color,
                smallIcon,
                largeIcon,
                NotificationUtils.channelDownloadId,
                new Intent());
        builder.setOngoing(true);
        builder.setAutoCancel(false);
        builder.setColor(Color.WHITE);
        service.startForeground(id, builder.build());
    }

    /**
     * 8.0关闭前台服务
     * @param service
     * @param id
     */
    public void hideStartForeground(Service service, int id) {
        service.stopForeground(true);
        manager.cancel(id);
    }

通知的创建与一般情况没有两样,就是多了service中的startForeground、stopForeground方法
记得5秒钟内调用showStartForeground方法即可

后台位置限制

之前说的后台服务限制目前只针对目标SDK版本为8.0的app,但是涉及到后台位置限制就是所有版本SDK都要调整的了,它限制后台应用每小时只接收几次位置更新。
当然,解决的办法是一样的,依然是建立前台服务

广播限制

每次发送广播时,应用的接收器都会消耗资源。 如果多个应用注册了接收基于系统事件的广播,这会引发问题;触发广播的系统事件会导致所有应用快速地连续消耗资源,从而降低用户体验。为了缓解这一问题,Android 7.0(API级别25)对广播施加了一些限制,而Android 8.0让这些限制更为严格。

  1. 针对 Android 8.0的应用无法继续在其清单中为隐式广播注册广播接收器
  2. 应用可以继续在它们的清单中注册显式广播
  3. 应用可以在运行时使用Context.registerReceiver()为任意广播(不管是隐式还是显式)注册接收器
  4. 需要签名权限的广播不受此限制所限,因为这些广播只会发送到使用相同证书签名的应用,而不是发送到设备上的所有应用

在许多情况下,之前注册隐式广播的应用使用JobScheduler作业可以获得类似的功能

注意,还有很多隐式广播当前不受此限制所限。 应用可以继续在其清单中为这些广播注册接收器,不管应用针对哪个API级别。有关已豁免广播的列表请参阅隐式广播例外

5. 升级

Android 8.0去除“允许未知来源”选项,需手动确认。如果我们的App具备安装App的功能,那么AndroidManifest文件需要包含REQUEST_INSTALL_PACKAGES权限,未声明此权限的应用将无法安装其他应用。我们可以选择使用 Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES这个action将用户引导至安装未知应用权限界面,同时也可以使用 packageManager.canRequestPackageInstalls()查询此权限的状态
不过一般最简单的办法就是直接在AndroidManifest中配置一下就行了,这样会在App调用安装界面的同时,系统会自动询问用户完成授权,我觉得这个流程还是蛮好的

安装未知应用

6. 提醒窗口

还记得各种安全卫士在桌面的那个小火箭小娃娃悬浮窗吧,我们最早是把WindowManager.LayoutParams的type设置为TYPE_SYSTEM_ALERT实现。随后大神们尝试用TYPE_PHONE与TYPE_TOAST来绕过系统限制(详见UCToast)。但是怎么说呢,这些毕竟算旁门左道吧,官方早晚会把这个漏洞堵住的,这不Android 8.0又开始各种android.view.WindowManager$BadTokenException: Unable to add window了
我们看看文档描述:
如果应用使用SYSTEM_ALERT_WINDOW权限并且尝试使用以下窗口类型之一来在其他应用和系统窗口上方显示提醒窗口:
TYPE_PHONE、
TYPE_PRIORITY_PHONE、
TYPE_SYSTEM_ALERT、
TYPE_SYSTEM_OVERLAY、
TYPE_SYSTEM_ERROR
...那么,这些窗口将始终显示在使用 TYPE_APPLICATION_OVERLAY 窗口类型的窗口下方
如果应用针对的是Android 8.0,则应用会使用TYPE_APPLICATION_OVERLAY窗口类型来显示提醒窗口

这样就明白了吧,TYPE_APPLICATION_OVERLAY是最上层显示窗口

来看看代码,首先配置一下权限,为了两种方式都满足

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />

记得在6.0之后去申请悬浮权限,首先要判断权限是否已经授予
在4.4以前是不用判断悬浮窗权限的直接使用就可以了。在4.4到6.0之前,google没有提供方法让我们用于判断悬浮窗权限,同时也没有跳转到设置界面进行开启的方法,因为此权限是默认开启的,但是有一些产商会修改它,所以在使用之前最好进行判断,以免使用时出现崩溃,判断方法是用反射的方式获取出是否开启了悬浮窗权限。在6.0以及以后的版本中,google为我们提供了判断方法和跳转界面的方法,直接使用Settings.canDrawOverlays(context)就可以判断是否开启了悬浮窗权限,没有开启可以跳转到设置界面让用户开启

private fun checkFloatPermission() : Boolean {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val appOpsMgr = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
        val mode = appOpsMgr.checkOpNoThrow("android:system_alert_window", android.os.Process.myUid(), packageName)
return mode == AppOpsManager.MODE_ALLOWED || mode == AppOpsManager.MODE_IGNORED
    }
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
        return Settings.canDrawOverlays(this)
    }
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
        var cls = Class.forName("android.content.Context")
        val declaredField = cls.getDeclaredField("APP_OPS_SERVICE")
        declaredField.isAccessible = true
        var obj = declaredField.get(cls) as? String ?: return false
        val str2 = obj
        obj = cls.getMethod("getSystemService", String::class.java).invoke(this, str2)  as String
        cls = Class.forName("android.app.AppOpsManager")
        val declaredField2 = cls.getDeclaredField("MODE_ALLOWED")
        declaredField2.isAccessible = true
        val checkOp = cls.getMethod("checkOp", Integer.TYPE, Integer.TYPE, String::class.java)
        val result = checkOp.invoke(obj, 24, Binder.getCallingUid(), packageName) as Int
        return result == declaredField2.getInt(cls)
    }
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {        return true
    }
    return false
}

这里我只判断6.0以后的跳转,其他版本的跳转方法自行查询

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    if(!Settings.canDrawOverlays(applicationContext)) {
        val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName))
        startActivity(intent)
        return
    }
}

准备就绪之后即可通过代码设置type

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

更完善的第三方Rom悬浮窗权限判断请参考settingscompat

7. 其他

还有其他很多的使用功能还得我们在实际开发中去学习,比如可下载字体、画中画等,这里我没有去研究了

参考文章

Android O自适应启动图标
AndroidNotifications
Android N新特性——Notification快速回复
Android Oreo 通知新特性,这坑老夫先踩了
Android悬浮窗权限“android.permission.SYSTEM_ALERT_WINDOW”判断是否开启问题

Android学习