この記事はCrowdWorksアドベントカレンダーの5日目の記事です。
#CrowdWorksのエンジニアが毎日なにかを書いています。
まえおき
クラウドワークスでAndroidアプリやAPIの開発をやっています @YusukeIwaki です。
前職でAndroid OSのカスタマイズをいろいろやっていたので、いまだにAndroidのソースを眺めて「この設計いいな」と思っては、それをマネしてアプリをつくるスタイルでやっています。
今回は、Androidのファイルのダウンロード機能の根幹を担う、DownloadProviderのイケてる設計のことを書きます。
DownloadProvider自体は、5年以上前からあるし今更感はあるとはおもうのですが、クラウドワークスの公式Androidアプリは、DownloadProviderの設計を大いに参考にしているので、CrowdWorksアドベントカレンダーネタにしました。
DownloadProiderの本題に入る前に・・・
例題:これどうやって実現しますか?
もしも ?
の実装のなかにAsyncTaskという単語が出てくる人がいたら、この記事ぜひ最後まで読んで下さい。
- API通信中に画面回転しても二重でAPIが呼ばれたりしない?
- API通信中に前の画面に戻っても正常に動く?
- API通信中にアプリのタスクを殺されたらどうなる?
というAsyncTask使うと苦しむポイントが、DownloadProviderはイケてる設計で華麗にクリアしています。
DownloadProviderの全体像
すごくざっくりとした絵で表現をすると、
DownloadManager = ダウンロードのアクションを要求するアプリ向けのAPI層
DownloadProvider = ダウンロードのキューや進捗状況を管理するデータストア
DownloadService = データストアの変更や通信状態(3G/Wifi/圏外)を監視して、必要に応じてファイルダウンロードを開始/中断/再開するサービス
DownloadThread = HTTPクライアントでファイルダウンロードを行い。ダウンロード進捗状況をデータストアにアップデートする
DocumentsUi, DownloadStorageProvider = データストアの内容(キューや進捗状況など)を、そのままビューに写像する
といった役割分担で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
ContentResolver経由でinsertされてきたものをそのままデータベースにinsertして、ついでに
- DBの変化通知を飛ばす
- DownloadServiceをキック(keep-alive)する
ということをやっています。
変化通知をとばすのは、ビュー側にデータを再読込みしてもらうため。
DownloadServiceをキックするのは、もしかしたら死んでいるかもしれないDownloadServiceを確実に生きている状態にして、insertしたダウンロード要求を正しくさばいてもらうようにするためです。
#ここには書きませんが、updateやdeleteも似たようなロジックになっています。
Androidのサービスって裏で生き続ける保証はまったくないので、insert/update/deleteするたびにkeep-aliveというのはすごく理にかなった設計ですね。
DownloadService
DownloadProviderのデータベースに書かれた内容と通信状態を監視して、ダウンロードが必要なものがあれば、DownloadThreadを用意してダウンロードを開始/再開あるいは、DownloadThreadの停止をおこないます。
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を一意に写像しているだけです。
たとえば、ステータス通知パネルのダウンロード進捗状況をタップしたときに出る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を監視してビューに反映するようなロジックを追加するだけでよいのです。
くどいようですが、どこでどんなUIを出すにしても、通信状態を一切気にせず、DBの内容をただただ反映するだけでよいのです!
最後に・・・
あえて冒頭で書いたことをもう一度書きます。
これどうやって実現しますか?
もうAsyncTaskを使おうだなんて思いませんね!
- DBを用意してMessageテーブルをつくり
- メッセージの吹き出しはMessageテーブルを一意に写像するだけ、にしておいて
- 送信ボタンを押したときには、「body=むりぽ, status=API通信待ち」というレコードをinsertするだけ
- 裏で動くサービスを1つ作って、query(status=API通信待ち) に引っかかったものを順にAPI通信していき、成功したら「status=成功」にアップデート、失敗したら「status=失敗」にアップデート
みたいなものを作ろうかな〜 という気になりましたね?
クラウドワークスの公式Androidアプリでは実際にこのアーキテクチャを採用していて、
「送信失敗したメッセージを再送できるようにする」といった状態変化の考慮がめんどくさそうなロジックも
- 「status=送信失敗」のレコードは、タップすると再送確認のダイアログがでるようにする
- 再送確認ダイアログで「再送」が選択されたら、そのレコードを「status=API通信待ち」にアップデートする
という至極単純なロジックで実現できています。
#具体的な実装が気になる方は、Realmアドベントカレンダー書くついでに作ったサンプルプログラムを是非ご覧ください。
ということで
この記事はCrowdWorks Advent Calendar 2016 の5日目の記事でした。
明日は @takeru0757 さんが何か書きます。