20200530のAndroidに関する記事は9件です。

Coursierで特定のmaven artifactの依存関係を確認する

Androidでの開発やJavaなどのJVM言語での開発で、あれ、このライブラリって何に依存しているんだっけ?って思うことよくありますよね?
皆さんはどのように確認しているでしょうか?
https://mvnrepository.com/ などのサイトを見たり、 ./gradlew dependenciesで確認するなどさまざまだと思います。

今日はScalaで書かれたCoursierというCLIツールが便利だったので紹介します。
https://get-coursier.io/

使い方

ターミナル上で coursier resolve maven_artifactという感じで確認できます

$ coursier resolve io.circe:circe-core_2.12:0.10.0
io.circe:circe-core_2.12:0.10.0:default
io.circe:circe-numbers_2.12:0.10.0:default
org.scala-lang:scala-library:2.12.6:default
org.scala-lang:scala-reflect:2.12.6:default
org.typelevel:cats-core_2.12:1.4.0:default
org.typelevel:cats-kernel_2.12:1.4.0:default
org.typelevel:cats-macros_2.12:1.4.0:default
org.typelevel:machinist_2.12:0.6.5:default

-tでツリー状にも依存関係を確認できます。

$ cs resolve -t io.circe::circe-generic:0.12.3
  Result:
└─ io.circe:circe-generic_2.13:0.12.3
   ├─ com.chuusai:shapeless_2.13:2.3.3
   │  └─ org.scala-lang:scala-library:2.13.0
   ├─ io.circe:circe-core_2.13:0.12.3
   │  ├─ io.circe:circe-numbers_2.13:0.12.3
   │  │  └─ org.scala-lang:scala-library:2.13.0
   │  ├─ org.scala-lang:scala-library:2.13.0

インストール方法

Macだと以下のようにしてインストールできるようです。
https://get-coursier.io/docs/cli-installation

brew install coursier/formulas/coursier

Google Maven Reposiotryにあるリポジトリを見る

Androidの開発で欠かせないGoogle Maven Reposiotryを見るには以下のようにして行います。

