[Android] Mobile Hubの生成コードを読む [コンテンツ管理] #アドカレ2015

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。AWSモバイルアドベントカレンダー2015 21日目です。今回は、現在ベータ版として提供されているMobile Hubが生成するソースコードをざっと読んで確認してみます。

Mobile Hubからコードを生成する

まずはコードのテンプレートを作成します。全てだとボリュームが多いので今回は静的コンテンツのResourceとUser Storageにフォーカスして見ていきます。

スクリーンショット 2015-12-21 19.31.37

  • App Contetnt Delivery: 画像や動画など静的なコンテンツをDownloadし、Cacheしておくための機能。S3、CloudFrontを利用して実現されている。今回はSingle Locationを設定
  • User Data Storage: ユーザーが利用できるデータ保存用のStorage機能。CognitoとS3を利用して実現されている

上記を設定したプロジェクトをBuildし、Donwloadします。

スクリーンショット 2015-12-21 19.32.11

プロジェクトを展開

今回は、Android用のプロジェクトの内容を確認します。Downloadしたzipファイルを解答すると、以下のファイル及びディレクトリが確認できます。

  • LICENSE.txt: ライセンス条項が記載されたテキスト
  • MySampleApp: アプリケーションのプロジェクトディレクトリ
  • READ_ME: READMEが記載されているHTMLコンテンツが格納されているディレクトリ

まずはアプリケーションのプロジェクトディレクトリを展開します。こちらはそのままAndroid Studioで開くことが出来ます。

スクリーンショット 2015-12-21 13.43.01

生成されたプロジェクトコードにはBuild ToolsのVersionが23.0.1 で指定されています。こちらは存在しない場合は上記のようなエラーが表示されるので、自分の環境に合わせて変更します。

MySampleApp>app>build.gradle

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.2"    // ←変更
(...snip)

私の環境では、Build Toolsは 23.0.2 がインストールされていたので上記の記載に変更します。

アプリケーションコード

まずはプロジェクトの構成から確認してみます。

スクリーンショット 2015-12-21 15.04.52

  • com.amazonaws.mobile
  • com.mysampleapp

上記2つのパッケージで構成されています。

com.amazonaws.mobile にはAWS Mobile SDKの中から必要な物だけをピックアップしたものが取捨選択されて配置されています。今回は、StorageやContentに絞っているため、他の機能(Push通知)などで利用するSDKは除外されています。

com.mysampleapp には実際のアプリケーションを構成するコードが配置されています。

アプリケーション起動

アプリケーション起動時には、SplashActivity.java から起動されます。念のためManifestを確認します。

<activity
    android:name=".SplashActivity"
    android:label="@string/app_name"
    android:configChanges="keyboard|keyboardHidden|orientation|screenSize">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

はい。間違いないですね。SplashActivityで何をやっているのかを確認します。

@Override
    protected void onCreate(Bundle savedInstanceState) {
    Log.d(LOG_TAG, "onCreate");

    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_splash);

    AWSMobileClient.initializeMobileClientIfNecessary(this.getApplicationContext());

    final Thread thread = new Thread(new Runnable() {
        public void run() {
            // Wait for the splash timeout.
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) { }

            // Expire the splash page delay.
            timeoutLatch.countDown();
        }
    });
    thread.start();
    goMain();
}

以下の記述が確認できます。こちらをSplashActivity#onCreate()で呼んでいます。AWSMobileClientの初期化コードですね。

AWSMobileClient.initializeMobileClientIfNecessary(this.getApplicationContext());

中身でどんな処理をしているかを覗いてみます。

AWSMobileClient のstaticメソッドとして定義されています。

