Android10適配-存儲適配

Android11預覽版出來了,android10適配再不進行就晚了。
Android 11 開發者預覽版使用入門
備註:本文章主要摘自OPPO對AndroidQ的適配指導,並結合華爲對AndroidQ的適配指導,並參考官方文檔和網上的一些優秀文章整理而來。

背景介紹

在Android Q上,target SDK大於或等於29的APP默認被賦予Filtered View,反之則默認被賦予Legacy View。APP可以在AndroidManifest.xml中設置新屬性requestLegacyExternalStorage來修改外部存儲空間視圖模式,true爲Legacy View,false爲Filtered View。可以使用Environment.isExternalStorageLegacy()這個API來檢查APP的運行模式。
設置如下:

<application
    ...
    android:requestLegacyExternalStorage="false"
    android:label="@string/app_name">
    
    ...

</application>

注:
1、Filtered View:App可以直接訪問App-specific目錄,但不能直接訪問App-specific外的文件。訪問公共目錄或其他APP的App-specific目錄,只能通過MediaStore、SAF、或者其他APP 提供的ContentProvider、FileProvider等訪問。
2、Legacy View:兼容模式。與Android Q以前一樣,申請權限後App可訪問外部存儲,擁有完整的訪問權限。

內部存儲訪問變化:
在這裏插入圖片描述
上圖來源: https://blog.csdn.net/lf0814/article/details/99683112

內容概要: 權限適配、訪問APP自身App-specific目錄文件、使用MediaStore訪問公共目錄、使用SAF 訪問指定文件和目錄和其他細節適配。

一、權限適配

Android Q仍然使用READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE作爲面向用戶的存儲相關運行時權限,但現在即使獲取了這些權限,訪問外部存儲也受到了限制。APP需要這些運行時權限的情景發生了變化,且各種情況下外部存儲對APP的可見性也發生了變化。
在Scoped Storage新特性中,外部存儲空間被分爲兩部分:
1、公共目錄
包括:Downloads、Documents、Pictures 、DCIM、Movies、Music、Ringtones等。
公共目錄下的文件在APP卸載後,不會刪除。
APP可以通過SAF(System Access Framework)、MediaStore接口訪問其中的文件。
2、App-specific目錄
APP卸載後,數據會清除。
APP的私密目錄,APP訪問自己的App-specific目錄時無需任何權限。
注:之前文檔上的READ_MEDIA_AUDIO、READ_MEDIA_IMAGES、READ_MEDIA_VIDEO已經被刪除了。

二、訪問APP自身App-specific目錄文件

無需任何權限。
APP即可直接使用文件路徑來讀寫自身App-specific目錄下的文件。
獲取App-specific目錄路徑的接口如下表所示:

App-specific目錄 接口(所有存儲設備) 接口(Primary External Storage)
Media getExternalMediaDirs() NA
Obb getObbDirs() getObbDir()
Cache getExternalCacheDirs() getExternalCacheDir()
Data getExternalFilesDirs(String type) getExternalFilesDir(String type)

需要說明的一點就是,getExternalFilesDirs(String type)中的type,可以自己指定,比如String type = “chat”, 當然也可以用Environment.DIRECTORY_PICTURES等作爲type。

新建並寫入文件爲例:

// set "chat" as subDir
final File[] dirs = getExternalFilesDirs("chat");
File primaryDir = null;
if (dirs != null && dirs.length > 0) {
    primaryDir = dirs[0];
}
if (primaryDir == null) {
    return;
}
File newFile = new File(primaryDir.getAbsolutePath(), "MyTestChat");
OutputStream fileOS = null;
try {
    fileOS = new FileOutputStream(newFile);
    if (fileOS != null) {
        fileOS.write("file is created".getBytes(StandardCharsets.UTF_8));
        fileOS.flush();
    }
} catch (IOException e) {
    LogUtil.log("create file fail");
} finally {
    try {
        if (fileOS != null) {
            fileOS.close();
        }
    } catch (IOException e1) {
        LogUtil.log("close stream fail");
    }
}

