App磁碟沙盒工具實踐

楊充發表於2022-03-03

目錄介紹

  • 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資料夾)跳轉詳情。
  • 一鍵接入該工具

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頁面,否則跳轉到檔案詳情頁面
  • 處理任務棧返回邏輯。舉個例子現在列表FileExplorerFragment當作B,檔案詳情頁面當作C,宿主Activity當作A。也就是說,點選返回鍵,依次關閉了fragment直到沒有,回到宿主activity頁面。再次點選返回鍵,則關閉activity!

    • 可能存在的任務棧是:開啟A1->開啟B1->開啟C1
    • 那麼點選返回鍵按鈕,返回關閉的順序則是:關閉C1->關閉B1->關閉A1
  • Fragment回退棧處理方式

    • 第一種方案:建立一個棧(先進後出),開啟一個FileExplorerFragment列表頁面(push一個fragment物件到佇列中),關閉一個列表頁面(remove最上面那個fragment物件,然後呼叫FragmentManagerpopBackStack操作關閉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傳送的檔案了。
  • 整個互動流程圖如下

    • image

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
      }

demo地址:https://github.com/yangchong2...

相關文章