20200514のAndroidに関する記事は7件です。

Flutter初心者が100日でアプリリリースするまでの記録

この記事は

Flutterの初心者が100日後にアプリリリースするまでの軌跡()を綴ったものになります。
また、Flutterでアプリ開発をしてみて思った所感などを書いていきます。

Flutterからプログラミングやってみようかな? とか
アプリ開発始めてみようかな? とか
Flutterに少しでも興味がある方は特に是非読み進めてください。

何故書こうと思った?

実はもう100日チャレンジを始めてから58日が経過しています。何故このタイミングなのか?
,,, 思いつきで書き始めました。

強いていうなら、今Flutterについて結構盛り上がっていると思うんですよ、Youtubeとかtwitterとか某フリーランスエンジニアの方とかね。それに乗っかろうかなと思ったわけです。

開発者(筆者)スペック

専門学校で4年間プログラミング、データベース、ネットワーク等々を学び、今年の春から一部上場企業のWebエンジニアとして働いています。
研修中ですが、優しい先輩に囲まれ充実した日々を送っています。(なんか会社に提出する用みたいですね)

個人開発は、ちょうど約2年ほど前から初めて,最初に触ったものはSwiftでした。当時からいろいろな言語に手を出すのが好きだった私は、Swiftさんも私の標的になってしまったわけですね。

そんなこんなで今年の2月頭に1作目となる Symplist(AppStoreで配信中)をリリースしたばかりです。

好きな言語はGo,Python3,Dartです。(記事書いている時点では。こんなものはすぐに入れ替わります。)

早速ですがFlutterを始めてみて

結論から言うと、「Flutterめっちゃ書きやすいし楽しいしビジネスとしても結構使えそうで良い!」

こう思った理由は大きく分けて3つありまして、

まず1つめですが、IOSとAndroid両方とも基本的に一つのコードで成立してしまう。

実際に実装し始めてみるとわかるんですが、これが結構感動する。今までIOSもAndroidもリリースしようと思ったら2つコードを書かないと行けなかったですし、何かと不便でした。(2つの言語が楽しめるという利点もありますが。)

次にCSSライクでUIを構築することができる

これは結構でかいんじゃないですかね。ワードプレスやらなんやらが流行っている時代ですからきっとCSSも書ける方多いと思うんです。
flexなんちゃらとか、Alignなんちゃら、padding margin うんぬんカンヌン 親と子の関係が常に成立しているって言うのもなんか似てますよね HTMLっぽいと言うか。

最後に、個人開発業として収入を得やすいのでは?

正直リリースしたアプリに広告貼り付けたことないので、広告収入等どんな感じで流れてくるのかはハッキリわかりませんが、flutterで開発してIOSでもAndoroidでもリリースしてしまえば、自分が作ったアプリを少時間で多くの方に使ってもらえる。その結果広告収入も多く入る可能性が高いと言うところですよね。

少時間でと言うのは SwiftとKotlinでわざわざ開発しなくても良いと言うことです。

ただ、これだと完全に個人開発業としての話であって、「Flutterでフリーランスに俺はなる!!」とかはまだ厳しいのではないかなと思ってます。
って言うのはまだ案件が少なすぎるんですよね。それならSwiftとかKotlinとか勉強してしっかりコード書けるようになった方が断然良いです。

まとめると、「個人開発で一人でFlutter使って楽しみながらアプリ作って広告収入得るとかは全然ありだけど、ガチで案件探して本気で仕事にしたいとかはまだ厳しいのでは?」です。

これに関しては色々な方の意見聞きたいので是非コメントまで。

どこが課題?

Flutter書きやすいし、楽しいし良いです。しかし完璧ではないです。

そりゃ欠点もあります。人とおんなじです。

IOSでの挙動が少し重い

最近のアップデートで結構軽くなったみたいなんですが、体感やや少し重いです。モーダルとか開くとき結構カクつきます。でもこの辺は解消まで時間の問題な気もするのでそこまで重要視する点ではないのかも(僕のコードが悪い可能性も十分ありますし)

仕事として始めるのはやや不向きである

この辺りはさっきも書いたのですが、自分から仕事探しに行こうとしたら結構難しいと思います。
本気でアプリ開発で仕事をしたいとかだったらFlutterは一旦勉強せずに、Swiftとかやる方をオススメします。

でも、スタートアップの企業さんで結構Flutter取り入れているところとかあったりするんです実は。僕もお声かけいただいたことあります。
なので少しずつ流れは来てるのかな ~ と思うところもあるのですが、この辺りは全然ハッキリしないです。

オワコン化してくる可能性がなきにしもあらず

今までIOS,Androidどっちも1つのコードで作ることができるフレームワークは他にもありましたよね。覚えてますか?覚えてないですね。僕も覚えてないです。

でも、覚えてないってことはそう言うことなんですよね。要はオワコン化していって誰にも使われなくなるんです。
Flutterもその可能性はないとは言い切れないです。日々情報が入れ替わる業界ですから。

なのでFlutterで仕事する際もこの辺も意識して選ぶ必要があると思います。
分析大事です。

残りの42日は?

そうですね 最初の方に100日後にリリースするまでの軌跡()と書いたので、気が向いたら続編と言うかFlutter系の記事もちょくちょく上げて行こうかなと思います。

最後に

そこまで長い記事ではないですが、ここまで読んでくださってありがとうございます。

Flutterバンザイ。

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

LINE Developer Meetup for Android #62 メモ書き

【Online】LINE Developer Meetup for Android #62 で気になったところだけメモ。
(最近メモばっか書いてんな…)

APKファイルはいかにして作られるのか

  • Android は端末の種類が多くて実行環境がコレ!って決まってないから JVM 上で動くやつ(.dex)を実行する
  • コンパイル
    • aapt2: AndroidManifestとリソースファイル
      • *.flat を生成
        • R.java
        • proguard-rules.pro
    • R8 & D8
      • classes.dex を生成
      • コード圧縮
      • リソース圧縮
      • 難読化
      • 最適化
      • desugar(D8がやってくれる)
  • 署名
    • debug は AndroidStudio が自動で生成してくれたやつで署名
  • apkbuilder: コンパイルしたものをまとめる
  • zipalign: 読み込み速度とメモリ消費を抑える

Androidアプリに潜む脆弱性 LINEの場合

  • 脆弱性対策
    • PRレビュー
    • 専門チームによるレビュー
      • API設計、実装コード、STG環境でのチェック
      • 社外報告者による報告
        • Bounty Program
    • 脆弱性の公表
  • LINE Android にあった脆弱性(修正済みのもの)
    • SQL インジェクション
      • 不正なメッセージで起こせる
      • 受信したらアプリがクラッシュ
    • SQLite Journal Mode
      • トランザクション中の操作を管理するための方法
      • Android 4.1.1 から PERSIST になっていたせい(それまではデフォルト DELETE だったのかな、きっと)
    • WebView からのリソースアクセス
      • リソースの差し替えが可能
      • JS 経由で盗聴
      • HTTP のページを読み込んでいた
      • HTTPS 化
        • HTTP のページは外部ブラウザひらくなど
      • JS の無効化
    • 書換可能なファイルでのデバッグフラグ
      • ユーザーID とかが Logcat にでちゃう
      • /sdcard 配下にそのフラグをおいていた
    • TextView での HTML タグ
      • ユーザー名に HTML タグが入ってると反映されちゃう
    • パストラバーサル
      • ACTION_SEND で受け取った内容をメッセージで表示するところで起きてた
      • 「..」「.」が入ってたらエラー
  • セキュリティチームが報告された脆弱性をみて、サービスの影響度などを考慮して開発チームに依頼がくる

パスワードのない未来のためのFirebaseで実装するFIDO2

  • パスワード認証のリスクいろいろ
  • FIDO
    • FIDO Alliance が仕様策定、標準化する認証プロトコル
    • FIDO に対応したデバイスなら OK
    • 端末側に保存される公開鍵を使って認証する
    • パスワード必要なし、デバイスが盗まれなければなりすましのリスク低
  • WebAuthn
    • FIDO2
  • Firebase Auth
    • カスタム認証でやる
  • Cloud Functions
    • FIDO サーバとして利用
  • Firestore
    • Challenger、Credential の保存で利用
  • Firebase のアプリ登録のとき、フィンガープリントを設定する
    • assetlinks.json が生成される
  • Fido2ApiClient という API がある
    • android-safetynet限定?
  • 重要な箇所でサインイン処理はさせたほうがいいよね
    • 決済とか
  • 複数の端末を1アカウントに紐づけるには
    • デバイスの移行とか FIDO の仕様みてね