讀取公共區域的文件,並寫入App-specific:

	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();        
    }

三、使用MediaStore訪問公共目錄

1、APP通過MediaStore訪問文件所需要的權限:

無權限 READ_EXTERNAL
Audio 可讀寫APP自己創建的文件,但不可直接使用路徑訪問 可以讀其他APP創建的媒體類文件,刪改操作需要用戶授權
Image
Video
File 不可讀寫其他APP創建的非媒體類文件
Downloads
2、APP無法直接訪問公共目錄下的文件。MediaStore爲APP提供了訪問公共目錄下媒體文件的接口。APP在有適當權限時,可以通過MediaStore 查詢到公共目錄文件的Uri,然後通過Uri讀寫文件。

MediaStore相關的Google官方文檔
MediaStore提供了下列幾種類型的訪問Uri,通過查詢對應Uri數據(在MediaProvider中),達到訪問的目的。
下列每種類型又分爲三種Uri:Internal、External、可移動存儲。

文件類型 存儲設備 Uri
Audio Internal MediaStore.Audio.Media.INTERNAL_CONTENT_URI
content://media/internal/audio/media
External MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
content://media/external/audio/media
可移動存儲 MediaStore.Audio.Media.getContentUri(volumeName)
content://media//audio/media
Video Internal MediaStore.Video.Media.INTERNAL_CONTENT_URI
content://media/internal/audio/media
External MediaStore.Video.Media.EXTERNAL_CONTENT_URI
content://media/external/audio/media
可移動存儲 MediaStore.Video.Media.getContentUri(volumeName)
content://media//audio/media
Image Internal MediaStore.Image.Media.INTERNAL_CONTENT_URI
content://media/internal/audio/media
External MediaStore.Image.Media.EXTERNAL_CONTENT_URI
content://media/external/audio/media
可移動存儲 MediaStore.Image.Media.getContentUri(volumeName)
content://media//audio/media
Downloads Internal MediaStore.Downloads.Media.INTERNAL_CONTENT_URI
content://media/internal/audio/media
External MediaStore.Downloads.Media.EXTERNAL_CONTENT_URI
content://media/external/audio/media
可移動存儲 MediaStore.Downloads.Media.getContentUri(volumeName)
content://media//audio/media
File NA MediaStore. Files.Media.getContentUri(volumeName)
content://media//file

在Android Q上,所有的外部存儲設備,包括內置卡、SD卡等,都會被命名,即設備的Volume Name。MediaStore可以通過Volume Name獲取對應存儲設備的Uri。

for (String volumeName : MediaStore.getExternalVolumeNames(this)) {
	MediaStore.Images.Media.getContentUri(volumeName);
}

3、MediaProvider對於APP新建到公共目錄的文件,通過ContentResolver.insert方法中的Uri來確定具體存放目錄。

