androidQ适配(targetSdkVersion 29) - xiaoniudonghe2015/Android-Java-Code-Style GitHub Wiki

2020年8月3日之前,新应用的目标运行环境至少达到android10 2020年11月2日,所有更新版应用目标运行环境至少达到android10

1.Scoped Storage(分区存储)

为了更好的保护用户数据并限制设备冗余文件增加,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被赋予了对外部存储设备的分区访问权限(即分区存储), 对外部存储文件访问方式重新设计,便于用户更好的管理外部存储文件。

应用只能看到本应用专有的目录(通过 Context.getExternalFilesDir() 访问)以及特定类型的媒体。除非您的应用需要访问存放在应用的专有目录以及 MediaStore 之外的文件,否则最好使用分区存储。

要点:

Android Q文件存储机制修改成了沙盒模式

APP只能访问自己目录下的文件和公共媒体文件

Android Q版本以下机型,还是使用老的文件存储方式

Android Q及以上版本机型,所有应用均需要分区存储, 所以应用需要提前确保支持分区存储

需要注意:在适配AndroidQ的时候还要兼容Q系统版本以下的,使用SDK_VERSION区分

有个现象要注意一下:

如果应用通过升级安装,那么还会使用以前的储存模式(Legacy View)。只有通过首次安装或是卸载重新安装才能启用新模式(Filtered View)。

所以在适配时,我们的判断代码如下:

    // 使用Environment.isExternalStorageLegacy()来检查APP的运行模式
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && 
       !Environment.isExternalStorageLegacy()) {

    }

28升到29没有问题,即使29清了缓存数据

2.2 新特性概览

2.2.1 外部存储

外部存储被分为应用私有目录以及共享目录两个部分:

应用私有目录:存储应用私有数据,外部存储应用私有目录对应Android/data/packagename,内部存储应用私有目录对应data/data/packagename;

共享目录:存储其他应用可访问文件, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、Alarms, Music, Notifications,Podcasts, Ringtones、Movies、Download等目录

1)私有目录

应用私有目录文件访问方式与之前Android版本一致,可以通过File path获取资源。

2)共享目录

共享目录文件需要通过MediaStore API或者Storage Access Framework方式访问。

MediaStore API在共享目录指定目录下创建文件或者访问应用自己创建文件,不需要申请存储权限

MediaStore API访问其他应用在共享目录创建的媒体文件(图片、音频、视频), 需要申请存储权限,未申请存储权限,通过ContentResolver查询不到文件Uri,即使通过其他方式获取到文件Uri,读取或创建文件会抛出异常;

MediaStore API不能够访问其他应用创建的非媒体文件(pdf、office、doc、txt等), 只能够通过Storage Access Framework方式访问;

2.3 受影响的变更

2.3.1 图片位置信息

一些图片会包含位置信息,因为位置对于用户属于敏感信息, Android 10应用在分区存储模式下图片位置信息默认获取不到,应用通过以下两项设置可以获取图片位置信息:

在manifest中申请ACCESS_MEDIA_LOCATION

调用MediaStore setRequireOriginal(Uri uri)接口更新图片Uri

2.3.2 访问数据

MediaStore.Files应用分区存储模式下,MediaStore.Files 集合只能够获取媒体文件信息(图片、音频、视频), 获取不到非media(pdf、office、doc、txt等)文件。

2.3.3 File Path路径访问受影响接口

开启分区存储新特性, Andrioid 10不能够通过File Path路径直接访问共享目录下资源,以下接口通过File 路径操作文件资源,功能会受到影响,应用需要使用MediaStore或者SAF方式访问。

2.3.4 存储特性Android版本差异概览

应用在卸载后,会将App-specific目录下的数据删除,如果在AndroidManifest.xml中声明:android:hasFragileUserData="true"用户可以选择是否保留。

2.4 兼容模式

