本文已参与「新人创作礼」活动,一起开启掘金创作之路。
Android Q适配过程
准备工作
首先将我们项目中的targetSdkVersion
改为 29。
1.Scoped Storage(分区存储)
说明
Android Q之前的版本在做文件的操作时都回申请存储空间的读写权限,但这些权限是被滥用,造成的问题就是在手机中存储空间存储大量不明作用的文件,在APP被卸载后仍然没有被删除掉,从而成了僵尸文件。为了解决这个问题,Android 10引入了Scoped Storage的概念,通过添加外部存储访问限制来实现更好的文件管理。
首先明确一个概念,外部储存和内部储存。
-
内部储存:
/data
目录。一般我们使用getFilesDir()
或getCacheDir()
方法获取本应用的内部储存路径,读写该路径下的文件不需要申请储存空间读写权限,且卸载应用时会自动删除。 -
外部储存:
/storage
或/mnt
目录。一般我们使用getExternalStorageDirectory()
方法获取的路径来存取文件。
因为不同厂商、系统版本的原因,所以上述的方法并没有一个固定的文件路径。了解了上面的概念,那我们所说的外部储存访问限制,可以认为是针对getExternalStorageDirectory()路径下的文件。具体的规则如下表:
上图将外部存储空间分为了三部分:
-
特定目录(App-specific),使用
getExternalFilesDir()
或getExternalCacheDir()
方法访问。无需权限,且卸载应用时会自动删除。 -
照片、视频、音频这类媒体文件。使用
MediaStore
访问,访问其他应用的媒体文件时需要READ_EXTERNAL_STORAGE
权限。 -
其他目录,使用
存储访问框架SAF
(Storage Access Framwork)
所以在Android 10上即使你拥有了储存空间的读写权限,也无法保证可以正常的进行文件的读写操作。
适配
最简单粗暴的方法就是在AndroidManifest.xml
中添加 android:requestLegacyExternalStorage="true"
来请求使用旧的存储模式。
但是我不推荐此方法。因为在下一个版本的Android中,此条配置将会失效,将强制采用外部储存限制。其实早在Android Q Beta 3之前都是强制的,但为了给开发者适配的时间才没有强制执行。所以如果你不抓住这段时间去适配,那么今年下半年出了Android 11。。。直接开花~~
如果你已经适配Android 10,这里有个现象要注意一下: 如果应用通过升级安装,那么还会使用以前的储存模式(Legacy View)。只有通过首次安装或是卸载重新安装才能启用新模式(Filtered View)。 所以在适配时,我们的判断代码如下:
// 使用Environment.isExternalStorageLegacy()来检查APP的运行模式
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
!Environment.isExternalStorageLegacy()) {
}
这样的好处是你可以在用户升级后,能方便的将用户的数据移动至应用的特定目录。否则你只能通过SAF去移动,这样会非常麻烦。如果你要移动数据注意只适用于Android 10下,所以现在适配反而是一个好时机。当然如果你不需要迁移数据,那适配会更省事。 下面就说说推荐适配方案:
对于应用中涉及的文件操作,修改一下你的文件路径。
以前我们习惯使用Environment.getExternalStorageDirectory()
方法,那么现在可以使用getExternalFilesDir()
方法(包括下载的安装包这类的文件)。如果是缓存类型文件,可以放到getExternalCacheDir()
路径下。
或者使用MediaStore
,将文件存至对应的媒体类型中(图片:MediaStore.Images
,视频:MediaStore.Video
,音频:MediaStore.Audio
),不过仅限于多媒体文件。
下面代码将图片保存到公共目录下,返回Uri:
public static Uri createImageUri(Context context) {
ContentValues values = new ContentValues();
// 需要指定文件信息时,非必须
values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image");
values.put(MediaStore.Images.Media.DISPLAY_NAME, "Image.png");
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/test");
return context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
}
对于媒体资源的访问:比如图片选择器这类的场景。无法直接使用File,而应使用Uri。否则报错如下:
java.io.FileNotFoundException: open failed: EACCES (Permission denied)
复制代码比如我在适配项目中使用的图片选择器时,首先修改了Glide 通过加载File的方式显示图片。改为加载Uri的方式,否则图片无法显示出来。 Uri的获取方式还是使用MediaStore:
String id = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));
Uri uri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
其次为了便于不影响之前选择图片返回File的逻辑(因为一般都是上传File,没有直接上传Uri的操作),所以我将最终选择的文件又转存进了getExternalFilesDir(),主要代码如下:
File imgFile = this.getExternalFilesDir("image");
if (!imgFile.exists()){
imgFile.mkdir();
}
try {
File file = new File(imgFile.getAbsolutePath() + File.separator +
System.currentTimeMillis() + ".jpg");
// 使用openInputStream(uri)方法获取字节输入流
InputStream fileInputStream = getContentResolver().openInputStream(uri);
FileOutputStream fileOutputStream = new FileOutputStream(file);
byte[] buffer = new byte[1024];
int byteRead;
while (-1 != (byteRead = fileInputStream.read(buffer))) {
fileOutputStream.write(buffer, 0, byteRead);
}
fileInputStream.close();
fileOutputStream.flush();
fileOutputStream.close();
// 文件可用新路径 file.getAbsolutePath()
} catch (Exception e) {
e.printStackTrace();
}
如果你要获取图片中的地理位置信息,需要申请ACCESS_MEDIA_LOCATION权限,并使用MediaStore.setRequireOriginal()获取。下面是官方的示例代码:
Uri photoUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
cursor.getString(idColumnIndex));
final double[] latLong;
// 从ExifInterface类获取位置信息
photoUri = MediaStore.setRequireOriginal(photoUri);
InputStream stream = getContentResolver().openInputStream(photoUri);
if (stream != null) {
ExifInterface exifInterface = new ExifInterface(stream);
double[] returnedLatLong = exifInterface.getLatLong();
// If lat/long is null, fall back to the coordinates (0, 0).
latLong = returnedLatLong != null ? returnedLatLong : new double[2];
// Don't reuse the stream associated with the instance of "ExifInterface".
stream.close();
} else {
// Failed to load the stream, so return the coordinates (0, 0).
latLong = new double[2];
}
这样下来,一个图片选择器就基本适配完了。