/**
* Creates and initialize the default AWSMobileClient if it doesn't already
* exist using configuration constants from {@link AWSConfiguration}.
*
* @param context an application context.
*/
public static void initializeMobileClientIfNecessary(final Context context) {
    if (AWSMobileClient.defaultMobileClient() == null) {
        Log.d(LOG_TAG, "Initializing AWS Mobile Client...");
        final ClientConfiguration clientConfiguration = new ClientConfiguration();
        clientConfiguration.setUserAgent(AWSConfiguration.AWS_MOBILEHUB_USER_AGENT);
        final IdentityManager identityManager = new IdentityManager(context, clientConfiguration);
        final AWSMobileClient awsClient =
            new AWSMobileClient.Builder(context)
                .withCognitoRegion(AWSConfiguration.AMAZON_COGNITO_REGION)
                .withCognitoIdentityPoolID(AWSConfiguration.AMAZON_COGNITO_IDENTITY_POOL_ID)
                .withIdentityManager(identityManager)
                .withClientConfiguration(clientConfiguration)
                .build();

        AWSMobileClient.setDefaultMobileClient(awsClient);
    }
    Log.d(LOG_TAG, "AWS Mobile Client is OK");
}

AWSConfiguration を指定せずに、DefaultのAWSMobileClientを生成するメソッドです。必要なオブジェクトをDefaultの設定でインスタンス化していたり、Builderによって生成しているだけなので特に例外などは発生しなさそうです。

非同期スレッドの処理で2秒間のTimeoutが設定されています。スレッドの開始と同時にgoMain() を実行し内部でgoAfterSplashTimeout() を呼び出しています。こちらでももう一本の非同期スレッドが実行され、CountDownLatch の完了を監視します。

/**
 * Starts an activity after the splash timeout.
 * @param intent the intent to start the activity.
 */
private void goAfterSplashTimeout(final Intent intent) {
    final Thread thread = new Thread(new Runnable() {
        public void run() {
            // wait for the splash timeout expiry or for the user to tap.
            try {
                timeoutLatch.await();
            } catch (InterruptedException e) {
            }

            SplashActivity.this.runOnUiThread(new Runnable() {
                public void run() {
                    startActivity(intent);
                    // finish should always be called on the main thread.
                    finish();
                }
            });
        }
    });
    thread.start();
}

2秒経過後にonCreateで起動したThreadがCountDownLatchを解除し、goAfterSplashTimeout() の処理が先に進みます。指定されたIntent(この場合はMainActivity)を起動します。

この辺りの実装は他のスプラッシュ画面が必要なアプリケーションでも参考に出来そうな実装です。

MainActivity

アプリケーションが起動したらMainActivityの内部処理へ移行します。MainActivity#onCreate() では以下が実行されています。

@Override
protected void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Obtain a reference to the mobile client. It is created in the Splash Activity.
    AWSMobileClient awsMobileClient = AWSMobileClient.defaultMobileClient();

    if (awsMobileClient == null) {
        // In the case that the activity is restarted by the OS after the application
        // is killed we must redirect to the splash activity to handle initialization.
        Intent intent = new Intent(this, SplashActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        startActivity(intent);
        return;
    }

    // Obtain a reference to the identity manager.
    identityManager = awsMobileClient.getIdentityManager();

    setContentView(R.layout.activity_main);

    setupToolbar(savedInstanceState);
    setupNavigationMenu(savedInstanceState);
}

AWSMobileClientのDefaultインスタンスの存在を確認し、「存在しなければ初期化処理を実行しているSplashActivityを起動する」という、なかなか強気の実装になっている模様です。

今回は利用していませんが、identityManager をAWSMobileClientのインスタンスから取得するのもこのタイミングになります。

その後、MainActivityのLayoutをセットします。 起動直後のFragmentを配置するのは、setupNavigationMenu() の内部です。基本的にこのメソッドの中ではDrawer Menuのセットアップを行っています。

if (savedInstanceState == null) {
    // Add the home fragment to be displayed initially.
    navigationDrawer.showHome();
}

Drawer Menuのセットアップ後にsavedInstanceState の有無を判定し、存在しなければHomeを表示します。showHome()ではHomeFragmentへの遷移を行っています。

DemoFragmentBase

その前に。全てのFragmentのBaseになっているクラスがあるので、こちらを確認しておきます。DemoFragmentBase というクラスでこちらはcom.mysampleapp.demo 以下のパッケージに配置されています。