cs resolve -t  -r https://maven.google.com -r https://jcenter.bintray.com androidx.ui:ui-livedata:0.1.0-dev12|view -
└─ androidx.ui:ui-livedata:0.1.0-dev12
   ├─ androidx.compose:compose-runtime:0.1.0-dev12
   │  ├─ androidx.annotation:annotation:1.1.0
   │  ├─ org.jetbrains.kotlin:kotlin-stdlib:^[[33m1.3.70 -> 1.3.71^[[0m
   │  │  ├─ org.jetbrains:annotations:13.0
   │  │  └─ org.jetbrains.kotlin:kotlin-stdlib-common:1.3.71
   │  ├─ org.jetbrains.kotlin:kotlin-stdlib-common:^[[33m1.3.70 -> 1.3.71^[[0m
   │  ├─ org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.3
   │  │  ├─ org.jetbrains.kotlin:kotlin-stdlib:^[[33m1.3.50 -> 1.3.71^[[0m
   │  │  │  ├─ org.jetbrains:annotations:13.0
   │  │  │  └─ org.jetbrains.kotlin:kotlin-stdlib-common:1.3.71
   │  │  └─ org.jetbrains.kotlin:kotlin-stdlib-common:^[[33m1.3.50 -> 1.3.71^[[0m
   │  ├─ org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6
   │  │  ├─ org.jetbrains.kotlin:kotlin-stdlib:1.3.71
   │  │  │  ├─ org.jetbrains:annotations:13.0

終わりに

依存関係を調べるときに手軽に調べられるので便利そうでした。
依存性の確認以外にも、まとめてjarにしてくれる機能や起動してくれる機能などがあって便利だったので、公式サイトを確認してみてください。
https://get-coursier.io/docs/overview

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

7年前DynabookタブにAndroidx86入れてゲーム(続き)

前回、「7年前のWinタブにAndroidx86を入れてゲームをする」記事を書いた。

7年前 Corei5 WinタブにAndroidx86入れてゲームとか

https://qiita.com/ksmndevelop/items/764307de2e0342137a47

一ヶ月程度経ってしまったが、その後のことについて追記しておく。

ゲーム動作の様子動画

とりあえず、きらファンとプリコネをプレイした動画を上げた。

Dynabook VT712 Android x86 きらファンテスト - YouTube
Dynabookきらファンテスト

動画を見た通り、微妙なところである・・・

ベンチ

Antutu (Google Playストアからは削除された)は公式サイトから入手しようとしたが、インストールできなかった。(提供不明元のアプリ許可はしているのに・・・)

GeekBenchもインストールはできたが、起動不可。

唯一、3DMarkが動いた。しかし、スクショするのを忘れたため大体のベンチを記述しておく。

3DMarkのベンチ

大体、1500ぐらいであった。相当下。最近のミッドレンジより酷かった。
確かに、グラフィックは負荷がかかると、相当カクカクになる。

最後に

7年前のCorei5は最新のミッドレンジスマホにコテンパンであることがはっきり分かった。
今のミッドレンジスマホは、「1万円でもこんなに動くのか!」という感じではある。
結局、このDynabookタブの使い道が今の所あまりない。どうするか。
熟考。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Dagger-HiltはViewModelのFactoryをどのように差し込むのか

AAkiraさんのブログを見てViewModelのInjectがすごいと思って不思議に思って仕組みを調べてみました。
https://aakira.app/blog/2020/05/dagger-hilt/

まだ正式にリリースされているわけではないので後々仕組みが変わる可能性があります。
このブログで紹介されているサンプルコードをベースに見ていきます。 (理解しやすいサンプルを公開していただき、本当にありがとうございます :pray: )
https://github.com/AAkira/dagger-hilt-example

以下のように書いておくことで、VoiewModelをInjectできるそうです。

class MainViewModel @ViewModelInject constructor(
    private val repository: SampleRepository,
    @Assisted private val savedState: SavedStateHandle
) : ViewModel() {
}
@AndroidEntryPoint
class MainActivity : AppCompatActivity(R.layout.activity_main) {

    private val viewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        Log.v("main activity", "activity view model: $viewModel")
    }
}

なぜ、これだけでViewModelのDIができるのでしょうか?

viewModels{}メソッドについて

ここではHiltではなく、元からAndroid JetpackにあるviewModels<>()という関数を使っているのですが、viewModels<>()はデフォルトではComponentActivity#getDefaultViewModelProviderFactory()を経由してViewModelを生成して、getViewModelStore()で管理しているところにViewModelを保存します。
つまり、うまくこのProviderFactoryを変えることができれば、ViewModelのinjectができそうです。

@MainThread
inline fun <reified VM : ViewModel> ComponentActivity.viewModels(
    noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
    val factoryPromise = factoryProducer ?: {
        // **ここでComponentActivity#getDefaultViewModelProviderFactory()を使う**
        defaultViewModelProviderFactory
    }

    return ViewModelLazy(VM::class, { viewModelStore }, factoryPromise)
}

どうやってgetDefaultViewModelProviderFactory()を変えるか?

実際にアプリを動かしてコードを見てみましょう。
MainActivityのsuperクラスはAppCompatActivityのはずでしたが。。。

@AndroidEntryPoint
class MainActivity : AppCompatActivity(R.layout.activity_main) {

デバッグで確認する以下のように MainActivityのsuperクラスとして、Hilt_MainActivityというクラスがいることがわかります

image.png

Hilt_MainActivityは以下のようになっており、 getDefaultViewModelProviderFactory() が実装されています。これによってViewModelのProviderFactoryが生成されます。

@Generated("dagger.hilt.android.processor.internal.androidentrypoint.ActivityGenerator")
public abstract class Hilt_MainActivity extends AppCompatActivity implements GeneratedComponentManager<Object> {
  private volatile ActivityComponentManager componentManager;
...
  @Override
  public ViewModelProvider.Factory getDefaultViewModelProviderFactory() {
    ViewModelProvider.Factory factory = DefaultViewModelFactories.getActivityFactory(this);
    if (factory != null) {
      return factory;
    }
    return super.getDefaultViewModelProviderFactory();
  }
}

また少し脇道にそれますが、Hilt_MainActivityでは面白いことにこの生成されたコードの中でcomponentを持つActivityComponentManagerを生成しています。

@Generated("dagger.hilt.android.processor.internal.androidentrypoint.ActivityGenerator")
public abstract class Hilt_MainActivity extends AppCompatActivity implements GeneratedComponentManager<Object> {
  private volatile ActivityComponentManager componentManager;
...
  protected ActivityComponentManager createComponentManager() {
    return new ActivityComponentManager(this);
  }

  protected final ActivityComponentManager componentManager() {
    if (componentManager == null) {
      synchronized (componentManagerLock) {
        if (componentManager == null) {
          componentManager = createComponentManager();
        }
      }
    }
    return componentManager;
  }

話を戻しますが、DefaultViewModelFactories.getActivityFactory(this)を追ってみましょう。

@Generated("dagger.hilt.android.processor.internal.androidentrypoint.ActivityGenerator")
public abstract class Hilt_MainActivity extends AppCompatActivity implements GeneratedComponentManager<Object> {
  private volatile ActivityComponentManager componentManager;
...
  @Override
  public ViewModelProvider.Factory getDefaultViewModelProviderFactory() {
    // ********
    ViewModelProvider.Factory factory = DefaultViewModelFactories.getActivityFactory(this); 

生成されたコード内でしか使わないで!というメソッドがあります。 基本的にはactivityからDaggerのコンポーネントを取得して、そのコンポーネントにあるモジュールからViewModelのProviderFactoryを取得します。
EntryPoints.get(activity, ActivityEntryPoint.class)というのを呼び出すのですが、EntryPointsについては深くは説明しないのですが(インスタンス取得を楽にする面白い仕組みがありそうでした。)、ここではHilt_MainActivityのメソッドで作られているActivityのスコープのComponentを取得して返しています。
その後、.getActivityViewModelFactory()で、そのComponentに生えているgetActivityViewModelFactory()を呼び出すことで、ModuleでProvideされたViewModelのProviderFactoryを取得しています。

public final class DefaultViewModelFactories {

  /**
   * Retrieves the default view model factory for the activity.
   *
   * <p>Do not use except in Hilt generated code!
   */
  @Nullable
  public static ViewModelProvider.Factory getActivityFactory(ComponentActivity activity) {
    return getFactoryFromSet(
        EntryPoints.get(activity, ActivityEntryPoint.class).getActivityViewModelFactory());
  }

Hiltのライブラリ内にあるProviderFactoryをProvideするModuleのコードです。
ここでProviderFactoryがProvideされます。

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
public final class ViewModelFactoryModules {

    /**
     * Hilt Modules for providing the activity level ViewModelFactory
     */
    @Module
    @InstallIn(ActivityComponent.class)
    public abstract static class ActivityModule {

        @NonNull
        @Multibinds
        abstract Map<String, ViewModelAssistedFactory<? extends ViewModel>> viewModelFactoriesMap();

        @Provides
        @IntoSet
        @NonNull
        @DefaultActivityViewModelFactory
        static ViewModelProvider.Factory provideFactory(
                @NonNull Activity activity,
                @NonNull Application application,
                @NonNull Map<String, Provider<ViewModelAssistedFactory<? extends ViewModel>>>
                        viewModelFactories) {
            // Hilt guarantees concrete activity is a subclass of ComponentActivity.
            SavedStateRegistryOwner owner = (ComponentActivity) activity;
            Bundle defaultArgs = activity.getIntent() != null
                    ? activity.getIntent().getExtras() : null;
            SavedStateViewModelFactory delegate =
                    new SavedStateViewModelFactory(application, owner, defaultArgs);
            return new HiltViewModelFactory(owner, defaultArgs, delegate, viewModelFactories);

まとめ

中身を知らなくても作れるように作られていますが、気になって見てみても読めるコードになっていて面白かったです。ぜひ興味があれば見てみてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityでGoogle Play Game Services導入する際、あらゆる罠にハマった話

UnityプロジェクトにGoogle Play Game Servicesを導入した際、いろいろ罠にはまって24時間くらい無駄にしたので、ここに何が発生してどうやって解決したかメモしておきます。

私の開発環境

  • Unity 2018.4.10f1
  • Google Play Game plugin for Unity v 0.10.09
  • Android SDK level 28

macで作業しています。

動かしたいコード

Google Play Game Servicesプラグインを使って、プロジェクトにクラウドセーブ機能を導入しようとしています。
動かしたいコードは、サービスの初期化とプレイヤの認証です。

var conf = new PlayGamesClientConfiguration.Builder()
    .EnableSavedGame()
    .Build();
PlayGamesPlatform.InitializeInstance(conf);
PlayGamesPlatform.Activate();

Social.localPlayer.Authenticate( (res, msg) => {
    Debug.Log($"Result {res} : {msg}");
});

ハマった罠たち

■ビルド時、ネームスペースGoogleが見つからない

●現象
Googleというネームスペースが見つからずにエラーになる。
Assets/GooglePlayGames/Editor/GPGSAndroidSetupUI.cs(118,17): error CS0103: The name 'Google' does not exist in the current context

●原因

バッチビルドのオプションでパッケージマネージャを使わないようにしてるのが原因です。

●解決方法

-noUpmオプションを外して、パッケージマネージャを使うようにします。

$UNITY -quit -noUpm -batchmode -logfile ./build.log -username $USERNAME -password $PASSWORD -executeMethod AndroidBuildRelease

      ↓

$UNITY -quit -batchmode -logfile ./build.log -username $USERNAME -password $PASSWORD -executeMethod AndroidBuildRelease

■ビルド時、Proguard (R8)でエラーが発生する

●現象

設定ファイルは正しいはずなのに、Proguardが文法エラーで止まる。

/path/to/src/proguard-user.txt:2:2: D8: Expected char '-' at /path/to/src/proguard-user.txt:1:1
-keep class com.google.android.gms.games.multiplayer.** { *; }

●原因

progurad-user.txt にBOMがついているためエラーになります。

●解決方法

proguard-user.txtのBOMを取ります。また、AssetImporterでテキストを全てBOM付きに変更しているプロジェクトでは、このファイルを対象外にするように修正を加えます。

■ビルド時、support-v4周りでコンパイルエラーがでる

●現象

ビルドするとProgram type already present: android.support.v4.なんとかというエラーが発生する。

●原因

GPGSではandroidxで提供されているv4サポートを使っているためです。その他のプラグインでandroid.support.v4系のパッケージを利用しているとこのエラーが発生します。

●解決方法

android.support.v4 はバージョン28で終了なので androidxを使うようにします。でも、自分で書いたコードだったら直せますが、外部のライブラリがこの辺を要求しているとなかなか対応が面倒ですね。

■ClassNotFoundExceptionが発生する

●現象
APKを実行するとClassNotFoundExceptionを吐いて止まる。
Exception class not found com.google.android.gms.games.Games

●原因

これには以下の要因があります。

  1. Assets/Plugins/Android以下にサービス関連のaarファイルが配置されていないと発生します。
  2. ProguardによってGPGS関連のクラス名が変更されると発生します。

●解決方法

* 1. GPGS関連のaarを配置する
プラグインを導入すると、パッケージマネージャにレジストリを加えてGPGS関連のaarがインストールされるようになっています。
ここで問題がでるのは主に開発環境のjava関連が理由かと思われます。

  • javaがインストールされていなければインストールする
  • パスを通すか、パスの通ったディレクトリにjavaを置く。 /usr/bin/java があれば問題ないはず
  • gradleがインストールされていなければインストールする (未確認)
  • UnityエディタでAssets > External Dependency Manager > Android Resolver > Force Resolveを実行する

* 2. Proguard関連でリネームされないようにする

クラス名が変更されないように、ここらへんに書いてある内容を設定するか、難読しないようにします。
前述しましたが、Proguardの設定を追加する場合はファイルにBOMがつかないようにします。

■おかえりポップアップが表示されない

●現象

正式名称を何と言うのか分かりませんが、アプリを実行した際に表示されるはずのおかえりなさいポップアップが表示されない。

●原因

これには以下のような要因があります。

  1. バンドルIDを間違えている
  2. app idを間違えている

●解決方法

* 1. バンドルIDを正しいものにする

Google Play Consoleでゲームサービスに登録しているバンドルIDと、APKのものとを合わせるようにします。

* 2. app idを正しいものにする

UnityエディタのWindow > Google Play Games > Android setupでダイアログを開き、Resource Definitionで登録するXMLを確認します。

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="app_id" translatable="false">ここにapp idを記述する</string>
  <string name="package_name" translatable="false">ここにバンドルIDを記述する</string>
</resources>

app id はGoogle Play Consoleのゲームサービス > ゲームの詳細に書いてある11桁の数値です。

■プレイヤー認証に失敗する

●現象

device errorやcancel authentication といったエラーで、Social.localPlayer.Authenticate が常に失敗する。

●原因

これには以下のような要因があります。

  1. SHA1フィンガープリントが間違っている
  2. SavedGame の設定がオフになっている

●解決方法

* 1. SHA1フィンガープリントを修正する

アプリのSHA1フィンガープリントを確認します。署名の方法によって2パターンあります。

A) Google Play App Signingを使っている場合
 Google Play Consoleのアプリの詳細画面から リリース管理>アプリの署名 で確認します。

B) ビルド時にキーストアで署名している場合
 下記コマンドで表示されるSHA1フィンガープリントを確認します。

keytool -list -keystore キーストアのパス -v

つぎに https://console.developers.google.com でゲームサービス側に登録されているフィンガープリントを確認します。
上記サイトからサービスを選び、認証情報>Oauth2.0 クライアント IDのなかから、アプリを選んで編集画面に入ると確認できます。
アプリとサービスのSHA1フィンガープリントを合わせるように、どちらかもしくは両方を修正します。

* 2. SavedGameの設定をオンにする

Google Play Consoleのゲームサービスでアプリを選び、ゲームの詳細を表示します。SavedGameの設定項目があるのでONにします。

スクリーンショット 2020-05-30 20.29.35.png

保存済みゲームというのはSavedGameの翻訳ですが、これ紛らわしいですね。「SavedGameを有効にする」という感じの表記にしてほしいです。

最後に

iOSのGameCenterは簡単だったのでGPGSも楽勝だろうと思って舐めていたら、これらの罠を全部踏むと言う有様でした。UnityにGoogle Play Game Serviceを導入するための日本語情報は多少ありますが、トラブルシュートのようなものがないんですよね。
ここに書いたのは自分のための備忘録なのですが、もしだれかが同様につまづくことがあったとき、これが参考になればいいなと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FlutterアプリでonResume的なタイミングの処理をする

状況

カレンダー式表示のアプリを作成しています。「今日」が分かりやすいように、その日付のセルだけ背景色を変えています。
しかし、特にiOSで問題になるのですが、アプリをホームボタンでバックグラウンドに移動していて、もう一度アプリを立ち上げたときに、そのタイミングで日付が変わっていても「今日のセル」が移動してくれません。
Androidでいう、"onResume"のタイミングで処理ができれば解決できると思い、調査しました。

環境など

ツールなど バージョンなど
MacBook Air Early2015 macOS Mojave 10.14.6
Android Studio 3.6.1
Java 1.8.0_131
Flutter 1.12.13+hotfix.9
Dart 2.7.2
Xcode 11.3.1

解決策

SystemChannels.lifecycle.setMessageHandlerを使います。
詳しくはこちら。
https://api.flutter.dev/flutter/services/BasicMessageChannel/setMessageHandler.html

Widgetbuild内でこの関数にハンドラーを登録しておくと、任意のライフサイクルのタイミングで処理をすることが出来るようになります。

/// カレンダーウィジェット
class CalendarWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final viewModel = Provider.of<MonthlyViewModel>(context, listen: false);
    SystemChannels.lifecycle.setMessageHandler((msg) {
      if (msg == 'AppLifecycleState.resumed') {
        print('Resumed!');
        viewModel.updateToday();
      }
      return null;
    });

    return Column(...);
  }
}

参考サイト

flutterでonResume
https://qiita.com/sakyoyuto/items/a7a8ecefea1cee5fbac6

https://stackoverflow.com/questions/49869873/flutter-update-widgets-on-resume

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidの密度修飾子(ldpi, mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi)をコード上で判定する。

Androidの密度修飾子はDrawableリソースの場合、それぞれのフォルダを使うことで自動的に端末のDrawableリソースが使われます。
しかし、今の手持ちの密度修飾子は何なんだ?というときがあるので、コード上で判定してみたいと思います。

MainActivity.kt
   override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val densityDpi = resources.displayMetrics.densityDpi
        (findViewById<View>(R.id.textView2) as TextView).text = densityDpi.toString()
        if (0 < densityDpi && densityDpi <= DisplayMetrics.DENSITY_LOW) {
            textview.text = "ldpi"
        } else if (densityDpi <= DisplayMetrics.DENSITY_MEDIUM) {
            // mdpi
            textview.text = "mdpi"
        } else if (densityDpi <= DisplayMetrics.DENSITY_HIGH) {
            // hdpi
            textview.text = "hdpi"
        } else if (densityDpi <= DisplayMetrics.DENSITY_XHIGH) {
            // xdpi
            textview.text = "xdpi"
        } else if (densityDpi <= DisplayMetrics.DENSITY_XXHIGH) {
            // xxdpi
            textview.text = "xxdpi"
        } else if (densityDpi <= DisplayMetrics.DENSITY_XXXHIGH) {
            // xxxdpi
            textview.text = "xxxdpi"
        }
    }

ちょっとだけ解説
densityDpiがDPI(1インチあたりのピクセル数)として取得できます。
その取得できたDPIを以下の表に当てはめて、計算します。
スクリーンショット 2020-05-30 15.38.55.png
公式サイトから引用

参考

各種のピクセル密度をサポートする

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Android]Shared Element Transition を試してみる

はじめに

Shared Element Transition を利用すると Fragment 間で共有する View を指定することができ、次のように画面間でシームレスに View が移動したり拡大したりするようなアニメーションを実装できるらしい。Activity、Fragment どちらの画面遷移でも Shared Element Transition を利用できるらしいのだが、Single Activity で実装されることが最近は多いと思うので、Fragment を利用した画面遷移で Shared Element Transition を実装していこうかなと思います。

Shared Element Transiton なし Shared Element Transition あり
Image from Gyazo Image from Gyazo

準備

本記事では Navigation Component を利用して Fragment の画面遷移を実装します。次の手順に従って Navigation Component をセットアップすればとりあえず動かせます。詳細が知りたい方は公式ドキュメント、または次の記事を読んでみるとよいかもしれません。

1. Navigation Component をインストールする

Navigation Component で必要な依存関係を build.gradle(app) に記述する。

dependencies {
    def nav_version = "2.2.2"
    implementation "androidx.navigation:navigation-fragment:$nav_version"
    implementation "androidx.navigation:navigation-ui:$nav_version"
    implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
    implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
}

2. Navigation Graph を作成する

Navigation Component での画面遷移を定義する Navigation Graph を作成する。次の内容の main_graph_navigation.xmlres/navigation に作成すればよいです。

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main_graph_navigation">

3. NavHostFragment を配置する

Navigation Compoennt での画面遷移をするときは、NavHostFragment が必要になります。なので MainActivity のレイアウトにに次の Fragment を追加します。そしてその Fragment にさっき作成した Navigation Graph を指定しておきます。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/nav_host_fragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:defaultNavHost="true"
        app:navGraph="@navigation/main_graph_navigation" />

</androidx.constraintlayout.widget.ConstraintLayout>

4. Framgent を作成する

そして 2つの画面を遷移が必要になるので StartFragment と EndFragment という Fragment を作成しておきます。StartFragment と EndFragment ともに ImageView と TextView の同じ要素を配置したシンプルな画面です。

StartFragment EndFragment
Image from Gyazo Image from Gyazo

StartFragment

class StartFragment : Fragment(R.layout.fragment_start)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".StartFragment">

    <ImageView
        android:id="@+id/start_image_view"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_gravity="center"
        android:src="@drawable/cat"
        android:transitionName="start_image_view_transition"
        app:layout_constraintBottom_toTopOf="@id/start_text_view"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/start_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="CAT"
        android:transitionName="start_text_view_transition"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/start_image_view" />

</androidx.constraintlayout.widget.ConstraintLayout>

EndFragment

class EndFragment : Fragment(R.layout.fragment_end)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".EndFragment">

    <ImageView
        android:id="@+id/end_image_view"
        android:layout_width="match_parent"
        android:layout_height="500dp"
        android:layout_gravity="center"
        android:src="@drawable/cat"
        android:transitionName="end_image_view_transition"
        app:layout_constraintBottom_toTopOf="@id/end_text_view"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/end_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="CAT"
        android:textSize="32sp"
        android:transitionName="end_text_view_transition"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/end_image_view" />

</androidx.constraintlayout.widget.ConstraintLayout>

5. 画面遷移処理を実装する

そして最後に画面遷移できるように main_navigation_graph.xml に StartFragment と EndFragment を登録、StartFragment と EndFragment の画面遷移を追加しておきます。

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main_graph_navigation"
    app:startDestination="@id/startFragment">

    <fragment
        android:id="@+id/startFragment"
        android:name="jp.kaleidot725.sample.StartFragment"
        android:label="fragment_start"
        tools:layout="@layout/fragment_start" >
        <action
            android:id="@+id/action_startFragment_to_endFragment"
            app:destination="@id/endFragment"
            app:enterAnim="@android:anim/fade_in"
            app:exitAnim="@android:anim/fade_out"
            app:popEnterAnim="@android:anim/fade_in"
            app:popExitAnim="@android:anim/fade_out" />
    </fragment>
    <fragment
        android:id="@+id/endFragment"
        android:name="jp.kaleidot725.sample.EndFragment"
        android:label="fragment_end"
        tools:layout="@layout/fragment_end" />
</navigation>

あとは StartFragment の ImageView が選択されたら画面遷移が開始する、EndFragment の ImageView が選択されたら前の画面に戻るように実装してあげればこれで準備完了です。

class StartFragment : Fragment(R.layout.fragment_start) {
    private val navController get() = findNavController()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
            navController.navigate(R.id.action_startFragment_to_endFragments)
        }
    }
}
class EndFragment : Fragment(R.layout.fragment_end) {
    private val navController get() = findNavController()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        end_image_view.setOnClickListener {
            navController.popBackStack()
        }
    }
}

実装

準備がやたらと長かったですが、ようやく Shared Element Transition に実装を進められるようになりましたので実装を進めていきます。Shared Element Transition を使うためには次の 3 つの実装をする必要があります。
 
- View に android:transition を設定する
- View を Shared Element として登録する
- Shared Element をどのようなアニメーションで表示するか設定する

1. View に android:transition を設定する

Shared Element Transition を利用するには Shared Element として扱う View の要素に android:transitionName を設定しておく必要がありますので設定します。

StartFragment

    <ImageView
        android:id="@+id/start_image_view"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_gravity="center"
        android:src="@drawable/cat"
        android:transitionName="start_image_view_transition"
        app:layout_constraintBottom_toTopOf="@id/start_text_view"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/start_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="CAT"
        android:transitionName="start_text_view_transition"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/start_image_view" />

EndFragment

    <ImageView
        android:id="@+id/end_image_view"
        android:layout_width="match_parent"
        android:layout_height="500dp"
        android:layout_gravity="center"
        android:src="@drawable/cat"
        android:transitionName="end_image_view_transition"
        app:layout_constraintBottom_toTopOf="@id/end_text_view"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/end_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="CAT"
        android:textSize="32sp"
        android:transitionName="end_text_view_transition"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/end_image_view" />

2. View を Shared Element として登録する

View を Shared Element として登録していきます。 Shared Element の登録は StartFragmentで行います。そして Navigation Compoent を利用している場合には FragmentNavigatorExtras というクラスを生成して登録を行います。

FragemtnNavigatorExtras は View と transtionName の組み合わせを保持するクラスになっています。次のように StartFragment の View と EndFragment の TransitionName の Map を作成して FragmentNavigatorExtras を生成してやります。

そして FragmentNavigatorExtras を NavController.navigate にて画面遷移する際に渡してやります。そうすることで Map で指定した View と TransitionName の View が Shared Element として認識されるようになります。

class StartFragment : Fragment(R.layout.fragment_start) {
    private val navController get() = findNavController()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        start_image_view.setOnClickListener {
            val extras = FragmentNavigatorExtras(
                start_image_view to "end_image_view_transition",
                start_text_view to "end_text_view_transition"
            )
            navController.navigate(R.id.action_startFragment_to_endFragment, null, null, extras)
        }
    }
}

3. Shared Element をどのようなアニメーションで表示するか設定する

Shared Element のアニメーション設定は EndFragmentで行います。その画面に遷移したときのアニメーションは sharedElementEnterTransition、その画面から離れるときのアニメーションは sharedElementReturnTransiton に設定するようになっています。

sharedElementEnterTransition、 sharedElementReturnTransition には Transitionを指定するようになっています、Transition の種類は次のようなものがあり Shared Element として登録した View に適したものを選択してやります。

  • ChangeBounds - Viewのレイアウト境界の変更をアニメーション化する。
  • ChangeClipBounds - Viewのクリップ境界の変化をアニメーション化する。
  • ChangeTransform - View のスケールと回転の変化をアニメーション化します。
  • ChangeImageTransform - ImageView のサイズとスケールの変化をアニメーション化する。

今回は Shared Element として TextView と ImageView を登録したので ChangeBounds と Change-Transform、 ChangeImageTransform のアニメーションを TransitionSet にセットして設定してやります。

class EndFragment : Fragment(R.layout.fragment_end) {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val transition = TransitionSet().apply {
            addTransition(ChangeBounds())
            addTransition(ChangeTransform())
            addTransition(ChangeImageTransform())
        }
        sharedElementEnterTransition = transition
        sharedElementReturnTransition = transition
    }
}

おわりに

実装したアプリを起動すると次のような感じになります。Shared Element として登録した TextView と ImageView が滑らかにスケールが変化するようになっています。このように Shared Element Transition を利用すると画面遷移したときにでもシームレスに View を表示できるようになります。

Shared Element Transiton なし Shared Element Transition あり
Image from Gyazo Image from Gyazo

今回作成したソースコードはこちらにあります。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【入門】AndroidStudio Hello Worldをエミュレータ上に表示させる

はじめに

AndroidStudioでプロジェクトをビルドして、HelloWorldをエミュレータ上に表示されるところまで確認したいと思います。AndroidStudioインストール後の動作確認として試してみます。

環境と前提条件

  • windows10(64bit)
  • AndroidStudio 3.6.3
  • AndroidStudio 日本語化済み
  • API 29(Android 10.0)

インストール方法はこちら
日本語化はこちら
を参考にしてみてください。

プロジェクト作成

まずはプロジェクトを作成します。

はじめに、AndroidStudioを起動します。
下記のようなウィンドウが開いたら新規AndroidStudioプロジェクトの開始を押下します。
2020-05-29 (23).png

AndroidStudioにはマップなど、いくつかのテンプレートが用意されています。
空のアクティビティーが最もシンプルで実装が簡単なので今回はこちらを選びます。
次へを押下します。
2020-05-29 (24).png

以下、それぞれ入力します。
- 名前:今回はHello Worldにしました。入力と同時にパッケージ名も自動で入力されます。
- パッケージ名:今回は自動入力されたものをそのまま使います。
- 言語:ネットに参考記事の多いJavaを選択しました。
- 最小SDK:最小SDKより古いOSにはアプリをインストールできない仕様になっているようです。一先ず、最新のAPI 29(Android 10.0)を選びます。API 28以下を選択する場合は、一通り動作確認できた後にチャレンジするほうが色々と理解しやすいと思います。
2020-05-29 (25).png

下記のようなウィンドウが開きました。
2020-05-29 (27).png

プロジェクトの作成は完了です。

AVDを作成する

ツール/AVD マネージャを開きます。
2020-05-29 (28).png

仮想デバイスの作成を開きます。
2020-05-29 (29).png

仮想ハードウェアを決めます。
ネットで調べて人気のありそうだったPixel 3aを選びました。他でも問題ありません。
2020-05-29 (30).png

システムイメージをダウンロードして選択します。
色々あり困惑しますが、、、

  • 最小SDKでAPI29を選択したのでAPI29以上(29しかないですね)の中から選びます。
  • 現在64bit端末が多く流通しているようなので、x86_64を選択してみます。尚、x86でも問題なく動作するはずです。
  • 一先ず、Google Playを選びます。今回の目的であればAPIsでも問題ありません。

違いの詳細はこちらの記事が分かりやすかったです。
では、対象のリリース名欄のDownloadを押下します。同時に、ダウンロードが始まります。
2020-05-29 (31).png

ダウンロードされるまでしばし待ちます。
2020-05-29 (32).png

完了を押下します。
2020-05-29 (33).png

この画面に戻ります。
ダウンロードしたイメージを選択し、次へ進みます。
2020-05-29 (34).png

AVD名を入力します。
AVDを複数作った際に混同すると厄介なので、余計なことはせずデフォルトのまま進めます。
2020-05-29 (35).png

AVDの作成が完了しました。ウィンドウを閉じます。
2020-05-29 (36).png

ビルドする

赤枠の三角マークを押下してビルドを開始します。
この時、上記で作成したAVD名が隣に表示されていることを確認します。
2020-05-29 (37).png

ビルドが始まると、赤枠部分にビルド実行中の表示が出ます。
表示が消えるまで待ちます。初回ビルドは結構長いです。
2020-05-29 (38).png

Androidエミュレータが別ウィンドウで起動し、HelloWorldと表示されたら動作確認完了です。
2020-05-29 (39).png

さいごに

HelloWorldがエミュレータ上に表示されることを確認しました。

参考にしたサイト

https://feel-log.net/android/api-level-target-version-min-version/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(13)Firebase Cloud Firestore使ってみた編

Firebaseの導入(Analytics/Crashlytics)Firebaseでのユーザー認証とやってきて、ようやくFirebaseのCloud Firestoreへの保存をやります。

今回の目標

Firebase Cloud Firestoreに簡単なデータを保存し、取得できる。
また、ユーザー認証と組み合わせてセキュリティルールを設定する。

Firebase Cloud Firestore概要

https://firebase.google.com/docs/firestore?hl=ja

Cloudと付いているとおり、クラウドへの保存です。まあつまりサーバーにデータがありますよってことです。
で、SQLiteのようなテーブルに絡むがあってSQLでクエリー発行して・・・というSQLデータベースではなく、NoSQLと言われています。

データ保存が出来るFirebaseの似たようなサービスに、Realtime Databaseというのがあります。
大きな違いは、Realtime DatabaseはJsonを保存していて、階層が深かったりデータ量が多かったりする複雑なデータだとクエリーなどが難しくなります。
Firestoreはドキュメントをコレクションとして保存し、サブコレクションを付けて階層化することが出来ます。
公式に違いを説明しているページがありますので、詳細はこちらを読んでください。
https://firebase.google.com/docs/firestore/rtdb-vs-firestore?hl=ja

まあ要するにFirebaseはFirestoreをオススメしてますよって感じです。
ちなみに、Googleに買収される前のFirebaseは、そもそもこのRealtime Databaseが目玉だったようです。

あともう一つ、Storageというサービスもあります。
https://firebase.google.com/docs/storage?authuser=0
こちらは、写真、動画、ドキュメントファイルなど、いわゆるファイル単位でアップロードして管理するものです。

実は、このシリーズでは、当初はこちらを使う予定でした。
というのも、「ユーザーがバックアップと復元を任意のタイミングで行うだけ」という方式を考えていたからです。Roomで使っているデータはSQLのテーブルですから、CSVで書き出せます。なので、その都度、CSVファイルを作ってアップロード、復元したければダウンロード、というように考えていました。
まあこれは「課金怖い」だったからというのもあるんですけどね^^;

でも、UploadTask自分でやんなきゃいけないのかとか、いろいろ調べていくうちに、「Firestoreの方が、(Roomとのデータコンバートは必要だけど)簡単そうだしリアルタイムにデータ同期できるのって楽しそうじゃん」となって、Firestoreに決めました。

写真や動画のコンテンツをユーザー同士でアップロードして共有するようなサービスだったら、Storageが良いのでしょうね。

Firestore Databaseの作成

こちらの手順に従ってやっていきます。
https://firebase.google.com/docs/firestore/quickstart?hl=ja#read_data

1.Databaseを作成

  • Firestoreコンソールのプロジェクトの左側のメニューから[Database]を選ぶ
  • [データベースの作成]をクリック

qiita13_01.png

2.セキュリティモデル

  • [テストモードで開始]を選ぶ
    • 注意書きにあるとおり、30日間だけユーザーがすべてのデータを見られます。当然、セキュリティがガバガバです。開発が終わったらちゃんとセキュリティを設定してねってことです。

qiita13_02.png

3.ロケーションの設定

  • 任意のリージョンを選ぶ
    • どこが良いとかはよく分かりませんが、ひとまずマルチリージョンである必要は、今回のプロジェクトではないでしょう。マルチリージョンだと、1つのリージョンで障害が起こってデータベースにアクセスできなくなっても、他のリージョンが生きていればサービスが止まることがなくなります。サービスの規模や要件に応じて検討しましょう。

qiita13_03.png

4.作成

[完了]をタップして、少し待ちます。多分VMをどこかに起動しているんでしょうね。

qiita13_04.png

終わると、次のようなページが表示されます。

qiita13_05.png

データベースの作成はこれだけです!
このコンソールから手動でデータを追加することも出来ますが、アプリからデータを登録するのをやっていきます。

デフォルトデータとか、アプリから端末に配信したいデータなんかは、ここで追加するか、Firebase CLIを使ってデプロイする、という運用になっていくのではと思います。

Firestoreを使ってみる

1.アプリに依存関係を設定する

app/build.gradleにいつものごとく依存関係を追加します。

app/build.gradle
    implementation 'com.google.firebase:firebase-firestore:21.4.3'

2.データを追加してみる

(1)Firestoreの初期化

Firestoreの初期化をしてインスタンスを取得します。
実験用にMainActivityのプロパティに持たせます。

MainActivity.kt
private val db = FirebaseFirestore.getInstance()

このdbに対して処理を呼び出していきます。

(2)データを登録する

さっそくFirestoreにデータを登録してみましょう。
適当な場所がないので、AnalyticsとCrashlyticsの回で使ったHasPetダイアログ再びといきましょうか。

登録データにはHashMapを使います。

MainActivity.kt
    /**
     * 犬を飼っているかの選択肢を送信
     */
    override fun onSelected(hasDog: Boolean) {
        analyticsUtil.setPetDogProperty(hasDog)
        if (!hasDog) {
//            // Crashlyticsに送るサンプル用
//            throw RuntimeException("Test Crash")
        }
        settingRepository.savePetDog(hasDog)

        // Firestoreお試し用
        val pet = hashMapOf(
            "petDog" to hasDog,
            "message" to "Test"
        )
        db.collection("pets")
            .add(pet)
            .addOnSuccessListener { documentReference ->
                Log.d("FIRESTORE", "DocumentSnapshot added with ID: ${documentReference.id}")
            }
            .addOnFailureListener { e ->
                Log.w("FIRESTORE", "Error adding document", e)
            }
    }

今回は、犬を飼ってないと答えても強制クラッシュはしないようにします(笑)

もう、これだけです。
面倒なテーブル定義など全く不要!

(3)実行

アプリを実行してみて下さい。既にインストール済みだった場合は、いったんアンインストールするか、データの削除を行って下さい。

ポップアップでは、どっちでもいいのでボタンをタップします。
すると、Logcatに以下のように出力されるはずです。

FIRESTORE: DocumentSnapshot added with ID: XXXXXXXXXXX

Firebaseコンソールにいくと、コレクションにpetsというのが追加されていますね。

qiita13_06.png

あっという間にデータベースにデータ保存が出来てしまいました!
しかもクラウドにです。
なんて簡単なんでしょう・・・
便利なRoomだってもうちょっと大変だったのに。

(4)コレクションにフィールドを追加する

コレクションにフィールド、すなわちkey-valueのセットを追加してみます。
そうですね、犬を飼ってると答えたかどうかで、登録する内容を変えてみましょう。

MainActivity.kt
        val pet =
            if (hasDog) {
                hashMapOf(
                    "petDog" to hasDog,
                    "message" to "Test",
                    "petName" to "Pochi",
                    "born" to 2018
                )
            } else {
                hashMapOf(
                    "petDog" to hasDog,
                    "message" to "Test"
                )
            }

アプリをアンインストールするかデータを削除してもう一度起動し、「はい」をタップしてみて下さい。

qiita13_07.png

コレクションが増えてますね。しかも先ほど追加したコレクションとフィールドが異なります(増えている)。
SQLなデータベースだと、テーブル構成はカチッと決まっていて、データを増やそうとしたらカラムを追加する必要があり、テーブル定義の変更となってしまいます。特にAndroidのSQLiteでは結構手間なんです、これが。

それが、NoSQLだと、こんな風に柔軟にコレクションにフィールドを追加できるんですね。
いやはや、なんとも便利です。
ただ、気をつけないと行けないのは、取り出す方でしょうね。すべてのフィールドがあるとは限らない、という実装にしないと落ちまくりのアプリが出来上がりそうです。
とはいえ、Kotlinなら、すべての要素をnullableとして扱うように実装しておけば、そういったことも防げますけれどね。

3.データを読み取る

次はクラウドのデータを読み込んでみます。
HasPetダイアログに回答済みだったら、クラウドのデータを取り込んでリスト表示しましょう。
リストの行レイアウトを考えるのはちょっと面倒なので(本筋でない)、単に文字列で出しますが、拘ってみたい人はトライしてみて下さい。

(1)データの読込コード

onCreateで設定ファイルにpet情報があるかチェックして、あればデータを読みに行くようにします。

MainActivity.kt
   override fun onCreate(savedInstanceState: Bundle?) {
        ...

        val hasPet = settingRepository.readPetDog()
        if (hasPet == null) {
            val dialog = SelectPetDialog()
            dialog.show(supportFragmentManager, null)
        } else {
            db.collection("pets")
                .get()
                .addOnSuccessListener { result: QuerySnapshot ->
                    val list = arrayListOf<HasPet>()
                    for (document in result) {
                        Log.d("FIRESTORE", "${document.id} => ${document.data}")
                        list.add(HasPet(document.data))
                    }
                    val dialog = ListDialogFragment.Builder(list).create()
                    dialog.show(supportFragmentManager, null)
                }
                .addOnFailureListener { exception ->
                    Log.w("FIRESTORE", "Error getting documents.", exception)
                }
        }
    }

db.collection("pets").get()で、コレクションデータ"pets"を取得し、addOnSuccessListeneraddOnFailureListenerはそれぞれ成功時、失敗時のコールバックを登録しています。

リストダイアログを表示するために、QuerySnapshotであるresultの数だけforを回してHasPetを要素に持つArrayListを得ています。

(2)Parcelableなクラス

HasPetクラスは、次のようにしました。

ListDialogFragment.kt
@Parcelize
data class HasPet(val map: @RawValue Map<String, Any>) : Parcelable {
    private val hasPet: Boolean
        get() {
            return map["petDog"] as Boolean
        }
    private val petName: String?
        get() {
            return map["petName"] as String?
        }
    private val born: Long?
        get() {
            return map["born"] as Long?
        }

    fun titleString(): String {
        return if (hasPet) {
            "$petName($born)"
        } else {
            "ペットを飼っていない"
        }
    }
}

@Parcelizeというのは、Parcelableなクラスに付けるKotlin拡張プラグインのアノテーションです。Parcelableというのは、IntentBundlesetXXXputXXXといった関数が用意されていない型のオブジェクトを入れたいときに使えるAndroid独自のインターフェースです。Serializaleでも良いのですが、今回HashMapを入れるためにParcelableを使ってみることにしました。

Parcelableはインターフェースですから、本来、実装しなければならない関数がいくつかあります。でも結構単純なクラスでも長ーくなるんですよね。その割には、コードは割とボイラーテンプレートというか、お決まりのものになりがち。
そこで、自動的にコード生成してくれるのが、@Parcelizeのアノテーションです。

あとはMapから必要な情報を取り出すプロパティアクセスとそれらのプロパティを組み合わせて文字列を作る関数です。

(3)リストダイアログ

リストダイアログというものが正式にクラスであるわけではありません。リストを表示するダイアログ、というだけです。
AlertDialogはリスト表示を簡単にさせることが出来るので、今回はそれを使ってみます。

このアプリの本筋では使わないダイアログですが、リストでのダイアログ表示は覚えておくと便利なので是非やってみて下さい。

ListDialog.kt
class ListDialogFragment : DialogFragment() {
    private val analyticsUtil: AnalyticsUtil by inject()

    class Builder(val list: ArrayList<HasPet>) {
        fun create(): ListDialogFragment {
            val d = ListDialogFragment()
            d.arguments = Bundle().apply {
                putParcelableArrayList(KEY_LIST, list)
            }
            return d
        }
    }

    companion object {
        const val KEY_LIST = "list"
        const val SCREEN_NAME = "ユーザーペット情報リスト"
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        // AlertDialogで作成する
        val builder = AlertDialog.Builder(requireContext())

        // メッセージの決定
        val list = arguments!!.getParcelableArrayList<HasPet>(KEY_LIST)!!.map { t -> t.titleString() }

        // AlertDialogのセットアップ
        builder.setItems(list.toTypedArray(), null)
            .setTitle(R.string.user_has_pet)
            .setIcon(android.R.drawable.ic_dialog_info)
        return builder.create()
    }

    override fun onResume() {
        super.onResume()
        activity?.let { analyticsUtil.sendScreenName(it, SCREEN_NAME) }
    }
}

HasPetParcelableなので、Bundle#putParcelableArrayListでセットできています。取り出すときはgetParcelableArrayListです。

このダイアログを表示するとこうなります。

qiita13_08.png

※デバッグ端末にOS4.4の実機を引っ張り出してみました^^;

もう少し踏み込んで使ってみる

1.認証して使う

今は認証をせずに使っていますが、実は、Firestore的には「匿名ログイン」というのを使っています。これを、Firebase Authenticationのユーザーログインと認証を関連付けていきます。

認証したユーザーだけが読み書きできるようにします。
この設定は、Firebaseのコンソールで行います。

Databaseのページに、「ルール」というタブがあるのでクリックします。

qiita13_09.png

何やらコードが書ける部分がありますね。
そこに次のように記入します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // とりあえずログインしたユーザーは読み書き可能
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}

request.authnullでなければ、つまりログイン済みユーザーであれば、読み書きが出来るというルールになります。

これで実行してみましょう。データを削除するか、一度アプリをアンインストールしてから再インストールします。

ダイアログで「いいえ」と回答しても、権限がないのでエラーがLogcatに出力されているはずです。

W/Firestore: (21.4.3) [Firestore]: Write failed at pets/WVtO6mZzyOqhf6LFRgJw: Status{code=PERMISSION_DENIED, description=Missing or insufficient permissions., cause=null}
W/FIRESTORE: Error adding document

アプリを再起動すると、データを取得してリストを表示しようとするはずですが、やはりエラーになります。

W/Firestore: (21.4.3) [Firestore]: Listen for Query(target=Query(pets order by __name__);limitType=LIMIT_TO_FIRST) failed: Status{code=PERMISSION_DENIED, description=Missing or insufficient permissions., cause=null}
W/FIRESTORE: Error getting documents.
    com.google.firebase.firestore.FirebaseFirestoreException: PERMISSION_DENIED: Missing or insufficient permissions.

ログインして、もう一度アプリを起動してみましょう。
リストが出るはずです。ログアウトして再起動すると、またエラーになるはずです。

エラーがLogcatだけで分かりづらいという人は、ErrorDialogやトーストメッセージで出してみると良いでしょう。

2.データを更新(上書き)

ペット選択ダイアログを「いいえ」にした人だけ、アプリ起動時に毎回選択ダイアログを表示し、「はい」と答えるまで毎回表示されるという意地悪仕様にしてみます。

そのためには、ダイアログ表示の条件を以下のように変更します。

        val hasPet = settingRepository.readPetDog()
//        if (hasPet == null) {
        if (hasPet != true) {

readPetDogの戻り値はBoolean?ですから、hasPetnullの可能性があります。が、!=trueで比較すると、nullの場合はこの比較結果値がtrueになるため、ダイアログ表示の分岐に入るというわけです。nulltrueとは等しくないので、当たり前ですね。

アプリをアンインストールして再インストール後再起動して、一度「いいえ」で保存した後、サインインします。アプリをもう一度再起動すると、リストダイアログでは無くまた質問ダイアログが表示されるかと思います。そこでまた「いいえ」を選択してみて下さい。(ここでやっと保存が成功するので)

で、またアプリを終了して再起動して、今度は「はい」にしてみます。
で、またアプリを終了して再起動します(酷い手順ですみませんw)

はい、「ペットを飼っていない」と、「Pochi(2018)」が1つずつ増えちゃったかと思います(汗)
毎回db.collection("pets").addしてるんだから、当然です。

データを更新するためには、DocumentReferenceというのを取得して、そのドキュメントを更新する、という手順を踏まなければなりません。ちなみに、db.collection("pets")というのはコレクション、その中の一つ一つのデータの集まりを、「ドキュメント」と言います。

さて、DocumentReferenceを得るには、どのドキュメントか、というのを特定する情報をアプリに保持しておかなければなりません。ドキュメントを追加したときに、DocumentReferenceを受け取っているのでそのidを保存しておくことにしましょう。

SettingsRepositoryに以下の関数を追加します。

SettingsRepository.kt
    fun saveDocReferenceId(refId: String) {
        val pref =
            applicationContext.getSharedPreferences(PREF_FILE_NAME, AppCompatActivity.MODE_PRIVATE)
        pref.edit().putString("docRefId", refId).apply()
    }

    fun getDocReferenceId():String? {
        val pref =
            applicationContext.getSharedPreferences(PREF_FILE_NAME, AppCompatActivity.MODE_PRIVATE)
        return pref.getString("docRefId", null)
    }

保存するのはここです。

MainActivity.kt
    override fun onSelected(hasDog: Boolean) {
            ...

            db.collection("pets")
                .add(pet)
                .addOnSuccessListener { documentReference ->
                    // document reference idを保存
                    settingRepository.saveDocReferenceId(documentReference.id)
                    Log.d("FIRESTORE", "DocumentSnapshot added with ID: ${documentReference.id}")
                }

ここで保存する前に、Preferenceに保存したIDが無いか取ってきます。

MainActivity.kt
    override fun onSelected(hasDog: Boolean) {
        ...

        settingRepository.savePetDog(hasDog)
        val savedDocReferenceId = settingRepository.getDocReferenceId()

savedDocReferenceIdnullだったら新規追加、保存されていれば上書きとします。

MainActivity.kt
        if (savedDocReferenceId == null) {
            // 新規登録
            db.collection("pets")
                .add(pet)
                ....
        } else {
            val docRef = db.collection("pets").document(savedDocReferenceId)
            // 上書き更新
            docRef.update(pet)
                .addOnSuccessListener {
                    Log.d("FIRESTORE", "DocumentSnapshot Updated.")
                }
                .addOnFailureListener { e ->
                    Log.w("FIRESTORE", "Error updating document", e)
                }
        }

これでもう一度、データを削除して手順を踏んでみましょう。
「いいえ」を選んだ後(未ログインなので保存できない)、ログインし、アプリを再起動、「いいえ」を選んでデータを保存、もう一度アプリを再起動して、今度は「はい」を選び、また再起動すれば、リスト表示が確認できます(我ながら面倒くさすぎるw)

今度はデータは1件しか増えていないはずです。

なお、今回はデータの保存と読み出しのタイミングが離れているので、ドキュメントIDをpreferenceに保存するという策を採りましたが、実際のアプリではまずしない方法では無いかと思います。というのも、例えばこのアプリではデータをカレンダーに表示することになりますが、その表示するデータを取ってきたときに毎回DocumentReferenceを取れますよね。だからDocumentReferenceをローカルに保存しておく必要が無いんです。この辺については次回に詳しくやります。
今回は、「触ってみる」を目的としていますので、こんな風にやるのか、どんな関数があるのか、という感じで捉えれば良いかと思います。

3.クエリ

さて、今は無条件にすべてのデータを取得していますが、条件検索、つまりクエリーをしてみます。

(1)Boolean比較

まあ条件といえば、hasPetを使うくらいしかありませんね。
全件検索だと、今はこのように「ペットを飼っていない」と飼っている情報が混ざって表示されています。

qiita13_10.png

これを、ペットを飼っている情報だけにしてみましょう。
あるフィールドが特定の値のものだけを抽出するには、whereEqualToを使います。これをget()の前に呼びます。

MainActivity.kt
           db.collection("pets")
                .whereEqualTo("petDog", true)
                .get()
                .addOnSuccessListener {...}

qiita13_11.png

「ペットを飼っていない」情報が取り除かれました^-^

(2)Long比較

せっかくなのでborn>2000みたいなクエリーもやってみましょう。
その前に、このままだとペットを飼っている情報はすべてborn=2018で引っかかってしまうので、ちょっとランダムで入れるようにしてみました。

HasPet.kt
@Parcelize
data class HasPet(val map: @RawValue Map<String, Any>) : Parcelable {
    ...

    companion object{
        val names = listOf("Hachi", "Coma", "Suzuri")
        val years = listOf(1923, 2006, 2018)
        val messages = listOf("Test", "Sample", "Cute")
        fun randomPet() : HashMap<String, Any>{
            val i = Random(System.currentTimeMillis()).nextInt(3)
            return hashMapOf(
                "petDog" to true,
                "message" to messages[i],
                "petName" to names[i],
                "born" to years[i]
            )
        }
    }
}
MainActivity.kt
    override fun onSelected(hasDog: Boolean) {
        ...

        val pet: HashMap<String, Any> =
            if (hasDog) {
                HasPet.randomPet()
            } else {
                ...
            }

これで、何度かアプリ再インストールしてダイアログで「はい」と答えてみてください。
手順が面倒で済みません。気になる人は何度も登録可能にしてみてください^^;
ちなみに、adbコマンドで以下のようにすればアプリのデータ削除が簡単に行えます。

$ adb shell pm clear パッケージネーム

debugビルド用にパッケージにSuffixId付けてる人は付け忘れないようにしてくださいね〜

いくつかデータが登録できたら、検索条件を変えてみましょう。
">"は、whereGreaterThanを使います。

MainActivity.kt
           db.collection("pets")
                .whereGreaterThan("born", 2000)
                .get()

残念ながら"Hachi"さんは生まれが昔すぎるので表示されません^^;
(もはや何のアプリだかw)

hasPet=falseのデータ、つまりbornが無いデータは引っかからないようですね。

4.もう少しセキュリティ保護をちゃんとする

今はルールはこうなっている状態です。

  • write : 認証済みユーザーのみ
  • read : 認証済みユーザーのみ

これを次のように変えてみます。

  • write : 認証済みユーザーのみ
  • read : all

readのルールだけ変えれば良さそうですね。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // とりあえずログインしたユーザーは読み書き可能
    match /{document=**} {
      allow write: if request.auth != null;
      allow read: if true
    }
  }
}

if trueで、権限無しに読み取れることになります。

アプリの方は、今のままだと「飼っている」と答えるまでリストが表示出来ないので、ダイアログで「はい」や「いいえ」を選択したら直ぐにリストを表示することにしてみます。
まず、リストでのダイアログ表示部分をprivateな関数に出します。
(クエリー条件はいったん無くしました。)

MainActivity.kt
    private fun showPetListDialog(){
        db.collection("pets")
//                .whereEqualTo("petDog", true)
//                .whereGreaterThan("born", 2000)
            .get()
            .addOnSuccessListener { result: QuerySnapshot ->
                val list = arrayListOf<HasPet>()
                for (document in result) {
                    Log.d("FIRESTORE", "${document.id} => ${document.data}")
                    list.add(HasPet(document.data))
                }
                val dialog = ListDialogFragment.Builder(list).create()
                dialog.show(supportFragmentManager, null)
            }
            .addOnFailureListener { exception ->
          Toast.makeText(this, "データを読み込めませんでした。", Toast.LENGTH_SHORT).show()
                Log.w("FIRESTORE", "Error getting documents.", exception)
            }
    }

これを、onCreateonSelected(ペット選択ダイアログのコールバック)で呼ぶようにします。

MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val hasPet = settingRepository.readPetDog()
        if (hasPet != true) {
            val dialog = SelectPetDialog()
            dialog.show(supportFragmentManager, null)
        } else {
            showPetListDialog()
        }
        ...
    }

    override fun onSelected(hasDog: Boolean) {
        ...

        if (savedDocReferenceId == null) {
            // 新規登録
            db.collection("pets")
                .add(pet)
                .addOnSuccessListener { documentReference ->
                    // document reference idを保存
                    settingRepository.saveDocReferenceId(documentReference.id)
                    Log.d("FIRESTORE", "DocumentSnapshot added with ID: ${documentReference.id}")
                }
                .addOnFailureListener { e ->
                    Toast.makeText(this, "登録できませんでした。", Toast.LENGTH_SHORT).show() // 追加
                    Log.w("FIRESTORE", "Error adding document", e)
                }
        } else {
            val docRef = db.collection("pets").document(savedDocReferenceId)
            // 上書き更新
            docRef.update(pet)
                .addOnSuccessListener {
                    Log.d("FIRESTORE", "DocumentSnapshot Updated.")
                }
                .addOnFailureListener { e ->
                    Toast.makeText(this, "更新できませんでした。", Toast.LENGTH_SHORT).show() // 追加
                    Log.w("FIRESTORE", "Error updating document", e)
                }
        }
        showPetListDialog() // 追加
    }

ついでに、Firestoreの各処理後のaddOnFailureListenerで、エラー時にはToast出すようにしておきました。

アプリデータをクリーンしてから実行すると、「いいえ」を押したときにエラーのToastが表示されますが、リストは表示されるはずです。
これで、未ログイン状態でも、リストは見られるようになりました。
(いったい何のアプリ^^;)

実際のアプリでは、「自分が作ったデータのみ自分が読み書きできる」にする予定なのでまた違ってきますが、ルールの書き方の参考にはなると思います。

テスト

今回はアプリの本質と関係ない実験的コードなので、テストは入れません。
逆に、テストを動かすときにペット選択ダイアログが出るとまずいので、CI等をpushで回している人はコメントアウトなどを忘れずに^^

ライブラリ内参照モジュールのexclude指定

リリースビルドするときに、以下のようなエラーが出ていました。

##[error]Process completed with exit code 1.
           Dependency path 'qiita_pedometer:app:unspecified' --> 'androidx.room:room-testing:2.2.3' --> 'androidx.room:room-migration:2.2.3' --> 'com.google.code.gson:gson:2.8.0'
           Constraint path 'qiita_pedometer:app:unspecified' --> 'com.google.code.gson:gson:{strictly 2.7}' because of the following reason: debugRuntimeClasspath uses version 2.7
           Dependency path 'qiita_pedometer:app:unspecified' --> 'com.google.firebase:firebase-firestore:21.4.3' --> 'io.grpc:grpc-android:1.21.0' --> 'io.grpc:grpc-core:1.21.0' --> 'com.google.code.gson:gson:2.7'

どうやら、androidx.room:room-testingが参照しているgsonのバージョンと、Firestoreが参照しているgsonのバージョンが違くてコンフリクトしているようです。
テストパッケージであるandroidx.room:room-testingの方のGsonは最終ビルド成果物には不要なので、exclude(除外指定)というのをします。

app/build.gradle
    androidTestImplementation ("androidx.room:room-testing:$room_version"){
        exclude group: 'com.google.code.gson'
    }

このように、参照しているライブラリが増えてくると、その中で参照しているライブラリに同じ物があることはよくある現象になります。そのバージョンが違うとビルドが通らなくなってしまうのは、本来意図しない方のバージョンを参照してしまって実行時に不定な動作になってしまう可能性があるので、事前に検出して教えてくれているわけです。

ですので、excludeした場合バージョンは統一されるので(今回の場合Firestoreが参照している方のGsonのバージョンがパッケージされます)、ビルドは通るようになりますが、本来別のバージョンを参照してビルドされているパッケージ(今回の場合はroom-testing)は動作がおかしくなる可能性があります。なのでその後の動作確認はよく行う必要があります。今回で言えば、room-testingはテスト用パッケージなので、Room関連のUnitTestをよく確認する必要がありますね。

実は、この「バージョン合わせ」は、大きなプロジェクトであるほど結構難しいハードルになってきます。
あるライブラリAのバージョンを最新に上げようとしたら、中で参照している別のライブラリBのバージョンが上がってしまうが、Bを参照しているライブラリCも一緒に最新に上げる必要が生じます。しかしライブラリCの最新版がまだ対応してないとか、最新版で削除された機能をまだ使いたいので上げられない、とか・・・
そんなジレンマが多発するようになります。
更に、ここ数年でGoogleも「常に最新OSに対応したアプリじゃないと(=TargetSDKが最新でないと)登録・アップデートさせてあげないよ」と言ってきているので、そこを上げようとすると付随していろいろ上げなきゃならなくなり、しかしサードパーティー製がまだ追いついてなかったりで、結構大変なことになります。

そんなわけで、依存関係のバージョン合わせは、精神的にしんどい作業です(汗)

まとめ

ここまでで、「Firestoreを使ってみた」は終わりです。
意外に簡単だったのではないでしょうか?
Roomなどのローカルデータ保存をせず、初めからクラウド保存ありきで考えておくのもありですね。ただ、UnitTestしづらそうですが^^;

ここまでのコードは以下のブランチにアップしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_13

予告

アプリの本質に関係ないデータのやりとりを見てきましたが、次回はRoomで保存してきたデータをFirestoreに上げられる形に直して、読み書きをするところを実装していきます。

参考サイトなど

KotlinでのParcelableについて参考になりました。
https://qiita.com/sadashi/items/fd902619f5b3491e969f

FirestoreのCodelabです。
https://codelabs.developers.google.com/codelabs/firestore-android

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む