Android 11 最新情報

  • 位置情報
    • バックグラウンドの権限を要求できる回数に制限がある
      • あれ、制限があるのは位置情報だけなのか、全部なのかと思ってた。。
  • データアクセス監査
    • どの機能でどの情報を使用しているのか追えるようになるやつっぽい
  • フォアグラウンドで利用するサービス(カメラ、マイク、位置情報)を manifest に書く必要がある
  • FLAG_ACTIVITY_REQUIRE_NON_BROWSER
    • ネイティブアプリで開かせたいときに flags に指定する
  • 自分のアプリの設定画面を直接開くことはできない
    • バブルに移行したほうがいい
  • システムの動作影響をテストする
    • 開発者オプションでいっこいっこオン/オフできるらしい
  • IMEのアニメーションコールバックの指定、フレームレートを変更できる、アプリオ終了ステータスを取れる
  • バブル
    • System Alert Window の代替
    • バブル用のアクティビティを作って、必要なプロパティをセット
    • BubbbleMetadata をセットして、Notification に設定して表示
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Web2App】ウェブページからアプリで開くをFirebase Dynamic Linksを使って簡単に実装する方法

概要

WEBからAPPに誘導する所謂Web2AppをFirebase Dynamic Linksを利用して
インストールしている・していないに関わらず一つのリンクで挙動を変えて
インストールされていない場合はストアに、されている場合は特定のページに遷移させる流れをざっくり書いておきます。

詳しいことは公式ドキュメントに書いてあるのでこちらを見てください。
Firebase Dynamic Links

page.linkの作り方

  1. FirebaseコンソールのレフトメニューからDynamicLinkに遷移
  2. 使いたいドメインを入力(小文字と数字のみを含む 3~100 文字で****.page.linnkの形で入力)

※適当にやっちゃうと、消したあとそのドメインは1ヶ月間使えないので慎重に

独自ドメインを使った方法もあるので使いたい方は公式ドキュメントを見てください
Dynamic Links のカスタム ドメインを設定する - Firebaseの公式ドキュメント

リンクの作成

4つの方法がありますが、今回の要件ではWEB2APPでアプリを開くだけでいいので
URLを短くする必要もないため手動で作成します。

ダイナミックリンクの作成 - Firebaseの公式ドキュメント
ダイナミックリンクURLを手動で構築する - Firebaseの公式ドキュメント

iOS

キー 説明
link 開きたいWEBのURL
isi アプリストアのID                  
ius     アプリのディープリンク(バンドルIDと同じ場合は省略可能)
ibi    アプリのバンドルID(Firebase コンソールの [概要] ページで、アプリとプロジェクトが接続されている必要がある)    

用途
アプリで開きたい画面に対応したURL https://example.com/contents/9999
開きたいアプリのバンドルID com.example.app
開きたいアプリのストアのID 999999999
https://example.page.link/?link=https://example.com/contents/9999&ibi=com.example.app&isi=999999999

ストアではなくLPなどWEBページを開きたい場合に使うオプションや
ipadで挙動を分けるオプションや
プレビューページを挟まないオプションなど詳しくは公式ドキュメントを見てください

ダイナミックリンクURLを手動で構築する - Firebaseの公式ドキュメント

Android

キー 説明
link 開きたいWEBのURL
apn Androidアプリのパッケージ名

用途
アプリで開きたい画面に対応したURL https://example.com/contents/9999
開きたいアプリのパッケージ名 com.example.app
https://example.page.link/?link=https://example.com/contents/9999&apn=com.example.app

合体

実際に利用する際はiOSとAndroidどちらでも動くようにしたいので、混ぜます。

https://example.page.link/?link=https://example.com/contents/9999&ibi=com.example.app&isi=999999999&apn=com.example.app

キャンペーンコードなどの利用も出来るので詳しくは公式ドキュメントを見てください。
ダイナミックリンクURLを手動で構築する - Firebaseの公式ドキュメント

アプリ側の処理

iOS

  1. PodでFirebaseDynamicLinkをインストール
  2. Associated Domains に 上記で作ったドメインを追加。今回だと applinks:example.page.link
  3. application:continueUserActivity:restorationHandler: に処理を追加
  4. application:openURL:options: に処理を追加

詳しくは公式ドキュメントに書いてあるのでそちらを見てください。
iOSでダイナミックリンクを受信する - Firebaseの公式ドキュメント

Android

  1. GradleでFirebaseDynamicLinkをインストール
  2. AndroidManifestの開きたいActivityにintent-filterを追加
  3. 開きたいActivityに処理を追加

詳しくは公式ドキュメントに書いてあるのでそちらを見てください。
iOSでダイナミックリンクを受信する - Firebaseの公式ドキュメント

検証方法

  1. 該当アプリをアンインストールしておく
  2. 作成したリンクをタップ
  3. ストアに遷移(ここではインストールしない)
  4. 実機にインストールしたりFirebaseAppDistributionなどでインストール
  5. 該当ページに遷移
  6. アプリのプロセスを落とすなどして最初の画面に戻す
  7. 作成したリンクをタップ
  8. 該当ページに遷移

以上です。

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

[Android] Room (SQLite) でテーブルからビューを生成する

はじめに

SQLite のラッパーライブラリである Room を利用して、あるテーブルからビューを生成してみたいと思います。今回は 2つのテーブルから、1つのビューを生成しようと思います。次のような User と Repo という 2つのテーブルを作成し、そこから UserRepo という 1つのビューを生成する感じのものです。

Image from Gyazo
※ ビューを生成するだけために UserId を主キーとする User と Repo のテーブルを定義してます。内容に関しては意味があるわけではありません。

準備

Roomのセットアップ方法は[Android]Roomを使ったサンプルと解説 で紹介していますので参考にしてセットアップします。

実装

User テーブルを作成する

User 情報のためのテーブルを users というテーブル名称で作成します。 User は主キーとして ID を持ち、その他に FirstName や LastName を持つユーザー情報になります。

User.kt

@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = false) val id: Int,
    val firstName: String?,
    val lastName: String?
)

テーブルの操作には Dao が必要になります。なので Insert, Update, Delete, DeleteAll, GetAll を備えた標準的な Dao を定義します。

UserDao.kt

@Dao
interface UserDao {
    @Insert
    fun insert(user : User)

    @Update
    fun update(user : User)

    @Delete
    fun delete(user : User)

    @Query("delete from users")
    fun deleteAll()

    @Query("select * from users")
    fun getAll(): List<User>

    @Query("select * from users where id = :id")
    fun getUser(id: Int): User
}

Repo テーブルを作成する

Repo 情報のためのテーブルを repos というテーブル名称で作成します。 Repo は主キーとして User のID を持ち、その他に Name を持つレポジトリ情報になります。

Repo

@Entity(tableName = "repos")
data class Repo(
    @PrimaryKey val userId: Int,
    val name: String?
)

テーブルの操作には Dao が必要になります。なので Insert, Update, Delete, DeleteAll, GetAll を備えた標準的な Dao を定義します。

RepoDao.kt

@Dao
interface RepoDao {
    @Insert
    fun insert(repo : Repo)

    @Update
    fun update(repo : Repo)

    @Delete
    fun delete(repo : Repo)

    @Query("delete from repos")
    fun deleteAll()

    @Query("select * from repos")
    fun getAll(): List<Repo>

    @Query("select * from repos where userId = :userId")
    fun getRepo(userId: Int): Repo
}

UserRepo ビューを生成する

users と repos テーブルから UserRepo ビューを作成していきます。Room でビューを作成するには @DatabaseView をつけ、viewName にはビュー名称、value にはどのようなビューを生成するのか記述した SQL 文を記述します。

SQL記述の際に気をつけること

  • SQL 文に含めるデーブル名称は、各テーブルの定義で指定した名称にする
  • SELECT で指定した属性名、クラスで定義したプロパティ名が一致しなければならない。
@DatabaseView(
    viewName = "UserRepo",
    value = """
        SELECT users.id, users.lastName, users.firstName,
        repos.name AS repoName FROM users
        INNER JOIN repos ON users.id = repos.userId
    """
)
data class UserRepo(
    val id: Int,
    val lastName: String?,
    val firstName: String?,
    val repoName: String?
)

ビューを操作するにも Dao が必要になります。ビューを操作する Dao では
@Insert@Delete@Update などは使えません。そのため全て @Query で操作を記述する必要があります。今回は GetAll と GetUserRepo という単純な操作を定義しました。

@Dao
interface UserRepoDao {
    @Query("select * from UserRepo")
    fun getAll(): List<UserRepo>

    @Query("select * from UserRepo where id = :id")
    fun getUserRepo(id: Int): UserRepo
}

RoomDatabase を作成する

定義したテーブルとビューを操作するには RoomDatabase を実装したクラスが必要です。RoomDatabase の実装でやらなければいけないことは 2つです。

1つ目ですが、@Databse アノテーションをつける必要があります。@Database アノテーションを付け、entities には利用するテーブル、 views には利用するビューを指定してやります。

2つ目ですが テーブルとビューを操作する Dao を取得するメソッドを実装する必要があります。実装ですが簡単で Dao を戻り値とする関数を定義するだけです。

