Android原生下載機制針對0K大小文件下載異常的分析 [復制鏈接]

2019-6-4 09:43
kengsirLi 閱讀:171 評論:0 贊:0
Tag:  
最近在進行一次測試用例中,發現測試手機在利用本地下載功能下載0K大小的文件時,進度條一直處于進度模糊狀態中,雖然查看本地存儲路徑,發現文件已經存在,但是頁面上并沒有提示下載成功,此時只能對下載執行暫停或刪除操作。最初只是懷疑是自身應用的問題,但是在試了自己的華為暢享5s(Android5.1)、聯想S560(Android.4.2)(暴露貧窮了)及朋友的ZTE小鮮(Android6.0)、華為P9(Android7.0)等三款不同廠商的設備后,發現都有相同的現象,所以懷疑這是android自身的一個待優化點(說bug有點嚴重了,畢竟0K大小的文件誰會經常遇到呢)。在此基礎上,對AOSP的DownloadProvider進行了一番研究,源碼資源大家可以訪問http://androidxref.com/,最新的Android Oreo源碼也可以在上面查閱。

下面的調查以Android7.0和Android8.0的DownloadProvider源碼為基礎展開。將探討兩個問題:

(1)進度條樣式為什么是進度模糊樣式?

(2)0K文件是否真正意義上下載成功了?

Android的本地下載分為三部分:
1、frameworks/base/core/java/android/app/下的DownloadManager.java和frameworks/base/core/java/android/provider/下的Downloads.java

2、packages/apps/providers/下的DownloadProvider

3、frameworks/base/packages/下的DocumentsUI

其中,DocumentsUI就是我們常見的下載列表。而DownloadManager.java就是開放給開發者調用的下載器,也是系統自身使用的下載器,Downloads.java負責表路徑和下載狀態標記的管理,DownloadProvider負責下載過程中數據的處理和展示。鑒于ANDROID下載機制中,默認下載進度是以通知的形式展示的,所以我通過檢索Notification,發現只在DownloadProvider中出現了調用,那么問題的根源可能就存在在DownloadProvider中。

DownloadProvider類目錄結構如下:

可以發現一個很明顯的DownloadNotifier.java,分析其代碼,DownloadNotifier在構造函數中創建了一個NotificationManager對象,代碼詳情如下:

DownloadNotifier的構造函數(Android8.0)
public DownloadNotifier(Context context) {
       mContext = context;
       mNotifManager = context.getSystemService(NotificationManager.class);
 
       // Ensure that all our channels are ready to use
        mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_ACTIVE,
                context.getText(R.string.download_running),
                NotificationManager.IMPORTANCE_LOW));
        mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_WAITING,
                context.getText(R.string.download_queued),
                NotificationManager.IMPORTANCE_DEFAULT));
        mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_COMPLETE,
                context.getText(com.android.internal.R.string.done_label),
                NotificationManager.IMPORTANCE_DEFAULT));
}
 
DownloadNotifier的構造函數(Android8.0以下)
public DownloadNotifier(Context context) {
       mContext = context;
       mNotifManager = (NotificationManager) context.getSystemService(
               Context.NOTIFICATION_SERVICE);
}
更新下載通知的操作由update()(低版本中叫updateWith())來實現,該方法將被具體的下載線程進行調用,而通知樣式圍繞下載狀態標記的具體實現則是updateWithLocked()內部的一個私有方法updateWithLocked(Cursor cursor)(這個方法名一直沒有變),代碼詳情如下:

Android7.0及以上
public void update() {
       try (Cursor cursor = mContext.getContentResolver().query(
               Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, UpdateQuery.PROJECTION,
               Downloads.Impl.COLUMN_DELETED + " == '0'", null, null)) {
           synchronized (mActiveNotifs) {
               updateWithLocked(cursor);
           }
       }
}
Android7.0以下
public void updateWith(Collection<DownloadInfo> downloads) {
       synchronized (mActiveNotifs) {
           updateWithLocked(downloads);
       }
}

在解析updateWithLocked()方法前,先介紹下DownloadProvider是如何調用DownloadNotifier.update()方法的。DownloadProvider下載運行圖如下:


下載記錄是統一保存在Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI指向的表中,當用戶執行下載操作時,系統通過URI調起DownloadProvider.java,該類負責統一管理DownloadProvider下各類的使用,首先初始化待訪問的數據集合DownloadInfo,啟動DownloadJobService管理本次下載,DownloadJobService會把DownloadProvider提供的DownloadInfo交給DownloadThread完成更新和操作,同時具體的數據讀寫也在DownloadThread中完成。DownloadJobService會先注冊一個數據監聽器:

