Android 10 完美适配

背景

2019 年 9 月 3 日,Google 发布了 Android 10 正式版。Android 10 聚焦移动创新、安全隐私和数字健康三大主题,全面打造最佳的用户体验。

在Android 10 版本中,官方的改动较大,相应的开发者适配成本还是很高的。基于前期调研,我们主要基于以下几方面进行Android 10的适配:

  • Android X
  • 分区存储
  • 设备ID
  • 明文HTTP限制

1. AndroidX

AndroidX 对原始 Android Support库进行了重大改进,后者现在已不再维护。AndroidX 软件包完全取代了支持库,不仅提供同等的功能,而且提供了新的库。

1.1 什么是AndroidX

Android 系统在刚刚面世的时候,可能连它的设计者也没有想到它会如此成功,因此也不可能在一开始的时候就将它的 API 考虑的非常周全。随着 Android 系统版本不断地迭代更新,每个版本中都会加入很多新的 API 进去,但是新增的 API 在老版系统中并不存在,因此这就出现了一个向下兼容的问题。

于是 Android 团队推出了一个鼎鼎大名的 Android Support Library,用于提供向下兼容的功能。比如我们熟知的support-v4 库,appcompat-v7 库都是属于 Android Support Library 的。4在这里指的是 Android API 版本号,对应的系统版本是1.6。support-v4 的意思就是这个库中提供的 API 会向下兼容到 Android 1.6 系统,它对应的包名为 android.support.v4.app。类似地,appcompat-v7 指的是将库中提供的 API 向下兼容至 API 7,也就是 Android 2.1 系统,它对应的包名为 android.support.v7.app。可以发现,Android Support Library 中提供的库,它们的包名都是以 **android.support.***开头的。

但是随着时间的推移,Android1.6、Android2.1 系统早已被淘汰了,现在 Android 官方支持的最低系统版本已经是 4.0.1,对应的 API 版本号是 15。support-v4、appcompat-v7 库也不再支持那么久远的系统了,但是它们的名字却一直保留了下来,虽然它们现在的实际作用已经对不上当初命名的原因了。

Android 团队也意识到这种命名已经非常不合适了,于是对这些 API 的架构进行了一次重新的划分,推出了AndroidX。因此,AndroidX 本质上其实就是对 Android Support Library 进行的一次升级。升级内容主要在于以下两个方面。

  1. 包名:之前 Android Support Library 中的 API,它们的包名都是在**android.support.***下面的,而 AndroidX 库中所有 API 的包名都变成了在 **androidx.* **下面。这是一个很大的变化,意味着以后凡是 android.* 包下面的 API 都是随着 Android 操作系统发布的,而 androidx.* 包下面的 API 都是随着扩展库发布的,这些 API 基本不会依赖于操作系统的具体版本。

  2. 命名规则:吸取了之前命名规则的弊端,AndroidX 所有库的命名规则里都不会再包含具体操作系统 API 的版本号了。比如,像 appcompat-v7 库,在 AndroidX 中就变成了 appcompat 库。

AndroidX 的依赖库格式如下所示:

implementation 'androidx.appcompat:appcompat:1.2.0'

总的来说,AndroidX 并不是什么全新的东西,只不过是对 Android Support Library 的一次升级,因此 AndroidX 上手起来也没有任何困难的地方,比如经常使用的 RecyclerView、ViewPager 等等库,在 AndroidX 中都会有一个对应的版本,只要改一下包名就可以完全无缝使用,用法方面基本上都没有任何的变化,但是有一点需要注意,AndroidX 和 Android Support Library 中的库是非常不建议混合在一起使用的,因为它们可能会产生很多不兼容的问题。最好的做法是,要么全部使用 AndroidX 中的库,要么全部使用 Android Support Library 中的库。

现在 Android Support Library 已经不再建议使用,并会慢慢停止维护,未来都会为 AndroidX 为主。另外,从Android Studio 3.4.2开始,新建的项目已经默认使用 AndroidX 架构了。

1.2 迁移方法

那么对于老项目的迁移应该怎么办呢?由于涉及到了包名的改动,如果从 Android Support Library 升级到AndroidX 需要手动去改每一个文件的包名,那可真得要改死了。为此,Android Studio 提供了一个一键迁移的功能,只需要对着你的项目名右击 → Refactor → Migrate to AndroidX,就会弹出如下图所示的窗口。在这里插入图片描述