@Database(entities = [User::class, Repo::class], views = [UserRepo::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun repoDao(): RepoDao
    abstract fun userRepoDao(): UserRepoDao
}

これで RoomDatabase の定義が終わり、テーブルとビューを操作できるようになりました。

動作確認

実装が終わったのであとは動作確認してみましょう。定義した RoomDatabase を初期化し、それぞれのテーブルやビューの Dao を取得します。そして新たに User と Repo の情報をテーブルに追加した上で、UserRepo ビューから取得してみます。

class MainActivity : AppCompatActivity() {
    private val database : AppDatabase by lazy {
        Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database-name"
        ).fallbackToDestructiveMigration().build()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        CoroutineScope(Dispatchers.IO).launch {
            testForUserRepo()
        }
    }

    private fun testForUserRepo() {
        val userDao = database.userDao()
        val repoDao = database.repoDao()
        val userRepoDao = database.userRepoDao()

        val newUser = User(0, Date().time.toString(), Date().time.toString())
        val newRepo = Repo(newUser.id, Date().time.toString())

        userDao.insert(newUser)
        repoDao.insert(newRepo)
        Log.v("TAG", "get view ${userRepoDao.getAll()}")

        userDao.delete(newUser)
        repoDao.delete(newRepo)
        Log.v("TAG", "get view ${userRepoDao.getAll()}")
    }
}

実行すると意図したとおり、 User と Repo があわさった UserRepo が取得できました。

V/TAG: get view [UserRepo(id=0, lastName=1589440137149, firstName=1589440137149, repoName=1589440137160)]
V/TAG: get view []

おわりに

Room でもビューは定義できました。説明した通りテーブルといくつか異なる点があるので注意が必要ですね。作成したコードは下記に保存しているので興味があれば閲覧してみてください。

参考文献

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

[Android] Room (SQLite) でテーブルからビューを生成する

はじめに

SQLite のラッパーライブラリである Room を利用して、あるテーブルからビューを生成してみたいと思います。今回は 2つのテーブルから、1つのビューを生成しようと思います。次のような User と Repo という 2つのテーブルを作成し、そこから UserRepo という 1つのビューを生成する感じのものです。

Image from Gyazo
※ ビューを生成するだけために UserId を主キーとする User と Repo のテーブルを定義してます。内容に関しては意味があるわけではありません。

準備

Roomのセットアップ方法は[Android]Roomを使ったサンプルと解説 で紹介していますので参考にしてセットアップします。

実装

User テーブルを作成する

User 情報のためのテーブルを users というテーブル名称で作成します。 User は主キーとして ID を持ち、その他に FirstName や LastName を持つユーザー情報になります。

User.kt

@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = false) val id: Int,
    val firstName: String?,
    val lastName: String?
)

テーブルの操作には Dao が必要になります。なので Insert, Update, Delete, DeleteAll, GetAll を備えた標準的な Dao を定義します。

UserDao.kt

@Dao
interface UserDao {
    @Insert
    fun insert(user : User)

    @Update
    fun update(user : User)

    @Delete
    fun delete(user : User)

    @Query("delete from users")
    fun deleteAll()

    @Query("select * from users")
    fun getAll(): List<User>

    @Query("select * from users where id = :id")
    fun getUser(id: Int): User
}

Repo テーブルを作成する

Repo 情報のためのテーブルを repos というテーブル名称で作成します。 Repo は主キーとして User のID を持ち、その他に Name を持つレポジトリ情報になります。

Repo

@Entity(tableName = "repos")
data class Repo(
    @PrimaryKey val userId: Int,
    val name: String?
)

テーブルの操作には Dao が必要になります。なので Insert, Update, Delete, DeleteAll, GetAll を備えた標準的な Dao を定義します。

RepoDao.kt

@Dao
interface RepoDao {
    @Insert
    fun insert(repo : Repo)

    @Update
    fun update(repo : Repo)

    @Delete
    fun delete(repo : Repo)

    @Query("delete from repos")
    fun deleteAll()

    @Query("select * from repos")
    fun getAll(): List<Repo>

    @Query("select * from repos where userId = :userId")
    fun getRepo(userId: Int): Repo
}

UserRepo ビューを生成する

users と repos テーブルから UserRepo ビューを作成していきます。Room でビューを作成するには @DatabaseView をつけ、viewName にはビュー名称、value にはどのようなビューを生成するのか記述した SQL 文を記述します。

SQL記述の際に気をつけること

  • SQL 文に含めるデーブル名称は、各テーブルの定義で指定した名称にする
  • SELECT で指定した属性名、クラスで定義したプロパティ名が一致しなければならない。
@DatabaseView(
    viewName = "UserRepo",
    value = """
        SELECT users.id, users.lastName, users.firstName,
        repos.name AS repoName FROM users
        INNER JOIN repos ON users.id = repos.userId
    """
)
data class UserRepo(
    val id: Int,
    val lastName: String?,
    val firstName: String?,
    val repoName: String?
)

ビューを操作するにも Dao が必要になります。ビューを操作する Dao では
@Insert@Delete@Update などは使えません。そのため全て @Query で操作を記述する必要があります。今回は GetAll と GetUserRepo という単純な操作を定義しました。

@Dao
interface UserRepoDao {
    @Query("select * from UserRepo")
    fun getAll(): List<UserRepo>

    @Query("select * from UserRepo where id = :id")
    fun getUserRepo(id: Int): UserRepo
}

RoomDatabase を作成する

定義したテーブルとビューを操作するには RoomDatabase を実装したクラスが必要です。RoomDatabase の実装でやらなければいけないことは 2つです。

1つ目ですが、@Databse アノテーションをつける必要があります。@Database アノテーションを付け、entities には利用するテーブル、 views には利用するビューを指定してやります。

2つ目ですが テーブルとビューを操作する Dao を取得するメソッドを実装する必要があります。実装ですが簡単で Dao を戻り値とする関数を定義するだけです。

@Database(entities = [User::class, Repo::class], views = [UserRepo::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun repoDao(): RepoDao
    abstract fun userRepoDao(): UserRepoDao
}

これで RoomDatabase の定義が終わり、テーブルとビューを操作できるようになりました。

動作確認

実装が終わったのであとは動作確認してみましょう。定義した RoomDatabase を初期化し、それぞれのテーブルやビューの Dao を取得します。そして新たに User と Repo の情報をテーブルに追加した上で、UserRepo ビューから取得してみます。

class MainActivity : AppCompatActivity() {
    private val database : AppDatabase by lazy {
        Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database-name"
        ).fallbackToDestructiveMigration().build()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        CoroutineScope(Dispatchers.IO).launch {
            testForUserRepo()
        }
    }

    private fun testForUserRepo() {
        val userDao = database.userDao()
        val repoDao = database.repoDao()
        val userRepoDao = database.userRepoDao()

        val newUser = User(0, Date().time.toString(), Date().time.toString())
        val newRepo = Repo(newUser.id, Date().time.toString())

        userDao.insert(newUser)
        repoDao.insert(newRepo)
        Log.v("TAG", "get view ${userRepoDao.getAll()}")

        userDao.delete(newUser)
        repoDao.delete(newRepo)
        Log.v("TAG", "get view ${userRepoDao.getAll()}")
    }
}

実行すると意図したとおり、 User と Repo があわさった UserRepo が取得できました。

V/TAG: get view [UserRepo(id=0, lastName=1589440137149, firstName=1589440137149, repoName=1589440137160)]
V/TAG: get view []

おわりに

Room でもビューは定義できました。説明した通りテーブルといくつか異なる点があるので注意が必要ですね。作成したコードは下記に保存しているので興味があれば閲覧してみてください。

参考文献

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

コピペでできるチュートリアル風TextView(android)

dynamic_textview.gif

文字列が動的にカタカタと追加されていって、もう一度クリックするとまとめて表示する、といったtextviewの拡張を実装しました。
手軽なライブラリとかありそうですがどうなんでしょう
まずコピペすべくコードは

TextView.kt
import android.os.Handler
import android.widget.TextView

fun TextView.showTextDynamically(text: String, milliSec: Int, afterShow: (() -> Unit)? = null): (() -> Unit) {
    val charArr = text.toCharArray()
    var isShowing = false
    var isEnd = false
    return {
        // 再起関数で1文字ずつ表示する
        fun addChar(idx: Int) {
            if (idx == charArr.count()) {
                afterShow ?: return
                afterShow()
                return
            }
            Handler().postDelayed( {
                if (!isEnd) {
                    this.text = this.text.toString() + charArr[idx]
                }
                addChar(this.text.length)
            }, milliSec.toLong())
        }
        // 表示中に二度目のクリックされたらtextをまとめて表示する
        if (isShowing) {
            isEnd = true
            this.text = text
        } else {
            isShowing = true
            addChar(0)
        }
    }
}

↑このファイルを追加してください。

ちなみに使いかたですが、
一度関数をオブジェクト化して使います。
引数には表示したいテキストと、一文字表示するごとの間隔をミリ秒で入れます。
そして最後にテキストを表示し終えたタイミングで何かしたい場合は、カッコ内に書き込めば使えるようになっています(省略可能です)