public void onCreate() {
        super.onCreate();
        getContentResolver().registerContentObserver(ALL_DOWNLOADS_CONTENT_URI, true, mObserver);
}
private ContentObserver mObserver = new ContentObserver(Helpers.getAsyncHandler()) {
       @Override
        public void onChange(boolean selfChange) {
              Helpers.getDownloadNotifier(DownloadJobService.this).update();
       }
 };

監聽下載過程中數據庫的變化,及時調用DownloadNotifier.update()更新進度條。等到下載結束時,DownloadThread會再一次調用DownloadNotifier.update()。這便是下載中通知變化的流程。

下面來分析下updateWithLocked()中對通知更新的實現流程,總體上可以分成三步走:

1、不管入參是Cursor還是ArrayList<DoenloadInfo>,都是通過訪問Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI(content://downloads/all_downloads)獲得當前下載記錄。針對每條數據,根據status和visibility字段生成一個標記,作為key,一并保存到ArrayMap<String, IntArray> clustered中。

   標記整體分為兩部分:下載狀態;當前調用下載的應用包名/下載記錄的ID

    標記生成方法如下:buildNotificationTag(Cursor cursor)

private static String buildNotificationTag(Cursor cursor) {
       final long id = cursor.getLong(UpdateQuery._ID);
       final int status = cursor.getInt(UpdateQuery.STATUS);
       final int visibility = cursor.getInt(UpdateQuery.VISIBILITY);
       final String notifPackage = cursor.getString(UpdateQuery.NOTIFICATION_PACKAGE);
 
       if (isQueuedAndVisible(status, visibility)) {
           return TYPE_WAITING + ":" + notifPackage;
       } else if (isActiveAndVisible(status, visibility)) {
           return TYPE_ACTIVE + ":" + notifPackage;
       } else if (isCompleteAndVisible(status, visibility)) {
           // Complete downloads always have unique notifs
           return TYPE_COMPLETE + ":" + id;
       } else {
           return null;
       }
   }
2、對clustered中每一條下載記錄創建一條對應的通知對象。此時用到了一個全局變量mActiveNotifs,存放當前活躍的通知。

    private final ArrayMap<String, Long> mActiveNotifs = new ArrayMap<>();

根據clustered中提供的標記通過getNotificationTagType方法提取中下載類型type,實際上通過getNotificationTagType的實現代碼,type就是步驟1中提到的下載狀態,getNotificationTagType實現代碼如下:

private static int getNotificationTagType(String tag) {
       return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
   }
根據type的不同,通知將配置不同的文字、圖片等樣式,

3、刪除未更新的過期通知。即只展示當前正在下載的通知。

關鍵看步驟2中當前進度的計算這段邏輯,代碼如下:

 // Calculate and show progress
   String remainingText = null;
   String percentText = null;
   if (type == TYPE_ACTIVE) {
       long current = 0;
       long total = 0;
       long speed = 0;
       synchronized (mDownloadSpeed) {
           for (int j = 0; j < cluster.size(); j++) {
               cursor.moveToPosition(cluster.get(j));
 
               final long id = cursor.getLong(UpdateQuery._ID);
               final long currentBytes = cursor.getLong(UpdateQuery.CURRENT_BYTES);
               final long totalBytes = cursor.getLong(UpdateQuery.TOTAL_BYTES);
 
               if (totalBytes != -1) {
                   current += currentBytes;
                   total += totalBytes;
                   speed += mDownloadSpeed.get(id);
               }
           }
       }
       if (total > 0) {
           percentText =
                   NumberFormat.getPercentInstance().format((double) current / total);
 
           if (speed > 0) {
               final long remainingMillis = ((total - current) * 1000) / speed;
               remainingText = res.getString(R.string.download_remaining,
                       DateUtils.formatDuration(remainingMillis));
           }
 
           final int percent = (int) ((current * 100) / total);
           builder.setProgress(100, percent, false);
       } else {
           builder.setProgress(100, 0, true);
       }
   }
   mNotifManager.notify(tag, 0, notif);

進度值的計算是在狀態標記為TYPE_ACTIVE時才會觸發。那么什么時候進入TYPE_ACTIVE狀態呢,Downloads提供了詳細的下載狀態,但是DownloadManager可以更清晰的告訴我們。DownloadManager在Downloads基礎上轉化成五種下載狀態:

  DownloadManager.STATUS_PENDING

  DownloadManager.STATUS_RUNNING

  DownloadManager.STATUS_PAUSED

  DownloadManager.STATUS_SUCCESSFUL

  DownloadManager.STATUS_FAILED

任何一次下載都要經過 STATUS_PENDING->STATUS_RUNNING->STATUS_SUCCESSFUL/STATUS_FAILED 這樣一個過程。重看buildNotificationTag(),你會發現TYPE_ACTIVE是通過isActiveAndVisible()生成的,代碼如下:

private static boolean isActiveAndVisible(int status, int visibility) {
     return status == STATUS_RUNNING &&
             (visibility == VISIBILITY_VISIBLE
             || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
 }

STATUS_RUNNING表示當前下載鏈接已經建立成功,開始獲取文件大小,并執行下載。換句話說,STATUS_RUNNING階段不會因為文件大小是0K,就直接從STATUS_PENDING執行到STATUS_SUCCESSFUL/STATUS_FAILED,這是一個必要的過程。所以0K大小的文件下載時也會執行進度值計算。進度百分比percent分別通過字段total_bytes和current_bytes提供,total_bytes默認值為-1,只有當成功獲取到文件大小時才會被賦值。正常下載過程中,進度條設置為builder.setProgress(100, percent, false);當下載狀態為TYPE_ACTIVE且下載文件大小為0時,進度條設置為builder.setProgress(100, 0, true);這個true表示當前進度條為非明確樣式,即表示進度的加載條充滿進度槽并持續滾動,這樣就導致了下載0K文件時進度條樣式的不統一。

進度條樣式的問題到這里就可以告一段落了,關于0K文件是否下載成功,我們可以回看DownloadThread。DownloadThread中讀寫操作是有transferData(InputStream in, OutputStream out, FileDescriptor outFd)來實現。這里有一個入參FileDescriptor。顧名思義它會先創建一個待寫入的目標文件。transferData()中的讀寫操作如下:

private void transferData(InputStream in, OutputStream out, FileDescriptor outFd)throws StopRequestException {
      final byte buffer[] = new byte[Constants.BUFFER_SIZE];
      while (true) {
        if (mPolicyDirty) checkConnectivity();
 
        if (mShutdownRequested) {
            throw new StopRequestException(STATUS_HTTP_DATA_ERROR,
                    "Local halt requested; job probably timed out");
        }
 
        int len = -1;
        try {
           len = in.read(buffer);
        } catch (IOException e) {
           throw new StopRequestException(
                  STATUS_HTTP_DATA_ERROR, "Failed reading response: " + e, e);
         }
 
         if (len == -1) {
          break;
         }
         try {
            // When streaming, ensure space before each write
            if (mInfoDelta.mTotalBytes == -1) {
                final long curSize = Os.fstat(outFd).st_size;
                final long newBytes = (mInfoDelta.mCurrentBytes + len) - curSize;
 
                StorageUtils.ensureAvailableSpace(mContext, outFd, newBytes);
            }
 
            out.write(buffer, 0, len);
 
            mMadeProgress = true;
            mInfoDelta.mCurrentBytes += len;
 
            updateProgress(outFd);
 
        } catch (ErrnoException e) {
            throw new StopRequestException(STATUS_FILE_ERROR, e);
        } catch (IOException e) {
            throw new StopRequestException(STATUS_FILE_ERROR, e);
        }
     }
        ...
}

由于是一個0K文件,in.read(buffer)實際上是讀不到數據的,所以len依然是-1,這就導致while循環不能完整執行,后續的寫入操作、標志更新操作等都沒有被執行,由于又沒有異常產生,通知頁面便始終處于下載中狀態,直到超時。所以我在本地存儲路徑下看到的文件,實際上是沒有執行寫入操作的,嚴格意義上并沒有下載成功。

這就是關于0K大小文件的下載異常分析,雖然在平時的文件下載需求中不大會出現0K這種現象,但不代表著永遠不會遇到,也許你下載的文件的是破損壓縮文件、上傳失敗的壓縮文件或者是標記文件,這種場景下還是要考慮進來的,畢竟存在即為合理。對于0K文件直接本地創建即可,無需執行讀寫操作。


分享到:
我來說兩句
facelist
您需要登錄后才可以評論 登錄 | 立即注冊
所有評論(0)

領先的中文移動開發者社區
18620764416
7*24全天服務
意見反饋:[email protected]

掃一掃關注我們

Powered by Discuz! X3.2© 2001-2019 Comsenz Inc.( 粵ICP備15117877號 )

两码中特期期