いまこそAndroidのDownloadProviderのソースを読もう

この記事は[CrowdWorksアドベントカレンダー](http://qiita.com/advent-calendar/2016/crowdworks)の５日目の記事です。
＃[CrowdWorksのエンジニア](https://crowdworks.co.jp/recruit/engineer)が毎日なにかを書いています。


## まえおき

クラウドワークスでAndroidアプリやAPIの開発をやっています [@YusukeIwaki](https://github.com/YusukeIwaki) です。
前職でAndroid OSのカスタマイズをいろいろやっていたので、いまだにAndroidのソースを眺めて「この設計いいな」と思っては、それをマネしてアプリをつくるスタイルでやっています。

今回は、Androidのファイルのダウンロード機能の根幹を担う、DownloadProviderのイケてる設計のことを書きます。
DownloadProvider自体は、５年以上前からあるし今更感はあるとはおもうのですが、クラウドワークスの公式Androidアプリは、DownloadProviderの設計を大いに参考にしているので、CrowdWorksアドベントカレンダーネタにしました。


　
## DownloadProiderの本題に入る前に・・・

### 例題：これどうやって実現しますか？

![47b98f34-4471-7b00-304c-6453e6b6d667.png](https://qiita-image-store.s3.amazonaws.com/0/74571/404e1f9e-18f0-d3da-d79d-0ee2c88dff0d.png)
　

もしも `？` の実装のなかにAsyncTaskという単語が出てくる人がいたら、この記事ぜひ最後まで読んで下さい。

* API通信中に画面回転しても二重でAPIが呼ばれたりしない？
* API通信中に前の画面に戻っても正常に動く？
* API通信中にアプリのタスクを殺されたらどうなる？

というAsyncTask使うと苦しむポイントが、DownloadProviderはイケてる設計で華麗にクリアしています。

　
　
## DownloadProviderの全体像

すごくざっくりとした絵で表現をすると、

![fc3a2637-47cf-28cd-5e33-b0a46db992a9.png](https://qiita-image-store.s3.amazonaws.com/0/74571/5e19d82d-b952-cf19-97d7-97b6d2b12c17.png)


[**DownloadManager**](https://android.googlesource.com/platform/frameworks/base/+/android-6.0.1_r74/core/java/android/app/DownloadManager.java) = ダウンロードのアクションを要求するアプリ向けのAPI層
[**DownloadProvider**](https://github.com/android/platform_packages_providers_downloadprovider/blob/marshmallow-mr3-release/src/com/android/providers/downloads/DownloadProvider.java) = ダウンロードのキューや進捗状況を管理するデータストア
[**DownloadService**](https://github.com/android/platform_packages_providers_downloadprovider/blob/marshmallow-mr3-release/src/com/android/providers/downloads/DownloadService.java) = データストアの変更や通信状態（3G/Wifi/圏外）を監視して、必要に応じてファイルダウンロードを開始/中断/再開するサービス
[**DownloadThread**](https://github.com/android/platform_packages_providers_downloadprovider/blob/marshmallow-mr3-release/src/com/android/providers/downloads/DownloadThread.java) = HTTPクライアントでファイルダウンロードを行い。ダウンロード進捗状況をデータストアにアップデートする
[**DocumentsUi**](https://github.com/android/platform_frameworks_base/blob/marshmallow-mr3-release/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java), [**DownloadStorageProvider**](https://github.com/android/platform_packages_providers_downloadprovider/blob/marshmallow-mr3-release/src/com/android/providers/downloads/DownloadStorageProvider.java) = データストアの内容（キューや進捗状況など）を、そのままビューに写像する

といった役割分担でAndroidのファイルダウンロード処理が成り立っています。

　
ポイントとしては、**ビューは通信側の状態を一切考慮していないし、通信を行うパーミッションすらもっていない**ということです。**ただただデータストアに書かれた内容をリアクティブに反映しているだけ**なのです。
なので、画面が回転しようといきなりタスクを殺されようと、ファイルダウンロード処理に影響が出ることがありません。

これが「イケてる設計」の概要です。

　
ここからは個々のコンポーネントをもう少し詳しく見ていきます。「もうイケてる設計はわかった・・・」という方は、飛ばして一気に最後までいっちゃって下さい。


## DownloadManager

アプリが「このファイルをダウンロードして〜」って要求するためのAPI層です。
`request()` という関数ですが、中身をみると、単純にContentResolverを通じて、DownloadProviderのデータベースにリクエストをinsertしているだけなんです。

```
    /**
     * Enqueue a new download.  The download will start automatically once the download manager is
     * ready to execute it and connectivity is available.
     *
     * @param request the parameters specifying this download
     * @return an ID for the download, unique across the system.  This ID is used to make future
     * calls related to this download.
     */
    public long enqueue(Request request) {
        ContentValues values = request.toContentValues(mPackageName);
        Uri downloadUri = mResolver.insert(Downloads.Impl.CONTENT_URI, values);
        long id = Long.parseLong(downloadUri.getLastPathSegment());
        return id;
    }
```

Requestの中身は？というと、

```
    /**
     * This class contains all the information necessary to request a new download. The URI is the
     * only required parameter.
     *
     * Note that the default download destination is a shared volume where the system might delete
     * your file if it needs to reclaim space for system use. If this is a problem, use a location
     * on external storage (see {@link #setDestinationUri(Uri)}.
     */
    public static class Request {

      　　（中略）

        private Uri mUri;
        private Uri mDestinationUri;
        private List<Pair<String, String>> mRequestHeaders = new ArrayList<Pair<String, String>>();
        private CharSequence mTitle;
        private CharSequence mDescription;
        private String mMimeType;
        private int mAllowedNetworkTypes = ~0; // default to all network types allowed
        private boolean mRoamingAllowed = true;
        private boolean mMeteredAllowed = true;
        private boolean mIsVisibleInDownloadsUi = true;
        private boolean mScannable = false;
        private boolean mUseSystemCache = false;
```

* ファイルダウンロードのURL
* ダウンロード中に通知領域に表示させたいタイトルなど
* モバイルネットワークでも（Wifi接続されてなくても）ダウンロードするかどうか
* HTTP通信の際に使うカスタムヘッダ（User-Agentとか認証ヘッダとか）

などなどです。


```
$ adb shell
 @android:/# su 1000
 @android:/$ content query --uri content://downloads/all_downloads
```

エミュレータだったら、↑のコマンドを打つことで、実際にデータベースに書かれたRequestの中身が見えます。
　
## DownloadProvider

![b64f6eab-7f07-4d98-0563-4546c851b21a.png](https://qiita-image-store.s3.amazonaws.com/0/74571/a9e09f64-02ab-d7ab-42a5-bb22b3842002.png)


ContentResolver経由でinsertされてきたものをそのままデータベースにinsertして、**ついでに**

* **DBの変化通知を飛ばす**
* **DownloadServiceをキック（keep-alive）する**

ということをやっています。

![ef7209c2-d8c2-dd72-2155-0d0557d10e3e.png](https://qiita-image-store.s3.amazonaws.com/0/74571/b5965072-bf96-35f9-78e4-40f60084710c.png)

変化通知をとばすのは、ビュー側にデータを再読込みしてもらうため。
DownloadServiceをキックするのは、もしかしたら死んでいるかもしれないDownloadServiceを確実に生きている状態にして、insertしたダウンロード要求を正しくさばいてもらうようにするためです。

＃ここには書きませんが、updateやdeleteも似たようなロジックになっています。

Androidのサービスって裏で生き続ける保証はまったくないので、insert/update/deleteするたびにkeep-aliveというのはすごく理にかなった設計ですね。

　
## DownloadService

DownloadProviderのデータベースに書かれた内容と通信状態を監視して、ダウンロードが必要なものがあれば、DownloadThreadを用意してダウンロードを開始/再開あるいは、DownloadThreadの停止をおこないます。

![900d1990-2e9a-3268-8012-6652a85cc1df.png](https://qiita-image-store.s3.amazonaws.com/0/74571/1def88dd-58ab-ae63-e00d-a69a9407dc60.png)


DownloadServiceは先に書いたとおり、生き続ける保証はまったくないので、いつ死んでいつ生き返ってもいいように、

* 内部状態はすべてDBに書く
* DBに書かれた内容によって一意に動く

ような作りになっています。

たとえば、DownloadThreadを用意してダウンロードを開始したRequestは、ステータスをRUNNNINGにして保存し、二重リクエストを起こさないようになっていますね。

## DownloadThread

DownloadServiceの子分として、実際にHTTPクライアントで通信をおこないます。
このDownloadThreadもまた、DownloadServiceがいきなり死んだり生き返ったりしても困らないよう、8KBダウンロードしてはDBに進捗をアップデートし・・・、またさらに8KBダウンロードしてはDBに進捗をアップデートし・・・、といった動作をします。

```
    /**
     * Transfer as much data as possible from the HTTP response to the
     * destination file.
     */
    private void transferData(InputStream in, OutputStream out, FileDescriptor outFd)
            throws StopRequestException {
        final byte buffer[] = new byte[Constants.BUFFER_SIZE];
        while (true) {
            checkPausedOrCanceled();

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

------------

    /**
     * Local changes to {@link DownloadInfo}. These are kept local to avoid
     * racing with the thread that updates based on change notifications.
     */
    private class DownloadInfoDelta {

        private ContentValues buildContentValues() {
            final ContentValues values = new ContentValues();

            values.put(Downloads.Impl.COLUMN_URI, mUri);
            values.put(Downloads.Impl._DATA, mFileName);
            values.put(Downloads.Impl.COLUMN_MIME_TYPE, mMimeType);
            values.put(Downloads.Impl.COLUMN_STATUS, mStatus);
            values.put(Downloads.Impl.COLUMN_FAILED_CONNECTIONS, mNumFailed);
            values.put(Constants.RETRY_AFTER_X_REDIRECT_COUNT, mRetryAfter);
            values.put(Downloads.Impl.COLUMN_TOTAL_BYTES, mTotalBytes);
            values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, mCurrentBytes);
            values.put(Constants.ETAG, mETag);

            values.put(Downloads.Impl.COLUMN_LAST_MODIFICATION, mSystemFacade.currentTimeMillis());
            values.put(Downloads.Impl.COLUMN_ERROR_MSG, mErrorMsg);

            return values;
        }

        /**
         * Blindly push update of current delta values to provider.
         */
        public void writeToDatabase() {
            mContext.getContentResolver().update(mInfo.getAllDownloadsUri(), buildContentValues(),
                    null, null);
        }
```

一見すごく効率の悪いことをやっているように見えますが、こうすることで、「ダウンロード中にいきなり圏外になって、その後ダウンロードを再開をする」といったケースでも、○○バイト目からダウンロードを再開！ とETagヘッダ付きのリクエストを出せるので、通信料を無駄にしないで済むのです。


　
## DocumentsUi、DownloadStorageProvider

ビュー側ですが、最初にかいたように、ただただDownloadProviderのDBを一意に写像しているだけです。

![6966b57e-28ed-723d-4bfa-183f96895cef.png](https://qiita-image-store.s3.amazonaws.com/0/74571/be0ddb59-1a78-900c-b0cb-d3ae99f5f3f1.png)


たとえば、ステータス通知パネルのダウンロード進捗状況をタップしたときに出るAndroid標準のコンポーネント（DocumentsUi）は

```
    public void bindView(View convertView, int position) {
        if (!(convertView instanceof DownloadItem)) {
            return;
        }

        long downloadId = mCursor.getLong(mIdColumnId);
        ((DownloadItem) convertView).setData(downloadId, position,
                mCursor.getString(mFileNameColumnId),
                mCursor.getString(mMediaTypeColumnId));

        // Retrieve the icon for this download
        retrieveAndSetIcon(convertView);

        String title = mCursor.getString(mTitleColumnId);
        if (title.isEmpty()) {
            title = mResources.getString(R.string.missing_title);
        }
        setTextForView(convertView, R.id.download_title, title);
        setTextForView(convertView, R.id.domain, mCursor.getString(mDescriptionColumnId));
        setTextForView(convertView, R.id.size_text, getSizeText());

        final int status = mCursor.getInt(mStatusColumnId);
        final CharSequence statusText;
        if (status == DownloadManager.STATUS_SUCCESSFUL) {
            statusText = getDateString();
        } else {
            statusText = mResources.getString(getStatusStringId(status));
        }
        setTextForView(convertView, R.id.status_text, statusText);

        ((DownloadItem) convertView).getCheckBox()
                .setChecked(mDownloadList.isDownloadSelected(downloadId));
    }
```

(※ 説明のため、ここだけAndroid 4.2時代のソースを載せています。最近のは複雑なので・・・^_^;;)

こんな感じです。


ダウンロードを要求したアプリ側で

* ファイルのダウンロード中はくるくるを表示したい
* ダウンロードが完了したらチェックマークをつけたい

のようなUIにしたければ、同様にDownloadProviderのDBを監視してビューに反映するようなロジックを追加するだけでよいのです。

![5c39235d-0eed-f429-678c-93822f48ceba.png](https://qiita-image-store.s3.amazonaws.com/0/74571/1202bc27-0ecc-d531-5994-41283da6aacf.png)

くどいようですが、どこでどんなUIを出すにしても、通信状態を一切気にせず、DBの内容をただただ反映するだけでよいのです！
　
　


　
## 最後に・・・

あえて冒頭で書いたことをもう一度書きます。

### これどうやって実現しますか？

![47b98f34-4471-7b00-304c-6453e6b6d667.png](https://qiita-image-store.s3.amazonaws.com/0/74571/404e1f9e-18f0-d3da-d79d-0ee2c88dff0d.png)

もうAsyncTaskを使おうだなんて思いませんね！

　

* DBを用意してMessageテーブルをつくり
* メッセージの吹き出しはMessageテーブルを一意に写像するだけ、にしておいて
* 送信ボタンを押したときには、「body=むりぽ, status=API通信待ち」というレコードをinsertするだけ
* 裏で動くサービスを１つ作って、query(status=API通信待ち) に引っかかったものを順にAPI通信していき、成功したら「status=成功」にアップデート、失敗したら「status=失敗」にアップデート

みたいなものを作ろうかな〜　という気になりましたね？


　
クラウドワークスの公式Androidアプリでは実際にこのアーキテクチャを採用していて、
![d53057a6-2249-71c3-be0e-d5b1b5b2ec7a.gif](https://qiita-image-store.s3.amazonaws.com/0/74571/0a023ce8-ed5a-1118-f563-bfcaf55aa999.gif)


「送信失敗したメッセージを再送できるようにする」といった状態変化の考慮がめんどくさそうなロジックも

* 「status=送信失敗」のレコードは、タップすると再送確認のダイアログがでるようにする
* 再送確認ダイアログで「再送」が選択されたら、そのレコードを「status=API通信待ち」にアップデートする

という至極単純なロジックで実現できています。

＃具体的な実装が気になる方は、[Realmアドベントカレンダー](http://qiita.com/YusukeIwaki/items/d7f17870ba23b059c946)書くついでに作った[サンプルプログラム](https://github.com/YusukeIwaki/RealmDebugSample)を是非ご覧ください。

## ということで

この記事はCrowdWorks Advent Calendar 2016 の５日目の記事でした。
明日は @takeru0757 さんが何か書きます。