Mine Type Uri路徑 可選一級目錄(*標記爲默認一級目錄)
audio/* images/media images/media/# Environment.DIRECTORY_ALARMS
Environment.DIRECTORY_MUSIC(*)
Environment.DIRECTORY_NOTIFICATIONS
Environment.DIRECTORY_PODCASTS
Environment.DIRECTORY_RINGTONES
image/* audio/albumart audio/albumart/# Environment.DIRECTORY_MUSIC(*)
NA audio/playlists audio/playlists/# Environment.DIRECTORY_MUSIC(*)
video/ * video/media video/media/# Environment.DIRECTORY_DCIM
Environment.DIRECTORY_MOVIES(*)
image/* images/media images/media/# Environment.DIRECTORY_DCIM
Environment.DIRECTORY_PICTURES(*)
image/* video/thumbnails video/thumbnails/# Environment.DIRECTORY_MOVIES(*)
image/* images/thumbnails images/thumbnails/# Environment.DIRECTORY_PICTURES(*)
NA downloads downloads/# Environment.DIRECTORY_DOWNLOADS(*)
NA file file/# Environment.DIRECTORY_DOWNLOADS(*)
Environment.DIRECTORY_DOCUMENTS

(1)多媒體文件讀取
通過ContentProvider查詢文件,獲得需要讀取的文件Uri:

public static List < Uri > loadPhotoFiles(Context context) {
    Log.e(TAG, "loadPhotoFiles");
    List < Uri > photoUris = new ArrayList < Uri > ();
    Cursor cursor = context.getContentResolver().query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[] {
            MediaStore.Images.Media._ID
        }, null, null, null);
    Log.e(TAG, "cursor size:" + cursor.getCount());
    while (cursor.moveToNext()) {
        int id = cursor.getInt(cursor
            .getColumnIndex(MediaStore.Images.Media._ID));
        Uri photoUri = Uri.parse(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString() + File.separator + id);
        Log.e(TAG, "photoUri:" + photoUri);
        photoUris.add(photoUri);
    }
    return photoUris;
}

通過Uri讀取文件:

public static Bitmap getBitmapFromUri(Context context, Uri uri) throws IOException {
    ParcelFileDescriptor parcelFileDescriptor =
            context.getContentResolver().openFileDescriptor(uri, "r");
    FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
    Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
    parcelFileDescriptor.close();
    return image;
}

注意:MediaProvider變更適配指導:MediaProvider中的“_data”字段已經廢棄掉了,開發者不能再認爲該字段保存的是文件的真實路徑,Q版本因爲存儲空間限制的變更,應用已經無法直接通過文件路徑讀取文件,需要使用文件的Content URI讀取文件,目前發現有很多應用通過“_data”值作爲文件的真實路徑在加載顯示圖片之前判斷文件是否存在,這樣的做法在Q版本是有問題的,應用需要整改。
(2)多媒體文件保存
應用只能在沙箱內通過文件路徑的方式保存文件,如果需要保存文件到沙箱目錄外,需要使用特定的接口實現。
a、方式1
通過MediaStore.Images.Media.insertImage接口可以將圖片文件保存到/sdcard/Pictures/,但是隻有圖片文件保存可以通過MediaStore的接口保存,其他類型文件無法通過該接口保存;

public static void saveBitmapToFile(Context context, Bitmap bitmap, String title, String discription) {
    MediaStore.Images.Media.insertImage(context.getContentResolver(), bitmap, title, discription);
}

如果有圖片的路徑,可以使用如下方法

public static void saveBitmapToFile(Context context, String imagePath, String title, String discription) {
    MediaStore.Images.Media.insertImage(context.getContentResolver(), imagePath, title, discription);
}

b、方式2
通過ContentResolver的insert方法將多媒體文件保存到多媒體的公共集合目錄。

/**
* 保存多媒體文件到公共集合目錄
* @param uri:多媒體數據庫的Uri
* @param context
* @param mimeType:需要保存文件的mimeType
* @param displayName:顯示的文件名字
* @param description:文件描述信息
* @param saveFileName:需要保存的文件名字
* @param saveSecondaryDir:保存的二級目錄
* @param savePrimaryDir:保存的一級目錄
* @return 返回插入數據對應的uri
*/
public static String insertMediaFile(Uri uri, Context context, String mimeType,
                                     String displayName, String description, String saveFileName, String saveSecondaryDir, String savePrimaryDir) {
    ContentValues values = new ContentValues();
    values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName);
    values.put(MediaStore.Images.Media.DESCRIPTION, description);
    values.put(MediaStore.Images.Media.MIME_TYPE, mimeType);
    values.put(MediaStore.Images.Media.PRIMARY_DIRECTORY, savePrimaryDir);
    values.put(MediaStore.Images.Media.SECONDARY_DIRECTORY, saveSecondaryDir);
    Uri url = null;
    String stringUrl = null;    /* value to be returned */
    ContentResolver cr = context.getContentResolver();
    try {
        url = cr.insert(uri, values);
        if (url == null) {
            return null;
        }
        byte[] buffer = new byte[BUFFER_SIZE];
        ParcelFileDescriptor parcelFileDescriptor = cr.openFileDescriptor(url, "w");
        FileOutputStream fileOutputStream =
                new FileOutputStream(parcelFileDescriptor.getFileDescriptor());
        InputStream inputStream = context.getResources().getAssets().open(saveFileName);
        while (true) {
            int numRead = inputStream.read(buffer);
            if (numRead == -1) {
                break;
            }
            fileOutputStream.write(buffer, 0, numRead);
        }
        fileOutputStream.flush();
    } catch (Exception e) {
        Log.e(TAG, "Failed to insert media file", e);
        if (url != null) {
            cr.delete(url, null, null);
            url = null;
        }
    }
    if (url != null) {
        stringUrl = url.toString();
    }
    return stringUrl;
}