@Override
public void onViewCreated(final View view, final Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
    final UserSettings userSettings = UserSettings.getInstance();
    new AsyncTask<Void, Void, Void>() {
        @Override
        protected Void doInBackground(final Void... params) {
            userSettings.loadFromDataset();
            return null;
        }

        @Override
        protected void onPostExecute(final Void aVoid) {
            final View view = getView();
            if (view != null) {
                view.setBackgroundColor(userSettings.getBackgroudColor());
            }
        }
    }.execute();
}

UserSettingというユーザー設定を管理するインスタンスから非同期でユーザーの設定データを取得しています。こちらはCognitoの機能でしょうか。

UserSetting

BaseFragmentで呼び出されている、UserSetting のインスタンスはcom.mysampleapp.demo 以下のパッケージに定義されています。

Cognitoのユーザー固有の設定情報を取得し、それらを管理するクラスになっています。重要な部分は以下の場所でしょうか。

/**
 * Gets the Cognito dataset that stores user settings.
 *
 * @return Cognito dataset
 */
public Dataset getDataset() {
    return AWSMobileClient.defaultMobileClient()
            .getSyncManager()
            .openOrCreateDataset(USER_SETTINGS_DATASET_NAME);
}

コメントにもCognito Datasetという表記がありました。

DefaultのAWSMobileClientからユーザー設定のDatasetを取得しています。Fragmentの生成はMainActivityのonCreate()を正常に通過した後に実行されているはずなので、ここでのAWSMobileClient.defaultMobileClient() は null になることはないはずです。

HomeFragment

HomeFragmentを確認してみます。生成したコードでのHomeFragmentはHomeDemoFragment.java という名前でcom.mysampleapp.demo 以下のパッケージに配置されています。DemoFragmentBase を継承しているクラスです。

が、これは単なるListViewになってるので特に解説が必要そうなところはありません。

ContentDeliveryDemoFragment

device-2015-12-21-213726

S3とCloudFrontから静的コンテンツを取得し、キャッシュを管理します。

@Override
public void onViewCreated(final View view, final Bundle savedInstanceState) {

    final ProgressDialog dialog = getProgressDialog(
        R.string.content_progress_dialog_message_load_local_content);

    AWSMobileClient.defaultMobileClient().
            createDefaultContentManager(new ContentManager.BuilderResultHandler() {

                @Override
                public void onComplete(final ContentManager contentManager) {
                    if (isAdded()) {
                        final View fragmentView = getView();
                        ContentDeliveryDemoFragment.this.contentManager = contentManager;
                        createContentList(fragmentView, contentManager);
                        contentManager.setContentRemovedListener(contentListItems);
                        dialog.dismiss();
                        refreshContent(currentPath);
                    } else {
                        contentManager.destroy();
                    }
                }
            });
}

onViewCreated() でContentsのリストを取得します。取得は非同期で行われているので、getProgressDialog(R.string.content_progress_dialog_message_load_local_content); でProgress Dialogを表示します。

public void onComplete(final ContentManager contentManager) が非同期処理完了後に実行されます。ここでContentsリストの内容を更新します。

ContextMenuの作成