※クリックリスナーの中にすべて書いてしまうと、クリックするたびに新しい関数オブジェクトが生成されてしまって想定した動きになりません。
関数オブジェクトは一度だけ生成してください。そしてその関数オブジェクトを一度目の実行で文字がカタカタと入力されていって、二度目の実行(今回は二度目のボタンクリック)で文字が一気にすべて入力される仕組みになっています。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        setTutorialText()
    }

    private fun setTutorialText() {
        button.text = "スタート"
        // test用文字列
        val testText = "abcdefghijklmnopqrstuvwxyzあいうえおかきくけこさしすせそ"
        // 関数をオブジェクト化する
        val dynamicallyShowMethod = dynamic_textview.showTextDynamically(testText, milliSec=100) {
            // 表示完了後にここを通ります(設定しなくても可)
            button.text = "完了"
        }
        // buttonをクリックされたら上記関数を実行
        button.setOnClickListener {
            dynamicallyShowMethod()
        }
    }
}

これはほぼ雑談なのですが、今回、拡張メソッド内でフラグを使っています。
今までずっとフラグはメンバ変数に置かなければならないと思っていましたが、
これの何がよくないかというと
①フラグで一部にしか使われないのにわざわざメンバ変数を追加したらコードが汚れる
→スコープが無駄に広くなってしまいます。少なくとも今回のフラグはこのメソッド内でしか使われないのでメンバ変数として置くのは余計ですよね
②クラスの拡張においてメンバ変数は定義できないので、フラグをメンバ変数におくにはカスタムクラス(クラスを継承したクラス。今回の場合はTextView)を作らなければならない
→わざわざカスタムクラスを作ってそれを継承するというのは手間ですし汎用性がさがるので、できれば拡張して「しれっと使えるように」したいです。

普段使わないような文法が多いので初学者の方がこのコードを一つ一つ理解しようとしたら大変かと思います。
・関数内関数
・再起関数
・クロージャ
・関数オブジェクト
このあたりを検索してみたら勉強になるかと思います。

実は僕自身、関数内にフラグを保持してコードの品質を保つというやり方は、昨日リーダブルコードという本を読んでいて甚く感激したのでこれを使ったコードを書きたくなったんで、昨日知りましたwww
リーダブルコード、賛否ありますが僕はかなり好きです。

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

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(11) Firebase導入編(Firebase Analytics)

以下の記事の続きです。

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(10)
AndroidアプリでCIツール-Jenkins/CircleCI編

かなり時間が空いてしまいました。
Flutterにだいぶ浮気していました(笑)

よろしければご覧下さい。
FlutterアプリをPlayストアに登録してみた
FlutterアプリをiOS版ビルドに必要な手順のまとめ(debug/release)とTestFlightに上げるまで

他にもFlutter記事を多数上げています。

さて、Firebaseにデータ保存するのをやっていこうと思います。
FirebaseのFirestoreというのを使います。クラウドへのデータ保存ですね。データを保存するには、その前にアカウントを作ってもらって、認証するのが良いでしょうね。たとえば他の端末でも同じアプリを入れたら同じデータが見られるとか。
それには、Firebase Authenticationを使って認証していこうと思います。

・・・が、その前に、準備編として、Firebase導入、その接続の確認として、Analytics, Crashlyticsを入れていきます。

今回の目標

FirebaseSDKを導入し、Analytics、Crashlyticsの確認が出来る。

環境など

Gradleプラグインのバージョンを上げてあります。もし前回までのままでビルドできないなどあったら、上げてみて下さい。

root/build.gradle
classpath 'com.android.tools.build:gradle:3.6.3'
gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip

Kotlinも最新版に上げました。

root/build.gradle
    ext.kotlin_version = '1.3.72'

Firebase超概要

https://firebase.google.com/

アプリやウェブサービスで有用ないくつものクラウドサービスを提供しているGoogleのmBaaS(mobile Backend as a Service)です。

サーバーレスで色んなことが出来ます。
昔はサードパーティーのサービスでしたが、Googleに買収され、成長を続けています。

特に個人や小規模な人数でアプリを開発するときには、サーバーを立てたり等のバックエンド側の準備、開発がネックになってきたりもします。それらのおおよその部分をほとんど省くことが出来るので、大変有り難いサービスです。

1.Analytics概要

Googleといえば、Google Analyticsですね。サイト上のユーザー行動を解析して、それらを広告に連動させ、広告収入を得るのが、Googleさんの一番の収入源です。
その行動解析用のデータ収集をモバイルアプリで簡単に出来るようにしてくれるのが、Firebase Analyticsです。
※以前はGoogle Analyticsのモバイル向けSDKがGoogleから出ていましたが、廃止されており、現在はFirebase Analyticsに統一されています。

2.Crashlytics概要

こちらはもとはFabric社が行っていた、クラッシュレポートサービスです。
https://get.fabric.io/
↑Good bye言われてる^^;

これもGoogleがFirebaseに吸収しました。

クラッシュレポートサービスの何が嬉しいかって、スタックトレースが見られるので、「JavaコードのXXクラスのMM行目でYYY Exceptionですよ」ってのが分かることですね。で、それらが一定期間中に何件発生しているかとか、OS別の集計とか、そういったことを出してくれます。

そうすると、対応を急がなければならないクラッシュ、様子見して良いクラッシュなど、対応の優先付けが出来て便利なわけです。
特に、リリースされたアプリは通信瞬断や他のアプリの割り込み等の、開発者が単独でそのアプリだけを使っているときには起こりえない/想定してテストしづらい状況が起こって、それらに起因するクラッシュというのが目に見えて分かります。

コードを追ってるだけだとnullは有り得ないけど、非同期実行されていて実はあり得て、ものすごい量のnullチェックが必要だったとか、そんなことも発覚したりもします。

Firebaseの導入

GoogleアカウントまたはGsuitのアカウントがあれば、Firebase Consoleにログイン出来ます。
https://console.firebase.google.com/?hl=JA

1.Firebaseプロジェクトの作成

  • Firebaseコンソールにログインしたら、[+プロジェクトを追加]をクリック

firebase_add_project.png

  • プロジェクト名を入力
    • 英数字の小文字のみ使えます。記号は使えません。ハイフン-、アンダーバー_、不可です。
  • [続行]をクリック
  • [このプロジェクトで Google アナリティクスを有効にする]が有効なのを確認

    • GAのアカウントを用意してない場合は、ここで作るように言われると思います。
  • [続行]をクリック

少し待つと、プロジェクトページに遷移します。

2.Androidアプリの登録

トップページで、ドロイド君をタップします。

firebase_add_app.png

(1)リリース用パッケージ名の登録

アプリのパッケージ名(リリース用)と、表示用の名称(これは日本語可)を入れます。
デバッグ証明書のSHA-1は今は特に不要です。

firebase_app_info.png

debugビルド用にapplicationIdSuffixを使っている場合、以下は今はスキップします。

  • play-services.jsonのダウンロード
  • Firebase SDK の追加
  • 接続の確認

[コンソールに進む]をクリックしていったん終了させて下さい。

使っていない場合は、(3)play-servicesjsonのダウンロードから、そのまま続けて行って下さい。

(2)debugアプリの登録

debugビルド用にapplicationIdSuffixを使っている場合に行います。
上記と同じようにAndroidアプリのパッケージ名と表示名を入力し、[アプリを登録]をクリックするところまで進めます。

(3)play-services.jsonのダウンロード

  • play-services.jsonをダウンロード
  • プロジェクトルート/app下に置く

(4)アプリにFirebaseを設定する

プロジェクトルート下にあるbuild.gradleに追記します。

root/build.gradle
  dependencies {
      ...
      classpath 'com.google.gms:google-services:4.3.3' // 追加
  }

app下にあるbuild.gradleに追記します。

app/build.gradle
apply plugin: 'com.google.gms.google-services' // ファイルの上の方
andorid{
}

※昔はbuild.gradleの一番下に書かなければならなかったけど、いつの間にか変わったようです。

Gradle Syncをします。

(5)通信権限をアプリに追加

追記 2020/05/15
アプリのマニフェストファイルを編集します。
※しなくてもdebubビルドは通信してしまいますが、releaseビルドで権限が無く落ちます。

AndroidManifest.xml
<manifest
    ...
    <uses-permission android:name="android.permission.INTERNET"/>

    <application

(6)接続確認

アプリをビルドして、起動します。
接続確認が取れたら、[コンソールに進む]をクリックして設定を完了します。

もし、接続確認がどうしても終わらない場合は、いったんスキップして、次のDebugViewの設定を行ってみて下さい。

2.DebugView

Firebase Analyticsへの送信は通信量やバッテリー負荷などを軽減するためある程度情報が溜まってからまとめて送られているそうなのですが、それをデバッグ用途に逐次送るようにする、という設定をすることが可能です。

この設定がONだと、FirebaseのDebugViewというページで、発生したイベントをほぼリアルタイムで見ることが出来るようになります。

debugview.png

(1)Android端末に設定する

ターミナルなどで以下のコマンドを実行し、その後アプリを再起動する。
パッケージ名は、debugビルドの場合でsuffixを付けている場合は、ちゃんと付けたものを指定して下さい。

adb shell setprop debug.firebase.analytics.app パッケージ名

不要になったら、以下のように実行する

adb shell setprop debug.firebase.analytics.app .none.

こうしておくと、逐次送る設定が解除されます。

(2)Debug Viewで確認

アプリをある程度操作してからDebugViewを見ると、こんな風にイベントが随時届きます。

android_debugview.png