应用未完成外部存储适配工作,可以临时以兼容模式运行, 兼容模式下应用申请存储权限,即可拥有外部存储完整目录访问权限,通过Android10之前文件访问方式运行,以下两种方法设置应用以兼容模式运行。

2.4.1 AndroidManifest中申明

tagretSDK 大于等于Android 10(API level 29), 在manifest中设置requestLegacyExternalStorage属性为true。

<manifest ...>
...
<application android:requestLegacyExternalStorage="true" ... >
...
</manifest>

2.4.2、判断兼容模式接口

//返回值

//true : 应用以兼容模式运行

//false:应用以分区存储特性运行

Environment.isExternalStorageLegacy();

备注:应用已完成存储适配工作且已打开分区存储开关,如果当前应用以兼容模式运行,覆盖安装后应用仍然会以兼容模式运行,卸载重新安装应用才会以分区存储模式运行

2.5 适配方案

2.5.1 方案概览

分区存储适配包含文件迁移以及文件访问兼容性适配两个部分:

1)文件迁移

文件迁移是将应用共享目录文件迁移到应用私有目录或者Android10要求的media集合目录。

针对只有应用自己访问并且应用卸载后允许删除的文件,需要迁移文件到应用私有目录文件,可以通过File path方式访问文件资源,降低适配成本。

允许其他应用访问,并且应用卸载后不允许删除的文件,文件需要存储在共享目录,应用可以选择是否进行目录整改,将文件迁移到Android10要求的media集合目录。

2)文件访问兼容性

共享目录文件不能够通过File path方式读取,需要使用MediaStore API或者Storage Access Framework框架进行访问。

2.5.2 适配指导

AndroidQ中使用ContentResolver进行文件的增删改查。

1)获取(创建)私有目录下的文件夹

//在自身目录下创建apk文件夹

File apkFile = context.getExternalFilesDir("apk");

2)创建私有目录文件

生成需要下载的路径,通过输入输出流读取写入

String apkFilePath = context.getExternalFilesDir("apk").getAbsolutePath();
File newFile = new File(apkFilePath + File.separator + "demo.apk");
OutputStream os = null;
try {
    os = new FileOutputStream(newFile);
    if (os != null) {
        os.write("file is created".getBytes(StandardCharsets.UTF_8));
        os.flush();
    }
} catch (IOException e) {
} finally {
    try {
        if (os != null) {
        os.close();
    }catch (IOException e1) {
    }
}

3)创建共享目录文件夹

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    ContentResolver resolver = context.getContentResolver();
    ContentValues values = new ContentValues();
    values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
    values.put(MediaStore.Downloads.DESCRIPTION, fileName);
    //设置文件类型
    values.put(MediaStore.Downloads.MIME_TYPE, "application/vnd.android.package-archive");
    //注意MediaStore.Downloads.RELATIVE_PATH需要targetVersion=29,
    //故该方法只可在Android10的手机上执行
    values.put(MediaStore.Downloads.RELATIVE_PATH, "Download" + File.separator + "apk");
    Uri external = MediaStore.Downloads.EXTERNAL_CONTENT_URI;
    Uri insertUri = resolver.insert(external, values);
    return insertUri;
}else{
    ...
}

4)在共享目录指定文件夹下创建文件

主要是在公共目录下创建文件或文件夹拿到本地路径uri,不同的Uri,可以保存到不同的公共目录中。接下来使用输入输出流就可以写入文件。

重点:AndroidQ中不支持file://类型访问文件,只能通过uri方式访问。

/**
  * 创建图片地址uri,用于保存拍照后的照片 Android 10以后使用这种方法
  */
private Uri  createImageUri() {
    String status = Environment.getExternalStorageState();
    // 判断是否有SD卡,优先使用SD卡存储,当没有SD卡时使用手机存储
    if (status.equals(Environment.MEDIA_MOUNTED)) {
        return getContext().getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new ContentValues());
    } else {
        return getContext().getContentResolver().insert(MediaStore.Images.Media.INTERNAL_CONTENT_URI, new ContentValues());
    }
}