比如你需要把一個圖片文件保存到/sdcard/dcim/test/下面,可以這樣調用:

SandboxTestUtils.insertMediaFile(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, this, "image/jpeg",
"insert_test_img", "test img save use insert", "if_apple_2003193.png", "test", Environment.DIRECTORY_DCIM);

音頻、視頻文件和下載目錄的文件也是可以通過這個方式進行保存,比如音頻文件保存到/sdcard/Music/test/:

SandboxTestUtils.insertMediaFile(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this, "audio/mpeg",
"insert_test_music", "test audio save use insert", "Never Forget You.mp3", "test", Environment.DIRECTORY_MUSIC);

可以通過PRIMARY_DIRECTORY和SECONDARY_DIRECTORY字段來設置一級目錄和二級目錄:
(a)一級目錄必須是和MIME type的匹配的根目錄下的Public目錄,一級目錄可以不設置,不設置時會放到默認的路徑;
(b)二級目錄可以不設置,不設置時直接保存在一級目錄下;
(c)應用生成的文檔類文件,代碼裏面默認不設置時,一級是Downloads目錄,也可以設置爲Documents目錄,建議推薦三方應用把文檔類的文件一級目錄設置爲Documents目錄;
(3)多媒體文件的編輯和修改
應用只有自己插入的多媒體文件的寫權限,沒有別的應用插入的多媒體文件的寫權限,比如使用下面的代碼刪除別的應用的多媒體文件會因爲權限問題導致刪除失敗:

context.getContentResolver().delete(uri, null, null))

根據查詢得到的文件Uri,使用MediaStore修改其他APP新建的多媒體文件,需要catch RecoverableSecurityException ,由MediaProvider彈出彈框給用戶選擇是否允許APP修改或刪除圖片/視頻/ 音頻文件。用戶操作的結果,將通過onActivityResult回調返回到APP。如果用戶允許,APP將獲得該Uri 的修改權限,直到設備下一次重啓。
根據文件Uri,通過下列接口,獲取需要修改文件的FD或者OutputStream:
a、getContentResolver().openOutputStream(contentUri)獲取對應文件的OutputStream。
b、getContentResolver().openFile或者getContentResolver().openFileDescriptor。
通過openFile或者openFileDescriptor打開文件,需要選擇Mode爲”w”,表示寫權限。 這些接口返回一個ParcelFileDescriptor。

OutputStream os = null;
try {
	if (imageUri != null) {
		os = resolver.openOutputStream(imageUri);
	}
} catch (IOException e) {
	LogUtil.log("open image fail");
} catch (RecoverableSecurityException e1) {
	LogUtil.log("get RecoverableSecurityException");
	try {
		((Activity) context).startIntentSenderForResult(
e1.getUserAction().getActionIntent().getIntentSender(),
100, null, 0, 0, 0);
	} catch (IntentSender.SendIntentException e2) {
		LogUtil.log("startIntentSender fail");
	}
}

四、使用SAF訪問指定文件和目錄