Firebase Analyticsでイベントを送信する

以下の最低限のイベントを送信しておくことにします。

1.画面名報告

Debug Viewを見ていると気付いたかと思いますが、Activityを遷移するとそのクラス名が送られているのが分かるかと思います。
このままでも良いですが、ここを日本語にしてみようと思います。
また、同一Activity内でFragmentFragmentの遷移がある場合に、同じように使うことが出来ます。

一番良い方法は、ActivityFragmentonResumeで画面名報告イベントを送ることです。onCreateonStartだと、まだアクティブなActivityがないということで送信はエラーになってしまうので、onResumeでやりします。

直接毎回FirebaseのAPIを呼んでも良いのですが、DIすることを考えてラッパークラスを作っておきます。

パッケージはutilsとか作りましょうか。

(1)utilsパッケージを作成

  • AndroidStudioのパッケージルートで右クリックし、[New]-[Package]と選ぶ

qiita11_02.png

  • 任意のパッケージ名を入力する。例:utils
  • [OK]をクリック

続いて、Util.ktというファイルを、そのパッケージに移動しておくことにします。

  • Util.ktutilsパッケージにDrag&Drop

qiita11_03.png

  • [OK]をクリック

ちょっと時間がかかりますがダイアログが消えるまで待ちます。

無事、移動しました。

qiita11_04.png

  • test下のUtil.ktも同様にutilsパッケージを作って移動

(2)Analytics用のラッパークラスを作成

utilsパッケージで右クリックし、[New]-[Kotlin File/Class]でClassを選び、AnalyticsUtilと入力します。

utils/AnalyticsUtil.kt
import android.app.Activity
import android.app.Application
import com.google.firebase.analytics.FirebaseAnalytics

class AnalyticsUtil(app: Application) {

    private val firebaseAnalytics: FirebaseAnalytics = FirebaseAnalytics.getInstance(app)

    fun sendScreenName(activity: Activity, screenName: String, classOverrideName: String?) {
        firebaseAnalytics.setCurrentScreen(activity, screenName, classOverrideName)
    }
}

(3)Koinモジュールに追加

Koinやったの覚えてますか?
モジュールを追加してインスタンスが注入されるようにします。

modules.kt
// FirebaseService
val firebaseModule = module{
    single{ AnalyticsUtil(androidApplication()) }
}

// モジュール群
val appModules = listOf(
    viewModelModule
    , daoModule
    , repositoryModule
    , providerModule
    , firebaseModule // 追加
)

モジュール群のリストに追加するのも忘れずに。
上記のような書き方をしておくと、後でリストに追加していくのがちょっとだけ楽です。

(4)Activityで画面名報告を送る

Activityクラスで、Koinを使ってインジェクトします。
そしてonResumeをオーバーライドし、AnalyticsUtil#sendScreenNameで報告します。

MainActivity.kt
class Activity... {
    companion object {
        ...
        const val SCREEN_NAME = "トップ画面" // 追加
    }

    // AnalyticsTool inject by Koin
    val analytics:AnalyticsUtil by inject() // 追加


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

    // 追加
    override fun onResume() {
        super.onResume()
        analytics.sendScreenName(this, SCREEN_NAME)
    }
}

他のActivityにも追加しましょう。

なお、Activity追加する度に全く同じコードを追加していくことになるので、アナリティクス 送信に限っては、基底クラスを作ってそこから全部派生させるようにするのもアリです。
ただ、基底クラスを作ると全部そこに処理を入れたくなって肥大していく可能性があるので、使う場合は注意が必要です。

base/BaseActivity.kt
// Analytics送信を基底クラスに持たせる場合のサンプル
abstract class BaseActivity : AppCompatActivity() {

    abstract val screenName: String

    // AnalyticsTool inject by Koin
    val analytics: AnalyticsUtil by inject()

    override fun onResume() {
        super.onResume()
        analytics.sendScreenName(this, screenName)
    }
}

Kotlinでは、プロパティも抽象クラスに持たせ、overrideすることが出来ます
これは、「プロパティに見えているけど、実はsetter/getter関数を見えないように使っている」と考えるとなんとなくしっくりくるかと思います。

使う場合はこうなります。

MainActivity.kt
class MainActivity : BaseActivity() {
    companion object {
        ...
        const val SCREEN_NAME = "トップ画面"
    }
    override val screenName: String
        get() = SCREEN_NAME

まあ、onResumeのオーバーライドしなくて良くなくなる程度ですが、送信コードの追加し忘れなんかは防止できますね。

以下のActivityに対して、設定を行いました。

  • MainActivity
  • InstagramShareActivity
  • TwitterShareActivity

(5)Fragmentで画面名報告を送る

Fragmentでもやることは同じです。
たとえば、このアプリはこれまで作ってきた形通りだとすると、InputActivityは、LogEditFragmentLogInputFragmentのどちらかが表示されます。両方とも「入力画面」という画面名で送っても良ければそれでも良いのですが、ここでは変えて送ってみることにします。

FragmentもActivityと同様、基底クラスを作ってそこに集約することにします。

base/BaseFragment.kt
import androidx.fragment.app.Fragment
import jp.les.kasa.sample.mykotlinapp.utils.AnalyticsUtil
import org.koin.android.ext.android.inject

abstract class BaseFragment : Fragment() {

    abstract val screenName: String

    // AnalyticsTool inject by Koin
    val analytics: AnalyticsUtil by inject()

    override fun onResume() {
        super.onResume()
        activity?.let { analytics.sendScreenName(it, screenName) }
    }
}

activityのnullチェックを一応しておきます。
使う方もActivityの場合と同じです。

LogInputFragment.kt
class LogInputFragment : BaseFragment() {

    companion object {
        ...
        const val SCREEN_NAME = "ログ編集画面"
    }

    // 画面報告名
    override val screenName: String
        get() = SCREEN_NAME

以下のFragmentに設定しました。

  • LogInputFragment
  • LogEditFragment

(6)Dialogで画面名報告を送る

DialogもDialogFragmentを使っていれば通常のFragmentと同じです。

ErrorDialog.kt
class ConfirmDialog : DialogFragment(), DialogInterface.OnClickListener {

    private val analytics: AnalyticsUtil by inject()

    override fun onResume() {
        super.onResume()
        activity?.let{ analytics.sendScreenName(it, "エラーダイアログ")}
    }

でもエラーダイアログはエラーメッセージも分かるようなイベントを送った方が良いかも知れませんね。
それに、ダイアログを閉じたときに元の画面に戻った報告が来ないので、ダイアログはダイアログで別な送り方をした方が良さそうです。
ということで、Githubにアップしたるコードでは、現時点で使われていないConfirmDialogには入れているものの、ErrorDialogには入れていません。

色々画面遷移したり、ダイアログを表示させたりしてから、Debug Viewを見てみて下さい。

screen_viewというのを開くと、パラメーターがたくさん出てきます。
そのなかの、firebase_screenを開くと、送った画面名が入っているはずです。
firebase_previous_screenなどがあって、前にどの画面にいたかも分かるようになっていますね。

qiita11_05.png

※本記事を作成中に、Calendarクラスの拡張関数clearTextに不具合があることが分かって、以下のように修正しています。

Util.kt
fun Calendar.clearTime(): Calendar {
    set(Calendar.HOUR_OF_DAY, 0) // HOURから修正
    set(Calendar.MINUTE, 0)
    set(Calendar.SECOND, 0)
    set(Calendar.MILLISECOND, 0)
    return this
}

HOURのクリアだと、AM/PMが切り替わっていませんでした。従って、午後に実行すると、「正午」にセットされることになっており、LogInputFragmentでのlogInputValidationで不具合が起きていました。

2.ボタンイベント

ボタンをタップしたらイベント報告を送信します。
ボタン名をパラメータで送ります。

(1)Analyticsクラスにラッパーメソッドを作成

イベントを送信するには、Firebase Analytics APIのlogEventを使います。

AnalyticsUtil.kt
    /**
     * ボタンクタップイベント送信
     */
    fun sendButtonEvent(buttonName: String) {
        val bundle = Bundle().apply { putString("buttonName", buttonName) }
        firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SELECT_ITEM, bundle)
    }

logEventの第一引数は、FirebaseAnalytics.Eventで定義済みのイベント名を使うか、直接文字列で指定してカスタムイベントを送ることも出来ます。
ボタンタップは、定義済みのFirebaseAnalytics.Event.SELECT_ITEMを使うことにします。
パラメータとして、buttonNameを送ります。
パラメーターは一括してBundleに入れて送ります。

(2)ボタンタップイベントを送信する

ボタンタップイベントに実際にAnalyticsを送信するコードを入れていきましょう。
例えば、入力画面の日付選択ボタン、登録ボタンなどです。

LogInputFragment.kt
        contentView.button_update.setOnClickListener {
            validation()?.let {
                ErrorDialog.Builder().message(it).create().show(parentFragmentManager, null)
                return@setOnClickListener
            }
            analytics.sendButtonEvent("登録ボタン")
   ...
        }