这里点击Migrate,Android Studio 就会自动检查你项目中所有使用 Android Support Library 的地方,并将它们全部改成 AndroidX 中对应的库。另外 Android Studio 还会将你原来的项目备份成一个zip文件,这样即使迁移之后的代码出现了问题你还可以随时还原回之前的代码。

2. 分区存储

2.1 背景介绍

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

Android Q 在外部存储设备中为每个应用提供了一个“隔离存储沙盒”。任何其他应用都无法直接访问您应用的沙盒文件。由于文件是您应用的私有文件,因此您不再需要任何权限即可在外部存储设备中访问和保存自己的文件。此变更可让您更轻松地保证用户文件的隐私性,并有助于减少应用所需的权限数量。应用只能看到本应用专有的目录(通过 Context.getExternalFilesDir() 访问)以及特定类型的媒体。除非您的应用需要访问存放在应用的专有目录以及 MediaStore 之外的文件,否则最好使用分区存储。

要点:

  • Android Q 文件存储机制修改成了沙盒模式
  • APP 只能访问自己目录下的文件和公共媒体文件
  • Android Q 版本以下机型,还是使用老的文件存储方式
  • Android Q 及以上版本机型,所有应用均需要分区存储, 所以应用需要提前确保支持分区存储

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

2.2 新特性概览

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

  • 应用私有目录:存储应用私有数据,外部存储应用私有目录对应Android/data/packagename,内部存储应用私有目录对应data/data/packagename;
  • 共享目录:存储其他应用可访问文件, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、 Music、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 方式访问。

  1. File:createNewFile() 、delete() 、renameTo(File dest) 、mkdir() 、mkdirs() 。

  2. FileInputStream:FileInputStream(File file) 、FileInputStream(String name) 。

  3. FileOutputStream:FileOutputStream(String name) 、FileOutputStream(String name , boolean append) 、FileOutputStream(File file) 、FileOutputStream(File file , boolean append) 。

  4. BitmapFactory:decodeFile(String pathName) 、decodeFile(String pathName , Options opts) 。

2.4 兼容模式

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

tagretSDK 大于等于 Android 10(API level 29), 在 AndroidManifest中设置 requestLegacyExternalStorage 属性为true,代码如下所示:

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

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

2.5 适配方案

2.5.1 方案概览

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

1)文件迁移

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

  • 针对只有应用自己访问并且应用卸载后允许删除的文件,需要迁移文件到应用私有目录文件,可以通过 File path 方式访问文件资源,降低适配成本。
  • 允许其他应用访问,并且应用卸载后不允许删除的文件,文件需要存储在共享目录,应用可以选择是否进行目录整改,将文件迁移到 Android10 要求的 media 集合目录。

2)文件访问兼容性

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

2.5.2 适配指导

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

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

  1. 内部存储私有目录

/storage/emulated/0/Android/data/{package name}/files/test

//在外部存储私有目录下创建test文件夹
File testFile = context.getExternalFilesDir("test");
  1. 外部存储私有目录

/data/data/{package name}/files/test

//在内部存储私有目录下创建test文件夹
  File testFile = new File(getFilesDir(), "test");
  if (!testFile.exists()) {
      testFile.mkdirs();
  }

2)创建外部存储私有目录文件

  1. 通过输出流创建文件并写入数据:

/storage/emulated/0/Android/data/{package name}/files/test/test1.txt

String testFile = getExternalFilesDir("test").getAbsolutePath();
File newFile = new File(testFile + File.separator + "test1.txt");
OutputStream os = null;
try {
    os = new FileOutputStream(newFile);
    if (os != null) {
        os.write("test1.txt file is created".getBytes(StandardCharsets.UTF_8));
        os.flush();
    }
} catch (IOException e) {

} finally {
    try {
        if (os != null) {
            os.close();
        }
    } catch (IOException e1) {
    }
}
  1. 通过输入流获取数据