@Override
public void onCreateContextMenu(final ContextMenu menu, final View view,
                                final ContextMenu.ContextMenuInfo menuInfo) {
    super.onCreateContextMenu(menu, view, menuInfo);

    if (view == cacheLimitTextView) {
        for (final SpannableString string : ContentUtils.cacheSizeStrings) {
            menu.add(string).setActionView(view);
        }
        menu.setHeaderTitle(ContentUtils.getCenteredString(setCacheSizeText));
    } else if (view.getId() == listView.getId()) {
        final AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;
        final ContentItem contentItem = contentListItems.getItem(info.position).getContentItem();
        final ContentState contentState = contentItem.getContentState();

        final boolean isNewerVersionAvailable =
            ContentState.isCachedWithNewerVersionAvailableOrTransferring(contentState);
        final boolean isCached = contentState == ContentState.CACHED || isNewerVersionAvailable;
        final boolean isPinned = contentManager.isContentPinned(contentItem.getFilePath());

        if (contentState == ContentState.REMOTE_DIRECTORY) {
            final String newPath;
            if (currentPath.equals(contentItem.getFilePath())) {
                newPath = S3Utils.getParentDirectory(currentPath);
            } else {
                newPath = contentItem.getFilePath();
            }

            refreshContent(newPath);
            return;
        }
        // if item is downloaded
        if (isCached) {
            menu.add(getString(R.string.content_context_menu_open)).setActionView(listView);
        } else {
            menu.add(getString(R.string.content_context_menu_download)).setActionView(listView);
        }
        if (isNewerVersionAvailable) {
            menu.add(getString(R.string.content_context_menu_download_latest)).setActionView(listView);
        }
        if (isCached && !isPinned) {
            menu.add(getString(R.string.content_context_menu_pin)).setActionView(listView);
        }
        if (!isCached) {
            menu.add(getString(R.string.content_context_menu_download_pin)).setActionView(listView);
        }
        if (isPinned) {
            menu.add(getString(R.string.content_context_menu_unpin)).setActionView(listView);
        }
        if (isCached) {
            menu.add(getString(R.string.content_context_menu_delete_local)).setActionView(listView);
        }
        menu.setHeaderTitle(contentItem.getFilePath());
    }
}

ContentsのListのアイテムはそれぞれCacheを持っていたり、Pinされていたりなどそれぞれ状態が違います。そのため、アイテムをタップした時のContext Menuも当然異なってきます。Cacheに存在しなければDonwloadが必要ですし、変更されていれば再度Downloadしなければなりません。Cacheが存在すればCacheを利用すれば良いわけですし。ここでは、それぞれのコンテンツアイテムのステータスを確認しながらContext Menuを生成しています。

実際の実装ではこの辺りをどこまでユーザーに操作させるかは議論の余地がありますが、アイテムそれぞれのステータスが詳細に取得できるため、自動的にコンテンツを更新するなど、実装はとても参考になりそうです。

コンテンツの状態によってContext Menuが変更される様子。

device-2015-12-21-213033

device-2015-12-21-213050

UserFilesBrowserFragment

最後にファイルブラウザの機能を確認してみます。

@Override
public void onViewCreated(final View view, final Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);

    final Bundle args = getArguments();
    bucket = args.getString(BUNDLE_ARGS_S3_BUCKET);
    prefix = args.getString(BUNDLE_ARGS_S3_PREFIX);

    final ProgressDialog dialog = getProgressDialog(
            R.string.content_progress_dialog_message_load_local_content);

    // Create the User File Manager
    AWSMobileClient.defaultMobileClient()
            .createUserFileManager(bucket,
                prefix,
                new UserFileManager.BuilderResultHandler() {

                    @Override
                    public void onComplete(final UserFileManager userFileManager) {
                        if (isAdded()) {
                            UserFilesBrowserFragment.this.userFileManager = userFileManager;
                            createContentList(getView(), userFileManager);
                            userFileManager.setContentRemovedListener(contentListItems);
                            dialog.dismiss();
                            refreshContent(currentPath);
                        } else {
                            userFileManager.destroy();
                        }
                    }
                });
}

AWSMobileClientのインスタンスから、FileManagerを取得します。こちらも同時に非同期で処理されるため、Progress Dialogを表示しておきます。ファイルの管理はS3で行っているため、Bucket の名称とPrefix が指定されているのが確認できます。

こちらも先ほどのContentDeliveryDemoFragmentと同じように、Cacheの有無などでContext Menuが変わるようになっています。

まとめ

少々雛形というには大きく大げさなものになりますが、必要な機能を選択するだけで、SDKを含めたプロジェクトテンプレートが生成でき、CognitoやS3のBucketなどと言った必要なものをボタンをポチッとするだけで用意してくれるのでとても便利です *1

参照

脚注

  1. AWS Consoleでいちいち用意するのが面倒だと思っていた人間なので。