        // 日付を選ぶボタンで日付選択ダイアログを表示
        contentView.button_date.setOnClickListener {
            analytics.sendButtonEvent("日付選択ボタン")
            DateSelectDialogFragment().show(parentFragmentManager, DATE_SELECT_TAG)
        }

バリデーションが通ってから送るかどうかは仕様次第ですが、ここはチェックがOKな時のみ送ることにします。

select_itemのイベントに、カスタムパラメーターとして送った"buttonName"がいることがDebug Viewで確認できます。

qiita11_06.png

編集画面には、更新、編集ボタンと、シェアメニューボタンがありますね。
同じように追加しておきましょう。

ボタン以外のチェックボックスやスピナー、スイッチは、イベントリスナーを登録していないので今回は送りません。
送る必要がある場合は、それぞれにイベントリスナーを登録してその中でアナリティクス送信をすれば良いでしょう。

ちなみに、Bundleで送っているパラメータですが、最大25個まで送信できます。

3.シェアイベント

Twitterシェア画面、Instagramシェア画面はまたちょっと別のイベントを送ることにします。
定義済みのFirebaseAnalytics.Event.SHAREが定義済みなので、これを使って、TwitterやInstagramシェア画面イベントを送信することにします。
パラメーターに、TwitterかInstagramか付けることにします。

TwitterShareActivity.kt
    private fun post(message: String) {
        ...
        try {
            startActivity(intent)
            analytics.sendShareEvent("Twitter")
        }catch(e: ActivityNotFoundException){
             ...
        }

Twitterは公式アプリだけを探しているのでActivityNotFoundExceptionが起こる可能性がありますが、InstgramはIntent.createChooserで共有画面をOSで出させているため、try-catchはありません。そのまま、startActivityしたあとにイベントを送れば良いでしょう。

InstagramShareActivity.kt
            startActivity(Intent.createChooser(share, "Share to"))
            analytics.sendShareEvent("Instagram")

4.その他のカスタムイベント

ボタン以外のイベントを送ることも考えてみます。
例えば、カレンダーのセルをタップしたとき。エラーダイアログの内容。などなどです。

Firebase Analytics APIのlogEventの第一引数に、カスタムイベント名をセットすれば良いだけです。

AnalyticsUtil.kt
    /**
     * カレンダーセルタップイベント送信
     */
    fun sendCalendarCellEvent(date: String) {
        val bundle = Bundle().apply { putString("date", date) }
        firebaseAnalytics.logEvent("calendar_cell", bundle)
    }
}

MonthlyPageFragmentonItemClickで呼んでやります。

MonthlyPageFragment.kt
    override fun onItemClick(data: CalendarCellData) {
        analytics.sendCalendarCellEvent(data.calendar.getDateStringYMD())
qiita11_07.png

なお、MonthlyPageFragmentは画面名は送らないことにします。なので、アナリティクス送信をする基底クラスを作っていたとしても、MonthlyPageFragmentはそれを継承しないようにします。

5.UserPropertyとAudience

Analyticsが威力を発揮するのは、UserPropertyを使ってAudience(条件に該当するユーザーのリスト)を作るときです。
実は、Analyticsが送られているとき、同時にUserPropertyも送られています。

自動で収集されている内容は、以下のページから確認が出来ます。
https://support.google.com/firebase/answer/6317486?hl=ja

これ以外に、アプリに固有のプロパティを作っておくと、イベント送信時に同時に送信することが出来ます。

例えば、アプリを開始するときに、「犬を飼っているか」というアンケートを表示して、その結果をUserPropertyに入れたとします。
そして、アプリの運用で「犬を飼っている人」にだけpushを送ったり、なんてことが出来ます。

UserPropertyを使うと何が便利かっていうのは、こちらの記事などが参考になります。
https://qiita.com/rmakiyama/items/5abb0064677dbc65a94e

(1)ユーザープロパティを作る

まず最初にFirebaseコンソール上でユーザープロパティを作る必要があります。

  • プロジェクトの左側のメニューにUser Propertiesというのがあるので、そこをクリック

qiita11_09.png

  • [新しいユーザープロパティ]をクリック
  • 英数小文字とアンダーバー('_')のみで名称を付ける
  • 説明を任意で入力する
qiita11_08.png
  • [作成]をクリック

これで、ユーザープロパティを作成できました。

(2)アプリでユーザープロパティの値を設定する

Firebase Analytics APIのsetUserPropertyを使います。

AnalyticsUtil.kt
    /**
     * ユーザープロパティ設定の例
     */
    fun setPetDogProperty(hasDog: Boolean) {
        firebaseAnalytics.setUserProperty("pet_dog", hasDog.toString())
    }

例えばこれを、アプリの初回起動時にダイアログか何か出して、質問するとします。
その結果を受けて、この関数を呼び出しておきます。

MainActivity.kt
class MainActivity : BaseActivity(), SelectPetDialog.SelectPetEventListener {
    ...
    val settingRepository: SettingRepository by inject()

    ...

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

    /**
     * 犬を飼っているかの選択肢を送信
     */
    override fun onSelected(hasDog: Boolean) {
        analytics.setPetDogProperty(hasDog)
        settingRepository.savePetDog(hasDog)
    }

SelectPetDialogは簡単なので自分で書いてみてください。
リポジトリにはアップしておきます。

実行して、アンケートに[はい]と答えると、Debug Viewで次のように表示されました。

qiita11_10.png

Debug Viewの右下にある[現在アクティブなユーザープロパティ]にも、pet_dogが追加されました。

qiita11_11.png

(3)Audienceを作ってみる

  • プロジェクトの左側のメニューにAudiencesというのがあるので、そこをクリック

qiita11_12.png

すでにAll UsersというのとPurchasersというのが作成されていますね。これらのリストはデフォルトで必ず作成されます。

なお、購入レポートは、自動的に上がってくるはずで、その時、このPurchasersリストに「購入済みユーザー」として追加されます。
自動で収集されるイベントについては、以下を参考にしてください。
https://support.google.com/firebase/answer/6317485?hl=ja

以下、自分で作成する手順です。

  • [オーディエンス]をクリック
  • [カスタムオーディエンス]をクリック
  • [新しい...]と見えているドロップダウンリストをクリック
    • "新しい条件を追加"らしいです。マウスをホバリングさせておくとツールチップが出ます。
  • [ディメンション]-[登録済み]-[pet_dog]を選ぶ
qiita11_13.png
  • 任意の設定をする
    • 名称は日本語でも大丈夫そう
    • 説明も日本語可
    • 条件を、[完全一致 (=)] 、値=trueとする
    • 有効期間を、[上限に設定する]にする
    • 多分、「期間設定無し」、つまり無期限という意味だと思いますw
qiita11_15.png

FirebaseAnalyticsの仕様上、リストに該当するユーザー数が10名未満だと、詳細が表示されません。人数が少ないと個人が特定されやすくなってしまうからです。
なので、このリストにユーザーが追加されているのを確認するには、複数の端末でアプリを起動してアンケートに答えておく必要があります。エミュレーターなら、エミュレーターを作ってアプリを起動して設定後、いったんエミュレーターのデータをWipe Dataして再起動して、と同じことを繰り返すと、簡単にユーザー数を稼ぐことが出来ます。

qiita11_16.png

まあ、面倒ですけどね。
リストに入ったかちゃんと確認したい人は、是非やってみてください^^

エミュレーターの場合、起動する度にadb shell setprop debug.firebase.analytics.app パッケージ名しておくのを忘れないように。
実機の場合はネットにさえ繋がった状態でアプリをアンインストールしないで放置しておけばそのうち送信されるはずですが、気が短い人は端末をつなぎ替える度に同じようにセットをした方が良いでしょう(笑)

ちなみに、私はAndroid端末が4台あるのでそれらでやったのと、エミュレーターで6回ほど頑張ってみました。古いOSの端末で実行できるように、一時的にminSDKを16まで下げました。かなり重かったですが、なんとか動きました^^; (Firebase AnalyticsがminSDK16なので、それより下には下げられません)

12時間後の結果がこちらです。

qiita11_30.png

12回作業したようです(笑)
1人は「いいえ」にしてしまったようです。

(4)Audienceの利用

こうやって作ったAudienceは、例えばPush通知用のセグメンテーションに使えます。
まだPushを受け取るのは入れてないので、配信しても何も起きませんが、とりあえず作ってみましょう。
いずれpushの回はやるつもりですが、気が逸る方は、調べて入れてみて下さい。基本的には依存関係を1つ入れるだけです。ただ、それだと結構実運用には合わなかったりして細かい設定が必要になるので、別に回を設けてしっかりやるつもりです。

  • プロジェクトの左側のメニューから[Cloud Messaging]を選ぶ

qiita11_40.png

  • [Send your first message]をクリック
qiita11_41.png
  • 通知のタイトル、通知テキスト、通知名に任意のものを入力して、[次へ]をクリック
qiita11_42.png
  • ターゲット
    • [ユーザーセグメント]を選ぶ
    • [アプリ]で、任意のアプリをドロップダウンリストから選ぶ
    • [および]をクリック
    • [ユーザーオーディエンス]-[次を1つ以上含む]で、作成したオーディエンスを選ぶ
qiita11_44.png

とりあえずここまでです。今はpushを送っても何も起こりませんので^^;

「潜在なユーザーの○○パーセントがこのキャンペーンの対象になっています:人数」と表示されていて、どれくらいのユーザーに実際に通知が届くのかが分かりやすくなっていますね。

ただし、エミュレーターにいれてアンインストールしないでWipe Dataしたユーザーとかが母数に残るので、厳密な数字ではありません。あくまでも「目安」ですね。

こんな風にAudienceを活用してアプリの運用をしてユーザーに継続的に使ってもらうことを目指しましょう。

作りっぱなしのアプリは使って貰えませんよ^^;

(5)注意点

このUserPropertyAudience、実は結構使い勝手に工夫が必要なものです。
例えば、頻繁に代わるような属性にするのはあまり良くありません。リストがリフレッシュされるタイミングがあり、上手く拾えないことがあるからです。
また、あるAudienceを作っても、過去に遡って作成してくれません。Audienceを作成後、Analyticsを送ってきたユーザーに対して分類が行われるのです。

※更に昔は、あるAudience(当時はユーザーリストと呼んでいた)に入ったユーザーは、仮にアプリ内で属性を変えたとしても、二度とそのリストから抜けることが無かったんです。ただ、2018年頃の変更により、「動的なユーザーリスト」となったようで、これは解消されています。

また、UserPropertyは1プロジェクトに25までしか作成できないという制限もあります。更に作ったものは削除が出来ません。(アーカイブは出来るようになったようです)
Audienceは、50個まで。

よほどしっかりした設計の上で設定、使用しないと、簡単に破綻するのでご注意を。

Crashlyticsを導入する

1.アプリにCrashlyticsを設定する

(1)ルートbuild.gradleの変更

プロジェクトルート直下にあるbuild.gradleに以下を追加します。

root/build.gradle
    dependencies {
        // ...

        classpath 'com.google.gms:google-services:4.3.3'

        // 追加
        classpath 'com.google.firebase:firebase-crashlytics-gradle:2.1.0'
    }

(2)アプリのbuild.gradleの変更

app/build.graldleにプラグインを設定します。

  • ファイルの上の方に以下の記述を追加
app/build.gradle
apply plugin: 'com.google.firebase.crashlytics'

android{ ...
  • 依存関係に以下を追加
app/build.gradle
dependencies {
     ...
     implementation 'com.google.firebase:firebase-crashlytics:17.0.0'
}

Gradle Syncをしておきます。

2.強制的にクラッシュを送ってみる

(1)アプリで強制的にクラッシュさせる

普通にやってたんではなかなか意図的にクラッシュは起こせないので、ここは強制的にクラッシュさせます。

どこか任意の場所で、以下を呼びます。

    throw RuntimeException("Test Crash")

とりあえず先ほどの犬を飼ってるか聞くダイアログで、「いいえ」をタップしたらクラッシュするようにしてみました(どんな罠w)

MainActivity.kt
    /**
     * 犬を飼っているかの選択肢を送信
     */
    override fun onSelected(hasDog: Boolean) {
        analytics.setPetDogProperty(hasDog)
        if (!hasDog) {
            Crashlytics.getInstance().crash()
        }
        settingRepository.savePetDog(hasDog)
    }

[いいえ」を選択するとクラッシュしてセーブされないので次回起動時にまた聞かれるという(笑)

取り敢えず、実行してみます。
最初は静かに落ちるだけですが、2回以上クラッシュさせると、このようなポップアップが出ます。
これは別にCrashlyticsの機能ではなくて、AndroidOSの機能ですがね。

qiita11_17.png

コンソールログはこんな感じでスタックトレースが出ています。

    --------- beginning of crash
E/AndroidRuntime: FATAL EXCEPTION: main
    Process: jp.les.kasa.sample.mykotlinapp.debug, PID: 7268
    java.lang.RuntimeException: Test Crash
        at jp.les.kasa.sample.mykotlinapp.activity.main.MainActivity.onSelected(MainActivity.kt:125)
        at jp.les.kasa.sample.mykotlinapp.alert.SelectPetDialog.onClick(SelectPetDialog.kt:52)
        at androidx.appcompat.app.AlertController$ButtonHandler.handleMessage(AlertController.java:167)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:193)
        at android.app.ActivityThread.main(ActivityThread.java:6669)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)

(2)Firebaseコンソールで確認する

プロジェクトの左側のメニューに[Crashlytics]というのがあるのでそれをクリックします。

qiita11_18.png

ページ左上のドロップダウンリストから、デバッグ用のアプリを選びます。

qiita11_19.png

取り敢えず2回ほど起こしただけだと、「クラッシュ無し統計情報」は低下しましたが、「問題」の詳細が出てきません。

qiita11_20.png

少し時間をおいた方が良いのかも知れません。
30分位待ったらやっと出ました。
※アプリを次に再起動したときに送る、という説明があります。

qiita11_21.png

タップすると詳細が見られます。

qiita11_22.png

デバイス情報や、スタックトレースが見られます。

[ログ]タブを見てみて下さい。

qiita11_23.png

Analyticsのイベントが順番に送られていますね。なのでユーザーの行動を詳細にイベント送信していればしているほど、ここで障害発生手順が分かりやすくなるというわけです。

(3)カスタムログを送る

Crashlyticsでは、カスタムログを送ることが出来ます。
たとえば、今のクラッシュだと、「どこで」落ちたかは、スタックトレースから分かりますが、「なぜ」までは分かりません。
そこで、ダイアログの選択をした瞬間に、どっちを選んだかというカスタムログを送ることにします。

SelectPetDialog.kt
   override fun onClick(dialog: DialogInterface?, which: Int) {
        // 例外の原因を追いやすくするためどちらを選んだかユーザー操作をCrashlyticsに記録する
        FirebaseCrashlytics.getInstance().log("select_pet_dog = $which")  // (a)
        try {
            val listener = activity as SelectPetEventListener
            listener.onSelected(which == DialogInterface.BUTTON_POSITIVE)
        } catch (e: ClassCastException) {
            FirebaseCrashlytics.getInstance().recordException(e) // (b)
            Log.e(
                "SelectPetDialog",
                "Activity should implement ConfirmEventListener!!"
            )
        }
    }
  • (a)は、このあとクラッシュする可能性があるのでその原因となり得る選択肢を前もってログに入れています。

  • (b)は、アプリの実装上例外を握りつぶしているけど、Crashlyticsにはその報告を上げている例です。

デバッグ実行中はLog.eで吐いている内容で気付けば良いですが、もし見落としていた場合に、リリースアプリでも、Crashlyticsに上がってくれれば気付きやすいでしょう。

(a)のカスタムログが例外と一緒に送られたサンプルです。

qiita11_24.png

(b)のスルーした例外のログのサンプルです。一時的にMainActivityのSelectPetEventListenerの継承をやめ、コールバック関数はコメントアウトして実行しました。

qiita11_25.png

詳細には、ちゃんとスタックトレースがあります。

qiita11_26.png

なお、カスタムログは、例外発生箇所に近い場所で呼んでいると、Crasylyticsの情報収集に間に合わず無視されることがあるようです。(非同期でどこかに書き込んでいるからでしょう。それよりはスタックトレースを収集する方が優先度が高いから、だそうです)
なので、例外が発生するコードよりも物理的な処理順序が「それなりに前」に、ログを仕込んでおく、ということが重要になります。

それと、recordExceptionの方は、「次にFatal例外が起きてクラッシュ後、アプリを再起動したとき」に一緒に送られるようです。(なので他に例外が起きなければ報告に上がってきません。それってどうだろうという気もしますが・・・)

UserIdを送る

AnalyticsもCrashlyticsも、UserIdを使い、アプリ利用ユーザーを一意に特定することも可能です。例えば、個人情報保護法的な申し立てで、あるユーザーが「私のデータ消して下さい」と言ってきたときに、UserIdを特定して消すことが出来ます(※Googleに依頼する必要がありますが、その際にUserIdが分かっているとやりやすいようです)。

1.UserIdの生成

Hashidsというのを使ってみます。
https://hashids.org/
Kotlin版もあるようなのですがどこかのリポジトリに上がっているわけでないようで・・・
Java版が上がっていたのでそちらを利用することにします。
https://github.com/10cella/hashids-java

app/build.gradle
dependencies{
    implementation 'org.hashids:hashids:1.0.3'
}

(1)UserIdを作成する

Utils.ktに作ってみました。

Utils.kt
/**
 * 9文字のUserIdを作成する
 * SaltにはrandomUUIDを利用
 * ハッシュするソース元の数値は現在時刻を利用(ms)
 */
fun uniqueUserId(): String {
    val hashids = Hashids(UUID.randomUUID().toString(), 9)
    return hashids.encode(System.currentTimeMillis())
}

randomUUIDと、System.currentTimeMillis()を使うことで、予測されづらいUserIdの生成になっていると思います。
なぜ予測されにくくする必要があるかというと、これが推測しやすくなっていると、たとえばあるAPIのクエリーパラーメータに?userid=xxxxxxなどと付けているのが悪意のある攻撃者に分かった場合、このuseridを推測してAPIリクエストを投げつけて、ユーザー情報を盗み取ることが簡単にできてしまうからです。

(2)アプリケーションクラスで作成する

アプリ起動時に一度だけ作成します。
場所はMyAppクラスのonCreateにします。

MyApp.kt
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        startKoin {
            if (BuildConfig.DEBUG) androidLogger(Level.DEBUG) else EmptyLogger()
            androidContext(this@MyApp)
            modules(appModules)
        }

        val analyticsUtil: AnalyticsUtil by inject()
        val settingRepository: SettingRepository by inject()
        // 一度だけUserIdを作成する
        val userId = settingRepository.readUserId() ?: uniqueUserId()
        analyticsUtil.setUserId(userId)
        FirebaseCrashlytics.getInstance().setUserId(userId)
        settingRepository.saveUserId(userId)
    }
}

Koinのインジェクションを利用してAnalyticsUtilSettingRepositoryを得ているので、必ずstartKoinの後に書きます。
設定ファイルに保存されたUserIdが無ければ新規作成し、Analytics, Crashlyticsそれぞれにセットして設定ファイルに保存しています。
settingRepository.readUserId() ?: uniqueUserId()?:は、左側の値がnullならば、右側の値を使う、というKotlinの文法です。覚えてますか?エルビス演算子といいます。

(3)実行結果

Debug Viewの結果です。
AnalyticsにユーザーIdがあることが分かります。

qiita11_31.png

アンインストールしてからもう一度起動すると、ユーザーIdが変わっているのが分かります。

qiita11_33.png

Crasylyticsの方は、クラッシュログの[データ]タブに出てきます。

qiita11_32.png

ご覧の通り、このUserIdは、アプリをアンインストールすると再生成されます。randomUUIDと現在時刻は当然変わってしまうため、その度に生成されるUserIdも変わってしまいます。
そうすと、アプリ内で新規ユーザーという扱いになるため、リセマラ(※)対策のようなものが必要になる場合があります。
ちょっと高度すぎるのでこのシリーズでは触れませんが、業務で作るアプリならば検討が必要になることもあるので、心に留めておいて下さい。

※リセマラ=リセットマラソン:アプリのアンインストール&再インストールを繰り返して、無料トライアルなどを延々と使い続ける方法。

また、このUserIDを普通に設定ファイルに保存していますが、可能ならば暗号化するなりした方が良いですね。ユーザー自身には開示しても問題ないですが、悪意のあるアプリからその値を読み取れてしまったら、それを元にユーザー情報を盗まれる原因になってしまいます。
基本的にはルート化されてないと設定ファイルは読み取れないはずですので、そこはユーザーの自己責任、と言ってしまうことも出来ますが。

テスト

1.UnitTest

どうやらRobolectricのテストに影響があるようで、Fireabse Analyticsの初期化を少し変更する必要があります。

FirebaseApp.initializeAppが必要とエラーログに出ていたので、対応します。
多分普通にアプリを実行するときは中でよしなに自動的にされるのでしょうが、Robolectricはアプリを起動しませんから、そこでなにか不整合が起きてしまうようです。

AnalyticsUtil.kt
class AnalyticsUtil(app: Application) {