SAF,即Storage Access Framework。根據當前系統中存在的DocumentsProvider,讓用戶選擇特定的文件或文件夾,使調用SAF的APP獲取它們的讀寫權限。APP通過SAF獲得文件或目錄的讀寫權限,無需申請任何存儲相關的運行時權限。
SAF相關的Google官方文檔
使用SAF獲取文件或目錄權限的過程:
APP通過特定Intent調起DocumentUI -> 用戶在DocumentUI界面上選擇要授權的文件或目錄 -> APP在回調中解析文件或目錄的Uri,最後根據這一Uri可進行讀寫刪操作
(1)使用SAF選擇單個文件
使用Intent.ACTION_OPEN_DOCUMENT調起DocumentUI的文件選擇頁面,用戶可以選擇一個文件,將它的讀寫權限授予APP。

Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
// you can set type to filter files to show
intent.setType("*/*");
startActivityForResult(intent, REQUEST_CODE_FOR_SINGLE_FILE);

在這裏插入圖片描述
(2)使用SAF修改文件
用戶選擇文件授權給APP後,在APP的onActivityResult 回調中收到返回結果,解析出對應文件的Uri。然後使用該Uri,用戶可以獲取可寫的ParcelFileDescriptor或者打開OutputStream 進行修改。

if (requestCode == REQUEST_CODE_FOR_SINGLE_FILE && resultCode == Activity.RESULT_OK) {
    Uri fileUri = null;
    if (data != null) {
        fileUri = data.getData();
    }
    if (fileUri != null) {
        OutputStream os = null;
        try {
            os = getContentResolver().openOutputStream(fileUri);
            os.write("something".getBytes(StandardCharsets.UTF_8));
        } catch (IOException e) {
            LogUtil.log("modify document fail");
        } finally {
            if (os != null) {
                try {
                    os.close();
                } catch (IOException e1) {
                    LogUtil.log("close fail");
                }
            }
        }
    }
}

(3)使用SAF刪除文件
類似修改文件,在回調中解析出文件Uri,然後使用DocumentsContract.deleteDocument接口進行刪除操作。

if (requestCode == REQUEST_CODE_FOR_SINGLE_FILE && resultCode == Activity.RESULT_OK) {
    Uri fileUri = null;
    if (data != null) {
        fileUri = data.getData();
    }
    if (fileUri != null) {
        try {
            DocumentsContract.deleteDocument(getContentResolver(), fileUri);
        } catch (FileNotFoundException e) {
            LogUtil.log("delete document fail");
        }
    }
}

(4)使用SAF新建文件

Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
// you can set file mimetype
intent.setType("*/*");
// default file name
intent.putExtra(Intent.EXTRA_TITLE, "myFileName");
startActivityForResult(intent, REQUEST_CODE_FOR_CREATE_FILE);

在這裏插入圖片描述
在用戶確定後,操作結果將返回到APP的onActivityResult回調中,APP解析出文件Uri,之後就可以利用這一 Uri對文件進行讀寫刪操作。

if (requestCode == REQUEST_CODE_FOR_CREATE_FILE && resultCode == Activity.RESULT_OK) {
    Uri fileUri = null;
    if (data != null) {
        fileUri = data.getData();
    }
    // read/update/delete by the uri got here.
    LogUtil.log("uri: " + fileUri);
}

五、適配細節

(1)卸載應用
如果APP在AndroidManifest.xml中聲明:android:hasFragileUserData=“true”,卸載應用會有提示是否保留 APP數據。默認應用卸載時App-specific目錄下的數據被刪除,但用戶可以選擇保留。
(2)DATA字段數據不再可靠
MediaStore中,DATA(即_data)字段,在Android Q中開始廢棄,不再表示文件的真實路徑。讀寫文件或判斷文件是否存在,不應該使用 DATA字段,而要使用openFileDescriptor。
同時也無法直接使用路徑訪問公共目錄的文件。
(3)MediaStore.Files接口自過濾
通過MediaStore.Files接口訪問文件時,只展示多媒體文件(圖片、視頻、音頻)。其他文件,例如PDF文件,無法訪問到。

參考文檔:
OPPO:Android Q版本應用兼容性適配指導
華爲AndroidQ適配(這個鏈接華爲已經刪除了)
官方文檔
AndroidQ(10)分區存儲完美適配

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章