目錄介紹
01.磁碟沙盒的概述
- 1.1 專案背景說明
- 1.2 沙盒作用
- 1.3 設計目標
02.Android儲存概念
- 2.1 儲存劃分介紹
- 2.2 機身內部儲存
- 2.3 機身外部儲存
- 2.4 SD卡外部儲存
- 2.5 總結和梳理下
03.方案基礎設計
- 3.1 整體架構圖
- 3.2 UML設計圖
- 3.3 關鍵流程圖
- 3.4 介面設計圖
- 3.5 模組間依賴關係
04.一些技術要點說明
- 4.1 使用佇列管理Fragment棧
- 4.2 File檔案列表
- 4.3 不同版本訪問許可權
- 4.4 訪問檔案操作
- 4.5 10和11許可權說明
- 4.6 分享檔案給第三方
- 4.7 開啟圖片資源
- 4.8 為何需要FileProvider
- 4.9 跨程式IPC通訊
05.其他設計實踐說明
- 5.1 效能設計
- 5.2 穩定性設計
- 5.3 debug依賴設計
01.磁碟沙盒的概述
1.1 專案背景說明
- app展示在資料量多且重新整理頻繁的情況下,為提升使用者體驗,通常會對上次已有資料做記憶體快取或磁碟快取,以達到快速展示資料的目的。快取的資料變化是否正確、快取是否起到對應作用是QA需要重點測試的物件。
- android快取路徑檢視方法有哪些呢?將手機開啟開發者模式並連線電腦,在pc控制檯輸入cd /data/data/目錄,使用adb主要是方便測試(刪除,檢視,匯出都比較麻煩)。
- 如何簡單快速,傻瓜式的檢視快取檔案,操作快取檔案,那麼該專案小工具就非常有必要呢!採用視覺化介面讀取快取資料,方便操作,直觀也簡單。
1.2 沙盒作用
可以通過該工具檢視快取檔案
- 快速檢視
data/data/包名
目錄下的快取檔案。 - 快速檢視
/sdcard/Android/data/包名
下儲存檔案。
- 快速檢視
對快取檔案處理
- 支援檢視file檔案列表資料,開啟快取檔案檢視資料詳情。還可以刪除快取對應的檔案或者資料夾,並且友好支援分享到外部。
- 能夠檢視快取檔案修改的資訊,修改的時間,快取檔案的大小,獲取檔案的路徑等等。都是在視覺化介面上處理。
1.3 設計目標
視覺化介面展示
多種處理檔案操作
- 針對file資料夾,或者file檔案,長按可以出現彈窗,讓測試選擇是否刪除檔案。
- 點選file資料夾,則拿到對應的檔案列表,然後展示。點選file直到是具體檔案(文字,圖片,db,json等非file資料夾)跳轉詳情。
一鍵接入該工具
- FileExplorerActivity.startActivity(MainActivity.this);
- 開源專案地址:https://github.com/yangchong2...
02.Android儲存基本概念
2.1 儲存劃分介紹
儲存劃分介紹
- 手機空間儲存劃分為兩部分:1、機身儲存;2、SD卡外部儲存
- 機身儲存劃分為兩部分:1、內部儲存;2、外部儲存
機身內部儲存
- 放到data/data目錄下的快取檔案,一般使用adb無法檢視該路徑檔案,私有的。程式解除安裝後,該目錄也會被刪除。
機身外部儲存
- 放到/storage/emulated/0/目錄下的檔案,有共享目錄,還有App外部私有目錄,還有其他目錄。App解除安裝的時候,相應的app建立的檔案也會被刪除。
SD卡外部儲存
- 放到sd庫中目錄下檔案,外部開放的檔案,可以檢視。
2.2 機身內部儲存
想一下平時使用的持久化方案:這些檔案都是預設放在內部儲存裡。
- SharedPreferences---->適用於儲存小檔案
- 資料庫---->儲存結構比較複雜的大檔案
如果包名為:com.yc.helper,則對應的內部儲存目錄為:/data/data/com.yc.helper/
- 第一個"/"表示根目錄,其後每個"/"表示目錄分割符。內部儲存裡給每個應用按照其包名各自劃分了目錄
- 每個App的內部儲存空間僅允許自己訪問(除非有更高的許可權,如root),程式解除安裝後,該目錄也會被刪除。
機身內部儲存一般儲存那些檔案呢?大概有以下這些
- cache-->存放快取檔案
- code_cache-->存放執行時程式碼優化等產生的快取
- databases-->存放資料庫檔案
- files-->存放一般檔案
- lib-->存放App依賴的so庫 是軟連結,指向/data/app/ 某個子目錄下
- shared_prefs-->存放 SharedPreferences 檔案
那麼怎麼通過程式碼訪問到這些路徑的檔案呢?程式碼如下所示
context.getCacheDir().getAbsolutePath() context.getCodeCacheDir().getAbsolutePath() //databases 直接通過getDatabasePath(name)獲取 context.getFilesDir().getAbsolutePath() //lib,暫時還不知道怎麼獲取該路徑 //shared_prefs 直接通過SharedPreferences獲取
2.3 機身外部儲存
存放位置,主要有那些?如下所示,根目錄下幾個需要關注的目錄:
- /data/ 這個是前面說的私有檔案
- /sdcard/ /sdcard/是軟連結,指向/storage/self/primary
- /storage/ /storage/self/primary/是軟連結,指向/storage/emulated/0/
也就是說/sdcard/、/storage/self/primary/ 真正指向的是/storage/emulated/0/
下面這個是用adb檢視 /storage/emulated/0 路徑資源
a51x:/storage $ ls emulated self a51x:/storage $ cd emulated/ a51x:/storage/emulated $ ls ls: .: Permission denied 1|a51x:/storage/emulated $ cd 0 a51x:/storage/emulated/0 $ ls //省略 /storage/emulated/0 下的檔案
- 然後來看下 /storage/emulated/0/ 儲存的資源有哪些?如下,分為三部分:
第一種:共享儲存空間
- 也就是所有App共享的部分,比如相簿、音樂、鈴聲、文件等:
- DCIM/ 和 Pictures/-->儲存圖片
- DCIM/、Movies/ 和 Pictures-->儲存視訊
- Alarms/、Audiobooks/、Music/、Notifications/、Podcasts/ 和 Ringtones/-->儲存音訊檔案
- Download/-->下載的檔案
- Documents-->儲存如.pdf型別等檔案
第二種:App外部私有目錄
- Android/data/--->儲存各個App的外部私有目錄。
- 與內部儲存類似,命名方式是:Android/data/xx------>xx指應用的包名。如:/sdcard/Android/data/com.yc.helper
第三種:其它目錄
- 比如各個App在/sdcard/目錄下建立的目錄,如支付寶建立的目錄:alipay/,高德建立的目錄:amap/,騰訊建立的目錄:com.tencent.xx/等。
那麼怎麼通過程式碼訪問到這些路徑的檔案呢?程式碼如下所示
- 第一種:通過ContentProvider訪問,共享儲存空間中的圖片,視訊,音訊,文件等資源
第二種:可以看出再/sdcard/Android/data/目錄下生成了com.yc.helper/目錄,該目錄下有兩個子目錄分別是:files/、cache/。當然也可以選擇建立其它目錄。App解除安裝的時候,兩者都會被清除。
context.getExternalCacheDir().getAbsolutePath(); context.getExternalFilesDir(null).getAbsolutePath();
- 第三種:只要拿到根目錄,就可以遍歷尋找其它子目錄/檔案。
2.4 SD卡外部儲存
- 當給裝置插入SD卡後,檢視其目錄:/sdcard/ ---> 依然指向/storage/self/primary,繼續來看/storage/,可以看出,多了sdcard1,軟連結指向了/storage/77E4-07E7/。
訪問方式,跟獲取外部儲存-App私有目錄方式一樣。
File[] fileList = context.getExternalFilesDirs(null);
- 返回File物件陣列,當有多個外部儲存時候,儲存在陣列裡。返回的陣列有兩個元素,一個是自帶外部儲存儲存,另一個是插入的SD卡。
2.5 總結和梳理下
- Android儲存有三種:手機內部儲存、手機自帶外部儲存、SD卡擴充套件外部儲存等。
內部儲存與外部儲存裡的App私有目錄
相同點:
- 1、屬於App專屬,App自身訪問兩者無需任何許可權。
- 2、App解除安裝後,兩者皆被刪除。
- 3、兩者目錄下增加的檔案最終會被統計到"設定->儲存和快取"裡。
不同點:
- /data/data/com.yc.helper/ 位於內部儲存,一般用於儲存容量較小的,私密性較強的檔案。
- 而/sdcard/Android/data/com.yc.helper/ 位於外部儲存,作為App私有目錄,一般用於儲存容量較大的檔案,即使刪除了也不影響App正常功能。
在設定裡的"儲存與快取"項,有清除資料和清除快取,兩者有何區別?
當點選"清除資料" 時:
- 內部儲存/data/data/com.yc.helper/cache/、 /data/data/com.yc.helper/code_cache/目錄會被清空
- 外部儲存/sdcard/Android/data/com.yc.helper/cache/ 會被清空
當點選"清除快取" 時:
- 內部儲存/data/data/com.yc.helper/下除了lib/,其餘子目錄皆被刪除
- 外部儲存/sdcard/Android/data/com.yc.helper/被清空
- 這種情況,相當於刪除使用者sp,資料庫檔案,相當於重置了app
04.一些技術要點說明
4.1 使用佇列管理Fragment棧
該磁碟沙盒file工具頁面的組成部分是這樣的
- FileExplorerActivity + FileExplorerFragment(多個,file列表頁面) + TextDetailFragment(一個,file詳情頁面)
針對磁碟file檔案列表
FileExplorerFragment
頁面,點選file檔案item- 如果是資料夾則是繼續開啟跳轉到file檔案列表
FileExplorerFragment
頁面,否則跳轉到檔案詳情頁面
- 如果是資料夾則是繼續開啟跳轉到file檔案列表
處理任務棧返回邏輯。舉個例子現在列表
FileExplorerFragment
當作B,檔案詳情頁面當作C,宿主Activity當作A。也就是說,點選返回鍵,依次關閉了fragment直到沒有,回到宿主activity頁面。再次點選返回鍵,則關閉activity!- 可能存在的任務棧是:開啟A1->開啟B1->開啟C1
- 那麼點選返回鍵按鈕,返回關閉的順序則是:關閉C1->關閉B1->關閉A1
Fragment回退棧處理方式
- 第一種方案:建立一個棧(先進後出),開啟一個
FileExplorerFragment
列表頁面(push
一個fragment
物件到佇列中),關閉一個列表頁面(remove
最上面那個fragment
物件,然後呼叫FragmentManager
中popBackStack
操作關閉fragment
) - 第二種方案:通過fragmentManager獲取所有fragment物件,返回一個list,當點選返回的時候,呼叫popBackStack移除最上面一個
- 第一種方案:建立一個棧(先進後出),開啟一個
具體處理該場景中回退邏輯
- 首先定義一個雙端佇列ArrayDeque,用來儲存和移除元素。內部使用陣列實現,可以當作棧來使用,功能非常強大。
當開啟一個fragment頁面的時候,呼叫push(相當於addFirst在棧頂新增元素)來儲存fragment物件。程式碼如下所示
public void showContent(Class<? extends Fragment> target, Bundle bundle) { try { Fragment fragment = target.newInstance(); if (bundle != null) { fragment.setArguments(bundle); } FragmentManager fm = getSupportFragmentManager(); FragmentTransaction fragmentTransaction = fm.beginTransaction(); fragmentTransaction.add(android.R.id.content, fragment); //push等同於addFirst,新增到第一個 mFragments.push(fragment); //add等同於addLast,新增到最後 //mFragments.add(fragment); fragmentTransaction.addToBackStack(""); //將fragment提交到任務棧中 fragmentTransaction.commit(); } catch (InstantiationException exception) { FileExplorerUtils.logError(TAG + exception.toString()); } catch (IllegalAccessException exception) { FileExplorerUtils.logError(TAG + exception.toString()); } }
當關閉一個fragment頁面的時候,呼叫removeFirst(相當於彈出棧頂的元素)移除fragment物件。程式碼如下所示
@Override public void onBackPressed() { if (!mFragments.isEmpty()) { Fragment fragment = mFragments.getFirst(); if (fragment!=null){ //移除最上面的一個 mFragments.removeFirst(); } super.onBackPressed(); //如果fragment棧為空,則直接關閉activity if (mFragments.isEmpty()) { finish(); } } else { super.onBackPressed(); } } /** * 回退fragment任務棧操作 * @param fragment fragment */ public void doBack(Fragment fragment) { if (mFragments.contains(fragment)) { mFragments.remove(fragment); FragmentManager fm = getSupportFragmentManager(); //回退fragment操作 fm.popBackStack(); if (mFragments.isEmpty()) { //如果fragment棧為空,則直接關閉宿主activity finish(); } } }
4.2 File檔案列表
獲取檔案列表,主要包括,
data/data/包名
目錄下的快取檔案。/sdcard/Android/data/包名
下儲存檔案。/** * 初始化預設檔案。注意:加External和不加(預設)的比較 * 相同點:1.都可以做app快取目錄。2.app解除安裝後,兩個目錄下的資料都會被清空。 * 不同點:1.目錄的路徑不同。前者的目錄存在外部SD卡上的。後者的目錄存在app的內部儲存上。 * 2.前者的路徑在手機裡可以直接看到。後者的路徑需要root以後,用Root Explorer 檔案管理器才能看到。 * * @param context 上下文 * @return 列表 */ private List<File> initDefaultRootFileInfos(Context context) { List<File> fileInfos = new ArrayList<>(); //第一個是檔案父路徑 File parentFile = context.getFilesDir().getParentFile(); if (parentFile != null) { fileInfos.add(parentFile); } //路徑:/data/user/0/com.yc.lifehelper //第二個是快取檔案路徑 File externalCacheDir = context.getExternalCacheDir(); if (externalCacheDir != null) { fileInfos.add(externalCacheDir); } //路徑:/storage/emulated/0/Android/data/com.yc.lifehelper/cache //第三個是外部file路徑 File externalFilesDir = context.getExternalFilesDir((String) null); if (externalFilesDir != null) { fileInfos.add(externalFilesDir); } //路徑:/storage/emulated/0/Android/data/com.yc.lifehelper/files return fileInfos; }
4.3 不同版本訪問許可權
Android 6.0 之前訪問方式
Android 6.0 之前是無需申請動態許可權的,在AndroidManifest.xml 裡宣告儲存許可權。就可以訪問共享儲存空間、其它目錄下的檔案。
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
Android 6.0 之後的訪問方式
Android 6.0 後需要動態申請許可權,除了在AndroidManifest.xml 裡宣告儲存許可權外,還需要在程式碼裡動態申請。
//申請許可權 if (ContextCompat.checkSelfPermission(mActivity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(mActivity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, CODE); }
4.4 訪問檔案操作
- 許可權申請成功後,即可對自帶外部儲存之共享儲存空間和其它目錄進行訪問。分別以共享儲存空間和其它目錄為例,闡述訪問方式:
訪問媒體檔案(共享儲存空間)。目的是拿到媒體檔案的路徑,有兩種方式獲取路徑:
以圖片為例,假設圖片儲存在/sdcard/Pictures/目錄下。路徑:/storage/emulated/0/Pictures/yc.png,拿到路徑後就可以解析並獲取Bitmap。
//獲取目錄:/storage/emulated/0/ File rootFile = Environment.getExternalStorageDirectory(); String imagePath = rootFile.getAbsolutePath() + File.separator + Environment.DIRECTORY_PICTURES + File.separator + "yc.png"; Bitmap bitmap = BitmapFactory.decodeFile(imagePath);
通過MediaStore獲取路徑
ContentResolver contentResolver = context.getContentResolver(); Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null); while(cursor.moveToNext()) { String imagePath = cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA)); Bitmap bitmap = BitmapFactory.decodeFile(imagePath); break; }
還有一種不直接通過路徑訪問的方法,通過MediaStore獲取Uri。與直接拿到路徑不同的是,此處拿到的是Uri。圖片的資訊封裝在Uri裡,通過Uri構造出InputStream,再進行圖片解碼拿到Bitmap
private void getImagePath(Context context) { ContentResolver contentResolver = context.getContentResolver(); Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null); while(cursor.moveToNext()) { //獲取唯一的id long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)); //通過id構造Uri Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); openUri(uri); break; } }
訪問文件和其它檔案(共享儲存空間)。
- 直接構造路徑。與媒體檔案一樣,可以直接構造路徑訪問。
訪問其它目錄
- 直接構造路徑。與媒體檔案一樣,可以直接構造路徑訪問。
總結一下共同點
- 訪問目錄/檔案可通過如下兩個方法:1、通過路徑訪問。路徑可以直接構造也可以通過MediaStore獲取。 2、通過Uri訪問。Uri可以通過MediaStore或者SAF(儲存訪問框架,通過intent呼叫startActivity訪問)獲取。
4.5 10和11許可權說明
Android10許可權改變
- 比如能夠直接在/sdcard/目錄下建立目錄/檔案。可以看出/sdcard/目錄下,如淘寶、qq、qq瀏覽器、微博、支付寶等都自己建了目錄。
- 這麼看來,導致目錄結構很亂,而且App解除安裝後,對應的目錄並沒有刪除,於是就是遺留了很多"垃圾"檔案,久而久之不處理,使用者的儲存空間越來越小。
之前檔案建立弊端如下
- 解除安裝App也不能刪除該目錄下的檔案
- 在設定裡"清除資料"或者"清除快取"並不能刪除該目錄下的檔案
- App可以隨意修改其它目錄下的檔案,如修改別的App建立的檔案等,不安全
為什麼要在/sdcard/目錄下新建app儲存的目錄
- 此處新建的目錄不會被設定裡的App儲存用量統計,讓使用者"看起來"自己的App佔用的儲存空間很小。還有就是方便操作檔案
Android 10.0訪問變更
- Google在Android 10.0上重拳出擊了。引入Scoped Storage。簡單來說有好幾個版本:作用域儲存、分割槽儲存、沙盒儲存。分割槽儲存原理:
- 1、App訪問自身內部儲存空間、訪問外部儲存空間-App私有目錄不需要任何許可權(這個與Android 10.0之前一致)
- 2、外部儲存空間-共享儲存空間、外部儲存空間-其它目錄 App無法通過路徑直接訪問,不能新建、刪除、修改目錄/檔案等
- 3、外部儲存空間-共享儲存空間、外部儲存空間-其它目錄 需要通過Uri訪問
4.6 分享檔案給第三方
這裡直接說分享內部檔案給第三方,大概的思路如下所示:
- 第一步:先判斷是否有讀取檔案的許可權,如果沒有則申請;如果有則進行第二步;
- 第二步:先把檔案轉移到外部儲存檔案,為何要這樣操作,主要是解決data/data下目前檔案無法直接分享問題,因此需要將目標檔案拷貝到外部路徑
- 第三步:通過intent傳送,FileProvider拿到對應路徑的uri,最後呼叫startActivity進行分享檔案。
大概的程式碼如下所示
if (ContextCompat.checkSelfPermission(mActivity,Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(mActivity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, CODE); } else { //先把檔案轉移到外部儲存檔案 File srcFile = new File(mFile.getPath()); String newFilePath = AppFileUtils.getFileSharePath() + "/fileShare.txt"; File destFile = new File(newFilePath); //拷貝檔案,將data/data原始檔拷貝到新的目標檔案路徑下 boolean copy = AppFileUtils.copyFile(srcFile, destFile); if (copy) { //分享 boolean shareFile = FileShareUtils.shareFile(mActivity, destFile); if (shareFile) { Toast.makeText(getContext(), "檔案分享成功", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(getContext(), "檔案分享失敗", Toast.LENGTH_SHORT).show(); } } else { Toast.makeText(getContext(), "檔案儲存失敗", Toast.LENGTH_SHORT).show(); } }
4.7 開啟圖片資源
首先判斷檔案,是否是圖片資源,如果是圖片資源,則跳轉到開啟圖片詳情。目前只是根據檔案的字尾名來判斷(對檔名稱以.進行裁剪獲取字尾名)是否是圖片。
if (FileExplorerUtils.isImage(fileInfo)) { Bundle bundle = new Bundle(); bundle.putSerializable("file_key", fileInfo); showContent(ImageDetailFragment.class, bundle); }
開啟圖片跳轉詳情,這裡面為了避免開啟大圖OOM,因此需要對圖片進行壓縮,目前該工具主要是記憶體壓縮和尺寸縮放方式。大概的原理如下
- 例如,我們的原圖是一張 2700 1900 畫素的照片,載入到記憶體就需要 19.6M 記憶體空間,但是,我們需要把它展示在一個列表頁中,元件可展示尺寸為 270 190,這時,我們實際上只需要一張原圖的低解析度的縮圖即可(與圖片顯示所對應的 UI 控制元件匹配),那麼實際上 270 * 190 畫素的圖片,只需要 0.2M 的記憶體即可。這個採用縮放比壓縮。
- 載入圖片,先載入到記憶體,再進行操作嗎,可以如果先載入到記憶體,好像也不太對,這樣只接佔用了 19.6M + 0.2M 2份記憶體了,而我們想要的是,在原圖不載入到記憶體中,只接將縮放後的圖片載入到記憶體中,可以實現嗎?
- 進行記憶體壓縮,要將BitmapFactory.Options的inJustDecodeBounds屬性設定為true,解析一次圖片。注意這個地方是核心,這個解析圖片並沒有生成bitmap物件(也就是說沒有為它分配記憶體控制元件),而僅僅是拿到它的寬高等屬性。
- 然後將BitmapFactory.Options連同期望的寬度和高度一起傳遞到到calculateInSampleSize方法中,就可以得到合適的inSampleSize值了。這一步會壓縮圖片。之後再解析一次圖片,使用新獲取到的inSampleSize值,並把inJustDecodeBounds設定為false,就可以得到壓縮後的圖片了。
4.8 為何需要FileProvider
4.8.1 檔案共享基礎概念
瞭解檔案共享的基礎知識
- 提到檔案共享,首先想到就是在本地磁碟上存放一個檔案,多個應用都可以訪問它,如下:
- 理想狀態下只要知道了檔案的存放路徑,那麼各個應用都可以讀寫它。比如相簿裡的圖片或者視訊存放目錄:/sdcard/DCIM/、/sdcard/Pictures/ 、/sdcard/Movies/。
檔案共享方式是如何理解
- 一個常見的應用場景:應用A裡檢索到一個檔案yc.txt,它無法開啟,於是想借助其它應用開啟,這個時候它需要把待開啟的檔案路徑告訴其它應用。對應案例就是,把磁碟檔案分享到qq。
- 這就涉及到了程式間通訊。Android程式間通訊主要手段是Binder,而四大元件的通訊也是依靠Binder,因此我們應用間傳遞路徑可以依靠四大元件。
4.8.2 7.0前後對檔案處理方式
Android 7.0 之前使用,傳遞路徑可以通過Uri
Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); //通過路徑,構造Uri。設定Intent,附帶Uri,然後通過intent跨程式通訊 Uri uri = Uri.fromFile(new File(external_filePath)); intent.setData(uri); startActivity(intent);
- 接收方在收到Intent後,拿出Uri,通過:filePath = uri.getEncodedPath() 拿到傳送方傳送的原始路徑後,即可讀寫檔案。然而此種構造Uri方式在Android7.0(含)之後被禁止了,若是使用則丟擲異常,異常是FileUriExposedException。
- 這種方式缺點如下:第一傳送方傳遞的檔案路徑接收方完全知曉,一目瞭然,沒有安全保障;第二傳送方傳遞的檔案路徑接收方可能沒有讀取許可權,導致接收異常。
Android 7.0(含)之後如何解決上面兩個缺點問題
- 對第一個問題:可以將具體路徑替換為另一個字串,類似以前密碼本的感覺,比如:"/storage/emulated/0/com.yc.app/yc.txt" 替換為"file/yc.txt",這樣接收方收到檔案路徑完全不知道原始檔案路徑是咋樣的。那麼會導致另一個額外的問題:接收方不知道真實路徑,如何讀取檔案呢?
- 對第二個問題既然不確定接收方是否有開啟檔案許可權,那麼是否由傳送方開啟,然後將流傳遞給接收方就可以了呢?
- Android 7.0(含)之後引入了FileProvider,可以解決上述兩個問題。
4.8.3 FileProvider應用與原理
第一步,定義自定義FileProvider並且註冊清單檔案
public class ExplorerProvider extends FileProvider { } <!--既然是ContentProvider,那麼需要像Activity一樣在AndroidManifest.xml裡宣告:--> <!--android:authorities 標識ContentProvider的唯一性,可以自己任意定義,最好是全域性唯一的。--> <!--android:name 是指之前定義的FileProvider 子類。--> <!--android:exported="false" 限制其他應用獲取Provider。--> <!--android:grantUriPermissions="true" 授予其它應用訪問Uri許可權。--> <!--meta-data 囊括了別名應用表。--> <!--android:name 這個值是固定的,表示要解析file_path--> <!--android:resource 自己定義實現的對映表--> <provider android:name="com.yc.toolutils.file.ExplorerProvider" android:authorities="${applicationId}.fileExplorerProvider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_explorer_provider" /> </provider>
第二步,新增路徑對映表
在/res/ 下建立xml 資料夾,然後再建立對應的對映表(xml),最終路徑如下:/res/xml/file_explorer_provider.xml。
<paths> <!--FileProvider需要讀取對映表。--> <external-cache-path name="external_cache" path="." /> <cache-path name="cache" path="." /> <external-path name="external_path" path="." /> <files-path name="files_path" path="." /> <external-files-path name="external_files_path" path="." /> <root-path name="root_path" path="." /> </paths>
第三步,使用ExplorerProvider來跨程式通訊互動
如何解決第一個問題,讓接收方看不到具體檔案的路徑?如下所示,下面構造後,第三方應用收到此Uri後,並不能從路徑看出我們傳遞的真實路徑,這就解決了第一個問題。
public static boolean shareFile(Context context, File file) { boolean isShareSuccess; try { if (null != file && file.exists()) { Intent share = new Intent(Intent.ACTION_SEND); //此處可傳送多種檔案 String absolutePath = file.getAbsolutePath(); //通過副檔名找到mimeType String mimeType = getMimeType(absolutePath); share.setType(mimeType); Uri uri; //判斷7.0以上 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //第二個參數列示要用哪個ContentProvider,這個唯一值在AndroidManifest.xml裡定義了 //若是沒有定義MyFileProvider,可直接使用FileProvider替代 String authority = context.getPackageName() + ".fileExplorerProvider"; uri = FileProvider.getUriForFile(context,authority, file); } else { uri = Uri.fromFile(file); } //content://com.yc.lifehelper.fileExplorerProvider/external_path/fileShare.txt //content 作為scheme; //com.yc.lifehelper.fileExplorerProvider 即為我們定義的 authorities,作為host; LogUtils.d("share file uri : " + uri); String encodedPath = uri.getEncodedPath(); //external_path/fileShare.txt //如此構造後,第三方應用收到此Uri後,並不能從路徑看出我們傳遞的真實路徑,這就解決了第一個問題: //傳送方傳遞的檔案路徑接收方完全知曉,一目瞭然,沒有安全保障。 LogUtils.d("share file uri encode path : " + encodedPath); share.putExtra(Intent.EXTRA_STREAM, uri); share.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); //賦予讀寫許可權 share.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); Intent intent = Intent.createChooser(share, "分享檔案"); //交由系統處理 context.startActivity(intent); isShareSuccess = true; } else { isShareSuccess = false; } } catch (Exception e) { e.printStackTrace(); isShareSuccess = false; } return isShareSuccess; }
- 如何解決第二個問題,傳送方傳遞的檔案路徑接收方可能沒有讀取許可權,導致接收異常?通過FileProvider.getUriForFile為入口檢視原始碼,應用間通過IPC機制,最後呼叫了openFile()方法,而FileProvider重寫了該方法。
4.9 跨程式IPC通訊
A應用(該demo)通過構造Uri,通過intent呼叫B(分享到QQ)
- 應用A將path構造為Uri:應用A在啟動的時候,會掃描AndroidManifest.xml 裡的 FileProvider,並讀取對映表構造為一個Map。
- 還是以/storage/emulated/0/com.yc.lifehelper.fileExplorerProvider/external_path/fileShare.txt 為例,當呼叫 FileProvider.getUriForFile(xx)時,遍歷Map,找到最匹配條目,最匹配的即為external_file。因此會用external_file 代替原始路徑,最終形成的Uri為:content://com.yc.lifehelper.fileExplorerProvider/external_path/fileShare.txt
B應用(QQ)通過Uri構造輸入流,將Uri解析成具體的路徑
- 應用B通過Uri(A傳遞過來的),解析成具體的file檔案。先將Uri分離出external_file/fileShare.txt,然後通過external_file 從Map裡找到對應Value 為:/storage/emulated/0/com.yc.lifehelper.fileExplorerProvider/,最後將fileShare.txt拼接,形成的路徑為:/storage/emulated/0/com.yc.lifehelper.fileExplorerProvider/external_path/fileShare.txt
現在來梳理整個流程:
- 1、應用A使用FileProvider通過Map(對映表)將Path轉為Uri,通過IPC 傳遞給應用B。
- 2、應用B使用Uri通過IPC獲取應用A的FileProvider。
- 3、應用A使用FileProvider通過對映表將Uri轉為Path,並構造出檔案描述符。
- 4、應用A將檔案描述符返回給應用B,應用B就可以讀取應用A傳送的檔案了。
整個互動流程圖如下
05.其他設計實踐說明
5.1 效能設計
- 這個暫無,因為是小工具,主要是在debug環境下依賴使用。程式碼邏輯並不複雜,不會影響App的效能。
5.2 穩定性設計
修改檔案說明
- 目前,針對文字檔案,比如快取的json資料,儲存在文字檔案中,之前測試說讓該工具支援修改屬性,考慮到修改json比較複雜,因此這裡只是實現可以刪除文字檔案,或者修改檔名稱的功能。
- 針對圖片檔案,可以開啟且進行了圖片壓縮,僅僅支援刪除圖片檔案操作。
- 針對sp儲存的資料,是xml,這裡視覺化展示sp的資料,目前可以支援修改sp資料,測試童鞋這方便操作簡單,提高某些場景的測試效率。
為何不支援修改json
- 讀取文字檔案,是一行行讀取,修改資料編輯資料麻煩,而且修改完成後對json資料合法性判斷也比較難處理。因此這裡暫時不提供修改快取的json資料,測試如果要看,可以通過分享到外部qq檢視檔案,或者直接檢視,避免髒資料。
5.3 debug依賴設計
建議在debug下使用
在小工具放到debug包名下,依賴使用。或者在gradle依賴的時候區分也可以。如下所示:
//在app包下依賴 apply from: rootProject.file('buildScript/fileExplorer.gradle') /** * 沙盒file工具配置指令碼 */ println('gradle file explorer , init start') if (!isNeedUseExplorer()) { println('gradle file explorer , not need file explorer') return } println('gradle file isNeedUseExplorer = ture') dependencies { // 依賴 implementation('com.github.jacoco:runtime:0.0.23-SNAPSHOT') } //過濾,只在debug下使用 def isNeedUseJacoco() { Map<String, String> map = System.getenv() if (map == null) { return false } //拿到編譯後的 BUILD_TYPE 和 CONFIG。具體看 BuildConfig 生成類的程式碼 boolean hasBuildType = map.containsKey("BUILD_TYPE") boolean hasConfig = map.containsKey("CONFIG") println 'gradle file explorer isNeedUseExplorer hasBuildType =====>' + hasBuildType + ',hasConfig = ' + hasConfig String buildType = "debug" String config = "debug" if (hasBuildType) { buildType = map.get("BUILD_TYPE") } if (hasConfig) { config = map.get("CONFIG") } println 'gradle file explorer isNeedUseExplorer buildType =====>' + buildType + ',config = ' + config if (buildType.toLowerCase() == "debug" && config.toLowerCase() == "debug" && isNotUserFile()) { println('gradle file explorer debug used') return true } println('gradle file explorer not use') //如果是正式包,則不使用沙盒file工具 return false } static def isNotUserFile() { //在debug下預設沙盒file工具,如果你在debug下不想使用沙盒file工具,則設定成false return true }