    private val firebaseAnalytics by lazy { FirebaseAnalytics.getInstance(app) }

    init {
        FirebaseApp.initializeApp(app)
    }

init {}はコンストラクタに続けて行われる初期化関数です。

by lazyは、最初に変数にアクセスがあったときに実行して初期化するという初期化方法になります。

これで実際のアプリ起動にも影響は無さそうなので、これで行くことにします。

2.CIツールでのテスト

CIを回すようになっている場合、このままpushすると、恐らくビルドが通らないのでは無いかと思います。

プロジェクトルートにある、.gitignoreを見てみてください。

※Macで隠しファイルが見えない場合は、Shift+Command+.ショートカットキーを押して下さい。

# Google Services (e.g. APIs or Firebase)
google-services.json

そう、無視ファイルにデフォルトで追加されているんですね。
ということは、publicなリポジトリなどには上げない方が無難なファイルということです。
privateなら上げてしまっていてもいいかもしれませんが。

そこで、どうしたらいいでしょうか?

このプロジェクトでメインで運用していこうと思っている、Github Actionsの場合で考えます。恐らく、他のCIでも同じようなアプローチで出来るのでは無いかと思います。

Github ActionsでReleaseビルド用に証明書ファイルを設定したのと同じ方法を採ります。

つまり、google-services.jsonのBase64エンコードした文字列をSecretsに設定しておき、それをビルド前にデコードしてファイルに書き出しておく、ということになります。
早速やってみましょう。

1.google-services.jsonのBase64エンコードした文字列を取得

ターミナルでプロジェクトルートにいるものとします。

$ cd app
$ openssl base64 -in google-services.json -out base64file.txt

2.GithubのSecretsに保存する

Githubのリポジトリの[Setttings]タブから、[Add a new secret]します。

qiita11_34.png

先ほど出力したBase64文字列を貼り付け、任意の名前をつけて[Add secret]します。

qiita11_35.png

出力したbase64file.txtは不要なので削除しておきましょう。(間違ってコミットされないように)

3.ビルドスクリプトの設定

decodeしてファイルとして配置するスクリプトを、.github/workflow/xxxx.yamlに追加します。