File newFile = new File(apkFilePath + File.separator + "test1.txt");
InputStream in = null;
try {
    in = new FileInputStream(newFile);
    if (in != null) {
        int available = in.available();
        byte[] bytes = new byte[available];
        int len = 0;
        StringBuffer sb = new StringBuffer();
        while ((len = in.read(bytes)) != -1) {
            sb.append(new String(bytes, 0, len));
        }
        System.out.println("result  = " + sb.toString());
    }
} catch (IOException e) {

} finally {
    try {
        if (in != null) {
            in.close();
        }
    } catch (IOException e1) {
    }
}
  1. 内部存储私有目录文件的创建、数据读取与外部存储私的方式一样,这里就不累赘了。

3)创建共享目录文件夹

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    ContentResolver resolver = getContentResolver();
    ContentValues values = new ContentValues();
    values.put(MediaStore.Downloads.DISPLAY_NAME, "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);
} else {
    // ...
}

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

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

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

/**
 * 创建图片地址Uri,用于保存拍照后的照片 Android 10以后使用这种方法
 */
private Uri createImageUri() {
    String status = Environment.getExternalStorageState();
    // 判断是否有SD卡,优先使用SD卡存储,当没有SD卡时使用手机存储
    if (status.equals(Environment.MEDIA_MOUNTED)) {
        return getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new ContentValues());
    } else {
        return getContentResolver().insert(MediaStore.Images.Media.INTERNAL_CONTENT_URI, new ContentValues());
    }
}
  1. 保存或者下载文件到共享目录,如 Download,MIME_TYPE类型可以自行参考对应的文件类型,这里只对APK作出说明

    @RequiresApi(api = Build.VERSION_CODES.Q)
    public static void copyToDownloadAndroidQ(Context context, String sourcePath, String fileName, String saveDirName){
        ContentValues values = new ContentValues();
        values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
        values.put(MediaStore.Downloads.MIME_TYPE, "application/vnd.android.package-archive");
        values.put(MediaStore.Downloads.RELATIVE_PATH, "Download/" + saveDirName.replaceAll("/","") + "/");
    
        Uri external = MediaStore.Downloads.EXTERNAL_CONTENT_URI;
        ContentResolver resolver = context.getContentResolver();
    
        Uri insertUri = resolver.insert(external, values);
        if(insertUri == null) {
            return;
        }
        InputStream is = null;
        OutputStream os = null;
        try {
            os = resolver.openOutputStream(insertUri);
            if(os == null){
                return;
            }
            int read;
            File sourceFile = new File(sourcePath);
            if (sourceFile.exists()) { // 文件存在时
                is = new FileInputStream(sourceFile); // 读入原文件
                byte[] buffer = new byte[1444];
                while ((read = is.read(buffer)) != -1) {
                    os.write(buffer, 0, read);
                }
                is.close();
                os.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        finally {
           if(os !=null){
               try {
                   os.close();
               } catch (IOException e) {
                   e.printStackTrace();
               }
           }
           if(is!=null){
               try {
                   is.close();
               } catch (IOException e) {
                   e.printStackTrace();
               }
           }
        }
    }
    
  2. 保存图片相关

    /**
     * 通过MediaStore保存,兼容AndroidQ,保存成功自动添加到相册数据库,无需再发送广告告诉系统插入相册
     *
     * @param context      context
     * @param sourceFile   源文件
     * @param saveFileName 保存的文件名
     * @param saveDirName  picture子目录
     * @return 成功或者失败
     */
    public static boolean saveImageWithAndroidQ(Context context,
                                                File sourceFile,
                                                String saveFileName,
                                                String saveDirName) {
        ContentValues values = new ContentValues();
        values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image");
        values.put(MediaStore.Images.Media.DISPLAY_NAME, saveFileName);
        values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
        values.put(MediaStore.Images.Media.TITLE, "Image.png");
        values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/" + saveDirName);
    
        Uri external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
        ContentResolver resolver = context.getContentResolver();
    
        Uri insertUri = resolver.insert(external, values);
        BufferedInputStream is = null;
        OutputStream os = null;
        boolean result = false;
        try {
            is = new BufferedInputStream(new FileInputStream(sourceFile));
            if (insertUri != null) {
                os = resolver.openOutputStream(insertUri);
            }
            if (os != null) {
                byte[] buffer = new byte[1024 * 4];
                int len;
                while ((len = is.read(buffer)) != -1) {
                    os.write(buffer, 0, len);
                }
                os.flush();
            }
            result = true;
        } catch (IOException e) {
            result = false;
        } finally {
            if(os !=null){
                try {
                    os.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if(is!=null){
                try {
                    is.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return result;
    }
    

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

  Cursor imageCursor = getContentResolver().query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                IMAGE_PROJECTION, null, null, IMAGE_PROJECTION[4] + " DESC");
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 删除文件

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

7)判断共享目录文件是否存在,自 Android Q开始,共享目录 File API 都失效,不能直接通过 new File(path).exists();判断公有目录文件是否存在,正确方式如下:

public static boolean isAndroidQFileExists(Context context, String path){
    if (context == null) {
        return false;
    }
    AssetFileDescriptor afd = null;
    ContentResolver cr = context.getContentResolver();
    try {
        afd = cr.openAssetFileDescriptor(Uri.parse(path), "r");
        if (afd == null) {
            return false;
        }
    } catch (FileNotFoundException e) {
        return false;
    }finally {
       if(afd !=null){
           try {
               afd.close();
           } catch (IOException e) {
               
           }
       }
    }
    return true;
}

3. 设备ID

从Android 10 开始已经无法完全标识一个设备,曾经用mac地址、IMEI等设备信息标识设备的方法,从 Android 10 开始统统失效。而且无论你的 APP 是否适配过 Android 10。

3.1 IMEI等设备信息

从Android10 开始普通应用不再允许请求 android.permission.READ_PHONE_STATE 权限。而且,无论你的 App是否适配过 Android 10(既targetSdkVersion是否大于等于29),均无法再获取到设备 IMEI 等设备信息。

受影响的API:

Build.getSerial();
TelephonyManager.getImei();
TelephonyManager.getMeid()
TelephonyManager.getDeviceId();
TelephonyManager.getSubscriberId();
TelephonyManager.getSimSerialNumber();
  • targetSdkVersion<29 的应用,其在获取设备ID时,会直接返回null或者unknown。
  • targetSdkVersion>=29 的应用,其在获取设备ID时,会直接抛出异常SecurityException。

如果您的App希望在Android 10以下的设备中仍然获取设备IMEI等信息,可按以下方式进行适配:

<uses-permission android:name="android.permission.READ_PHONE_STATE"
        android:maxSdkVersion="28"/>
3.2 Mac地址随机分配

从Android10开始,默认情况下,在搭载 Android 10 或更高版本的设备上,系统会传输随机分配的 MAC 地址。(即从Android 10开始,普通应用已经无法获取设备的真正mac地址,标识设备已经无法使用mac地址)。

3.3 如何标识设备唯一性

3.3.1 Google解决方案:如果您的应用有追踪非登录用户的需求,可用ANDROID_ID来标识设备。

  • ANDROID_ID生成规则:签名+设备信息+设备用户
  • ANDROID_ID重置规则:设备恢复出厂设置时,ANDROID_ID将被重置
String androidId = Settings.Secure.getString(this.getContentResolver(),
                Settings.Secure.ANDROID_ID);

3.3.2 信通院统一SDK(OAID)

请参考 http://www.msa-alliance.cn/col.jsp?id=120 进行获取。

4. 明文HTTP限制

当 SDK 版本大于 API 28 时,默认限制了 HTTP 请求,并出现相关日志

java.net.UnknownServiceException: CLEARTEXT communication to xxx not permitted by network security policy

该问题有两种解决方案:

1)在 AndroidManifest.xml 中 Application 节点添加如下代码

<application android:usesCleartextTraffic="true">

2)在 res 目录新建 xml 目录,已建的跳过,在xml目录新建一个network_security_config.xml文件,然后在AndroidManifest.xml 中 Application 添加如下节点代码。

android:networkSecurityConfig="@xml/network_security_config"

network_security_config.xml

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>
 by network security policy

该问题有两种解决方案:

1)在 AndroidManifest.xml 中 Application 节点添加如下代码

<application android:usesCleartextTraffic="true">

2)在 res 目录新建 xml 目录,已建的跳过,在xml目录新建一个network_security_config.xml文件,然后在AndroidManifest.xml 中 Application 添加如下节点代码。

android:networkSecurityConfig="@xml/network_security_config"

network_security_config.xml

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>

扫描下方二维码关注公众号,获取更多技术干货。

在这里插入图片描述

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值