5)通过MediaStore API读取公共目录下的文件

if (cursor != null && cursor.moveToFirst()) {
    do {
        ...
        int _id = cursor.getInt(cursor.getColumnIndex(MediaStore.Images.Media._ID));
        Uri imageUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, _id);
        ...
    } while (!cursor.isLast() && cursor.moveToNext());
} else {
...
}

// 通过uri获取bitmap]

public Bitmap getBitmapFromUri(Context context, Uri uri) {
    ParcelFileDescriptor parcelFileDescriptor = null;
    FileDescriptor fileDescriptor = null;
    Bitmap bitmap = null;
    try {
        parcelFileDescriptor = context.getContentResolver().openFileDescriptor(uri, "r");
        if (parcelFileDescriptor != null && parcelFileDescriptor.getFileDescriptor() != null) {
            fileDescriptor = parcelFileDescriptor.getFileDescriptor();
            //转换uri为bitmap类型
            bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }finally {
        try {
            if (parcelFileDescriptor != null) {
            parcelFileDescriptor.close();
        }catch (IOException e) {
        }
    }
    return bitmap;
}

6)使用MediaStore删除文件

context.getContentResolver().delete(fileUri, null, null);

2.权限变化

在后台运行时访问设备位置信息需要权限

Android 10 引入了 ACCESS_BACKGROUND_LOCATION 权限(危险权限)。

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

该权限允许应用程序在后台访问位置。如果请求此权限,则还必须请求ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION权限。只请求此权限无效果。

在Android 10的设备上,如果你的应用的 targetSdkVersion < 29,则在请求ACCESS_FINE_LOCATION 或ACCESS_COARSE_LOCATION权限时,系统会自动同时请求ACCESS_BACKGROUND_LOCATION。

在请求弹框中,选择“始终允许”表示同意后台获取位置信息,选择“仅在应用使用过程中允许”或"拒绝"选项表示拒绝授权。

如果你的应用的 targetSdkVersion >= 29,则请求ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION权限表示在前台时拥有访问设备位置信息的权。在请求弹框中,选择“始终允许”表示前后台都可以获取位置信息,选择“仅在应用使用过程中允许”只表示拥有前台的权限。

  1. 首先在清单中对应的service中添加 android:foregroundServiceType="location":
<service
    android:name="MyNavigationService"
    android:foregroundServiceType="location" ... >
    ...
</service>
  1. 启动前台服务前检查是否具有前台的访问权限:
    boolean permissionApproved = ActivityCompat.checkSelfPermission(this, 
        Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED;

    if (permissionApproved) {
       // 启动前台服务
    } else {
       // 请求前台访问位置权限
    }

3.一些电话、蓝牙和WLAN的API需要精确位置权限

下面列举了Android 10中必须具有 ACCESS_FINE_LOCATION 权限才能使用类和方法:

电话

TelephonyManager

•getCellLocation()

•getAllCellInfo()

•requestNetworkScan()

•requestCellInfoUpdate()

•getAvailableNetworks()

•getServiceState()

•TelephonyScanManager

•requestNetworkScan()

•TelephonyScanManager.NetworkScanCallback

•onResults()

•PhoneStateListener

•onCellLocationChanged()

•onCellInfoChanged()

•onServiceStateChanged()

WLAN

•WifiManager

▪startScan()

▪getScanResults()

▪getConnectionInfo()

▪getConfiguredNetworks()

•WifiAwareManager

•WifiP2pManager

•WifiRttManager

蓝牙

•BluetoothAdapter

▪startDiscovery()

▪startLeScan()

•BluetoothAdapter.LeScanCallback

•BluetoothLeScanner

▪startScan()

我们可以根据上面提供的具体类和方法,在适配项目中检查是否有使用到并及时处理。

PROCESS_OUTGOING_CALLS

Android 10上该权限已废弃

4.后台启动 Activity 的限制

应用处于后台时,无法启动Activity 既然是限制,那么肯定有不受限的情况,主要有以下几点:

•应用具有可见窗口,例如前台 Activity。

•应用在前台任务的返回栈中已有的 Activity。

•应用在 Recents 上现有任务的返回栈中已有的 Activity。Recents 就是我们的任务管理列表。

•应用收到系统的 PendingIntent 通知。

•应用收到它应该在其中启动界面的系统广播。示例包括 ACTION_NEW_OUTGOING_CALL 和 SECRET_CODE_ACTION。应用可在广播发送几秒钟后启动 Activity。

•用户已向应用授予 SYSTEM_ALERT_WINDOW 权限,或是在应用权限页开启后台弹出页面的开关

因为此项行为变更适用于在 Android 10 上运行的所有应用,所以这一限制导致最明显的问题就是点击推送信息时,有些应用无法进行正常的跳转(具体的实现问题导致)。所以针对这类问题,可以采取PendingIntent的方式,发送通知时使用setContentIntent方法

对于全屏 intent,注意设置最高优先级和添加USE_FULL_SCREEN_INTENT权限,这是一个普通权限。比如微信来语音或者视频通话时,弹出的接听页面就是使用这一功能。

 <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
Intent fullScreenIntent = new Intent(thisCallActivity.class);
PendingIntent fullScreenPendingIntent = PendingIntent.getActivity(this0,
        fullScreenIntentPendingIntent.FLAG_UPDATE_CURRENT);

NotificationCompat.Builder notificationBuilder =
        new NotificationCompat.Builder(thisCHANNEL_ID)
    .setSmallIcon(R.drawable.notification_icon)
    .setContentTitle("Incoming call")
    .setContentText("(919) 555-1234")
    .setPriority(NotificationCompat.PRIORITY_HIGH// <--- 高优先级
    .setCategory(NotificationCompat.CATEGORY_CALL)

    // Use a full-screen intent only for the highest-priority alerts where you
    // have an associated activity that you would like to launch after the user
    // interacts with the notification. Also, if your app targets Android 10
    // or higher, you need to request the USE_FULL_SCREEN_INTENT permission in
    // order for the platform to invoke this notification.
    .setFullScreenIntent(fullScreenPendingIntenttrue); // <--- 全屏 intent
Notification incomingCallNotification = notificationBuilder.build();

注意:在部分手机上,直接设置setPriority无效(或者说以渠道优先级为准)。所以需要创建通知渠道时将重要性设置为IMPORTANCE_HIGH。

NotificationChannel channel = new NotificationChannel(channelId"xxx"NotificationManager.IMPORTANCE_HIGH);

5.深色主题

Android 10 新增了一个系统级的深色主题(在系统设置中开启)。虽然深色主题并不是强制适配项,但是它可以带给用户更好的体验:

•可大幅减少耗电量。OLED 屏幕中每个像素都是自主发光,所以在显示深色元素时像素所消耗的电流更低,尤其在纯黑颜色时像素点可以完全关闭来达到省电的效果。

•为弱视以及对强光敏感的用户提高可视性。深色可以降低屏幕的整体视觉亮度,减少对眼睛的视觉压力。

•让所有人都可以在光线较暗的环境中更轻松地使用设备

1.手动适配(资源替换)

官方文档中提到的继承Theme.AppCompat.DayNight 或者 Theme.MaterialComponents.DayNight的方法,但这只是将我们使用的各种View的默认样式进行了适配,并不太适用于实际项目的适配。因为具体的项目中的View都按照设计的风格进行了重定义

其实适配的方法很简单,类似屏幕适配、国际化的操作,并不需要继承上面的主题。比如你要修改颜色,就在res 下新建 values-night目录,创建对应的colors.xml文件。将具体要修改的色值定义在里面。图标之类的也是一个思路,创建对应的 drawable-night目录。

2.自动适配(Force Dark)

Android 10 提供 Force Dark 功能。一如其名,此功能可让开发者快速实现深色主题背景,而无需明确设置 DayNight 主题背景

应用必须选择启用 Force Dark,方法是在其主题背景中设置 android:forceDarkAllowed="true"。

此属性会在所有系统及 AndroidX 提供的浅色主题背景(例如 Theme.Material.Light)上设置。使用 Force Dark 时,您应确保全面测试应用,并根据需要排除视图。

如果您的应用使用Dark Theme主题(例如Theme.Material),则系统不会应用 Force Dark。同样,如果应用的主题背景继承自 DayNight 主题(例如Theme.AppCompat.DayNight),则系统不会应用 Force Dark,因为会自动切换主题背景。

监听深色主题是否开启

首先在清单文件中给对应的Activity配置 android:configChanges="uiMode":

<activity
    android:name=".MyActivity"
    android:configChanges="uiMode" />

这样在onConfigurationChanged方法中就可以获取:

@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    int currentNightMode = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK;
    switch (currentNightMode) {
        case Configuration.UI_MODE_NIGHT_NO:
            // 关闭
            break;
        case Configuration.UI_MODE_NIGHT_YES:
            // 开启
            break;
        default:
            break;    
    }
}

判断深色主题是否开启

其实和上面onConfigurationChanged方法同理:

public static boolean isNightMode(Context context) {
    int currentNightMode = context.getResources().getConfiguration().uiMode & 
        Configuration.UI_MODE_NIGHT_MASK;
    return currentNightMode == Configuration.UI_MODE_NIGHT_YES;
}

6.标识符和数据

对不可重置的设备标识符实施了限制

受影响的方法包括:

Build

•getSerial()

TelephonyManager

•getImei()

•getDeviceId()

•getMeid()

•getSimSerialNumber()

•getSubscriberId()

从 Android 10 开始,应用必须具有 READ_PRIVILEGED_PHONE_STATE 特许权限才能正常使用以上这些方法。

如果你的应用没有该权限,却仍然使用了以上的方法,则返回的结果会因目标 SDK 版本而异:

•如果应用以 Android 10 或更高版本为目标平台,则会发生 SecurityException。

•如果应用以 Android 9(API 级别 28)或更低版本为目标平台,则相应方法会返回 null 或占位符数据(如果应用具有 READ_PHONE_STATE 权限)。否则,会发生 SecurityException。

8.限制对屏幕内容的访问

为了保护用户的屏幕内容,Android 10 更改了 READ_FRAME_BUFFER、CAPTURE_VIDEO_OUTPUT 和 CAPTURE_SECURE_VIDEO_OUTPUT 权限的作用域,从而禁止以静默方式访问设备的屏幕内容。从 Android 10 开始,这些权限只能通过签名访问。

需要访问设备屏幕内容的应用应使用 MediaProjection API,此 API 会显示提示,要求用户同意访问。

9.限制了对剪贴板数据的访问权限

除非您的应用是默认输入法 (IME) 或是目前处于焦点的应用,否则它无法访问 Android 10 或更高版本平台上的剪贴板数据。

10.对启用和停用 WLAN 实施了限制

以 Android 10 或更高版本为目标平台的应用无法启用或停用 WLAN。WifiManager.setWifiEnabled()方法始终返回 false。

如果您需要提示用户启用或停用 WLAN,请使用设置面板。

11.摄像头和连接性

对访问摄像头详情和元数据的权限实施了限制

Android 10 更改了 getCameraCharacteristics() 方法默认返回的信息的广度。具体而言,您的应用必须具有 CAMERA 权限才能访问此方法的返回值中可能包含的设备特定元数据

12.折叠屏

Android10上对折叠屏设备有了更好的支持,对于有折叠屏适配的需求,可以参看为可折叠设备构建应用 和 华为折叠屏应用开发指导。

13.限制非 SDK 接口的更新

14.最新的是28+,升到Q,推荐转为androidX

相关app、library模块中build.gradle的compileSdkVersion、targetSdkVersion、buildToolsVersion的配置,都设置为29,示例如下

android {
   compileSdkVersion 29
   buildToolsVersion 29.0.2
   defaultConfig {
      targetSdkVersion 29
   }
   ...
}

修改当前项目的 gradle.properties

android.useAndroidX=true
android.enableJetifier=true

其中:

•android.useAndroidX=true 表示当前项目启用 AndroidX;

•android.enableJetifier=true 表示将依赖包也迁移到AndroidX 。如果取值为 false ,表示不迁移依赖包到AndroidX,但在使用依赖包中的内容时可能会出现问题,如果你的项目中没有使用任何三方依赖,此项可以设置为 false。

在 AndroidStudio 3.2 或更高版本(截图中 AndroidStudio 为 3.5 版本)中执行如下操作:菜单>Refactor > Migrate to AndroidX

15.Region.Op相关异常

java.lang.IllegalArgumentException: Invalid Region.Op - only INTERSECT and DIFFERENCE are allowed 当 targetSdkVersion >= Build.VERSION_CODES.P 时调用 canvas.clipPath(path, Region.Op.XXX); 引起的异常,参考源码如下:

@Deprecated
public boolean clipPath(@NonNull Path path, @NonNull Region.Op op) {
     checkValidClipOp(op);
     return nClipPath(mNativeCanvasWrapper, path.readOnlyNI(), op.nativeInt);
}

private static void checkValidClipOp(@NonNull Region.Op op) {
     if (sCompatiblityVersion >= Build.VERSION_CODES.P
         && op != Region.Op.INTERSECT && op != Region.Op.DIFFERENCE) {
         throw new IllegalArgumentException(
                    "Invalid Region.Op - only INTERSECT and DIFFERENCE are allowed");
     }
}

我们可以看到当目标版本从Android P开始,Canvas.clipPath(@NonNull Path path, @NonNull Region.Op op) ; 已经被废弃,而且是包含异常风险的废弃API,只有 Region.Op.INTERSECT 和Region.Op.DIFFERENCE 得到兼容,目前不清楚google此举目的如何,仅仅如此简单就抛出异常提示开发者适配,几乎所有的博客解决方案都是如下简单粗暴:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    canvas.clipPath(path);
} else {
    canvas.clipPath(path, Region.Op.XOR);// REPLACE、UNION 等
}

但我们一定需要一些高级逻辑运算效果怎么办?如小说的仿真翻页阅读效果,解决方案如下,用Path.op代替,先运算Path,再给canvas.clipPath:

if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P){
    Path mPathXOR = new Path();
    mPathXOR.moveTo(0,0);
    mPathXOR.lineTo(getWidth(),0);
    mPathXOR.lineTo(getWidth(),getHeight());
    mPathXOR.lineTo(0,getHeight());
    mPathXOR.close();
    //以上根据实际的Canvas或View的大小,画出相同大小的Path即可
    mPathXOR.op(mPath0, Path.Op.XOR);
    canvas.clipPath(mPathXOR);
}else {
    canvas.clipPath(mPath0, Region.Op.XOR);
}

对应用的影响

Sms/Sms Pro/Sms2020

1.备份还原有问题,不能备份还原

2.自定义铃声有问题,不能选择sd卡中的铃声

3.设置默认短信,点击没反应

4.壁纸应用不了,sticker,gif用不了

5.sd卡中字体用不了

  1. BluetoothAdapter api用不了(手表相关)

Messenger/Messenger2020

没发现问题

Messenger Pro

不能下载壁纸,ColorCall功能不能用

⚠️ **GitHub.com Fallback** ⚠️