    - name: copy google-service
      env:
          GOOGLE_SERVICE: ${{ secrets.GOOGLE_SERVICES_JSON }}
      run: echo $GOOGLE_SERVICE | base64 --decode > ./app/google-services.json

これを./gradlew assembleDebugなどの前に置きます。

コミットして、pushしてワークフローを動かしてみて下さい。

なお、CircleCI用の設定ファイルもリポジトリにはアップしてありますので参考にして下さい。
※CircleCIはエミュレーターテストはありません。(出来ない)

まとめ

Firebaseを設定して、AnalyticsとCrashlyticsを利用できるようになりました。
また、UserIdをそれぞれに設定することで、ユーザーを一意に特定することが出来るようになりました。

ここで特定しているのは、アプリである行動をしているユーザーが同一ユーザーであるかどうかだけで、リアル世界での個人を特定出来るわけではありません。
ただ、同じ情報を利用して、問い合わせや特典の付与などに使っていけますね。
これらを利用して、アプリの継続利用をしてもらうための効果的な施策を考えてアプリを運用していくことが、「使って貰える」アプリの条件となってくることがあります。ただし、やりすぎると「うるさいアプリだなあ」と通知を切られたり、アンインストールされてしまうことにも繋がってしまいます。なので、ご利用は計画的に。

おまけ(Analaytics設計の重要性について)

Analyticsを送っていて、例えば、

「Instagramシェア画面はみんな使ってるけど、Twitterシェア画面はほとんど使われてないな」

なんてことが分かれば、将来、Twitter側の仕様変更などで対応が必要になったとき、機能自体を無くしてしまう、なんていう判断に使えます。

ボタンタップをいちいち送るのも、「どこまではタップされていて、どこからユーザーが遷移をやめてしまうのか」などということが取れて分析できるようになると、UIの改善だったり何かイベントを打ったりするのに役立てることが出来ます。

それから一番強力なのは、UserPropertyと組み合わせてAudienceのリストを作り、該当ユーザーにpushを送ったりする、という使い方が出来ることです。
しかし、このAudienceリストが出来上がるまでには、24Hくらいかかります(2年ほど前の体感)。利用するためには、かなり前もってリストが出来るように、アプリを作っておかなければならないのです。

つまり、「何をどういう形で送っておくか」というのは、実はアプリ開発の初期から綿密な設計をしておかないと、後でとても苦労するということになりかねません。
特にAnalyticsを利用して色々やろうとしている場合には、最初から細かく取っておかないと、「あの値が取れてないと分析できないよ!」となるとアプリを改修してリリースする時間がかかる上に、Firebaseのレポートに上がるまでに時間差があるため、結構なロスになってしまいます。UserPropertyとAudienceを使う場合は特にです。

お仕事の現場では、iOS版も同時に開発していることがあると思いますが、iOS版と送っているパラメータが合ってないなど齟齬が生じてしまうと、これまたのちのち響いてきます。

なので、Analyticsの設計は実は結構大事なのです。
勉強のためだけなら、今回送ったような内容だけで十分ですが、アプリをリリースすることを考えているときには、「運用フェーズでどうしていきたいか」までを計画して設計しておいたほうが絶対良いので、心に留めておいてください。

注意

AnalyticsとCrashlyticsだけなら問題は起こりにくいですが、Firestoreなどストレージを使うタイプのものは、FreeのSparkプランだと上限があり超過するとストップしてしまいますし、Blazeプランだと従量課金されていつの間にかウン百万!なんて話も聞きます。
ご利用は計画的に。

予告

次回は、Firebase Authenticationでユーザー作成と認証を行います。

参考ページなど

https://qiita.com/rmakiyama/items/5abb0064677dbc65a94e

Hashids関係
https://qiita.com/peutes/items/d88f6fea7ca440b28d7c
https://www.programcreek.com/java-api-examples/index.php?api=org.hashids.Hashids

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