もやもやエンジニア

IT系のネタで思ったことや技術系のネタを備忘録的に綴っていきます。フロント率高め。

百人一首を暗記するサイトをGatsby.jsに乗せてNetlifyで公開した

ここ最近やってた作業がきりのいいところまで終わったのでログ代わりのブログです。もともとピュアなSPAとして作っていたものをGatsbyに乗せて静的化しました。コアのゲーム部分は以前と変わらずSPAとして動きますが、Googleにインデックスさせる部分は実体のHTMLがちゃんと存在する状態になっています。

作ったもの

hyakuninanki.net

前のバージョンの問題点

  • SEOで事故る
    • 最近のGoogle先生BotはJSによるレンダリング後のページを見てくれてるっぽくて、SearchConsoleでFetch as Googleしてちゃんと表示されたし、インデックスにも乗ったので放っておいたのですが、ある時点で検索結果から消滅してキャッシュを見ると真っ白なページになっていました。おそらくレンダリングがされずに空のページが登録されたのだろうというところです。レスポンス自体は常時200返してますしね。
  • アドセンスの審査に落ちる
    • 直接関係があるかは謎ですが、前の状態だとコンテンツ不足と言われ、リメイクした後だとすんなり審査がおりました。

コメント

  • だいたい調べたいことはGatsbyを検証したときに終わっていたので、ページレベルのComponentを多少作り変えるのと画像類の参照をGatsbyの仕組みに載せ替えるくらいで割とすんなり移行できました。もともとReact + TypeScript + styled-componentsで作ってたのでコアな部分は割とそのままですね。今のところ、SPA作るときはこの組み合わせで満足してます。
  • コードはいつもどおりポートフォリオ代わりに公開してます。

GitHub - rei-m/web_hyakuninisshu

参考

去年公開した時の記事

rei19.hatenablog.com

事前の素振りとして検証した記事

rei19.hatenablog.com

Androidアプリ版もあるよの記事

rei19.hatenablog.com

Gatsby.jsでTypeScriptで書かれたReact/ReduxなSPAを配信する

個人で公開しているSPAが素朴なReactで作られていてSSRも何もしておらず、もうちょっとGoogle先生Botに優しく作ってあげようということで、前段階の準備としていろいろ試してみたメモです。対象のサイトはそんなに大きくないので、SSRを頑張るのではなくて事前にレンダリング済のページを静的なHTMLに出力して配信という形式にします。

作ったもの

いきなり作り変えるには知見がないのでReduxのチュートリアルでよくやるTodoアプリケーションをGatsby.jsを使って実装して、できたものをNetlifyにデプロイしました。言語は一部のGatsbyの設定ファイル以外はTypeScriptで書いています。

で、作ってみたのがこちら gatsby-ts-todo.netlify.com

オリジナルのチュートリアルには無いページですがTodoの詳細のページを追加していて 、pathはtodos/:id/ になります。Gatsby.jsで動的に静的なページを生成するための検証として、Todoを2つ初期状態で登録済にしておいて、これらのTodoの詳細ページは静的なページとして配信して、URLを直接叩いて見えるようになっています。

これは直接見える https://gatsby-ts-todo.netlify.com/todos/1/

これは直接見えない。 https://gatsby-ts-todo.netlify.com/todos/3/

はまったところ

ページを動的に生成するためにはgatsby-node.jsに生えているAPIを実装するのですが、ここもTSで書きたいなあというとこで、ちょっと試行錯誤しました。結果としては以下のようになりました。

gatsby-node-config.tsにTSで実装を書いてます。createPagesではStoreの初期データからcreatePageしてるのがわかると思います。

gatsby-node-config.ts

import { resolve } from 'path';
import { GatsbyCreatePages, GatsbyOnCreatePage } from './types';
import { initialData } from '@src/state/todos';
import { Todo } from '@src/types';

export const createPages: GatsbyCreatePages<{ todo: Todo }> = async ({ actions }) => {
  const { createPage } = actions;
  initialData.forEach(todo => {
    createPage({
      path: `/todos/${todo.id}`,
      component: resolve(`./src/templates/todos.tsx`),
      context: {
        todo,
      },
    });
  });
};

export const onCreatePage: GatsbyOnCreatePage<{ todo: Todo }> = async ({ page, actions }) => {
  const { createPage } = actions;

  // page.matchPath is a special key that's used for matching pages
  // only on the client.
  if (page.path.match(/^\/todos/)) {
    page.matchPath = '/todos/:id';
    createPage(page);
  }
};

で、本来実装する gatsby-node.js では gatsby-node-config からrequireしてexportしてスルーパスしています。configのビルドはts-nodeを使うことでうまいこと通りました。

gatsby-node.js

'use strict'

require('source-map-support').install()

require("tsconfig-paths").register({
  baseUrl: './',
  paths: {
    '@src/*': [ 'src/*' ],
    '@test/*': [ 'test/*' ]
  }
});

require('ts-node').register({
  compilerOptions: {
    module: 'commonjs',
    target: 'es2017',
    noImplicitAny: false
  }
})

const { resolve } = require('path');

const config = require('./gatsby-node-config');

exports.createPages = config.createPages;

exports.onCreatePage = config.onCreatePage;

exports.onCreateWebpackConfig = ({ actions }) => {
  actions.setWebpackConfig({
    resolve: {
      alias: {
        '@src': resolve(__dirname, 'src/'),
        '@test': resolve(__dirname, 'test/')
      }
    }
  })
}

おしまい

Gatsbyチュートリアルが充実してたので、Reactを触ったことがあれば基本的な機能は割とすぐ使えるかなという印象です。GraphQLまわりのところがちょっとまだ完全には把握できてないので、そのあたりをもうちょっとドキュメントを読みつつ試してみようかなと思います。

全体のコードはこちら

GitHub - rei-m/gatsby-todo: Todo Application created by Gatsby with TypeScript.

2018年を振り返る

このエントリは、今年一年の自己の振り返り Advent Calendar 2018 - Adventar の23日目の記事で、34歳のWeb界隈で働いているおじさんが2018年を振り返ります。

仕事の開発話

  • 去年の春ぐらいに転してから変わらず10人程度のベンチャーで2BのWebサービスを作り続けてます。幸いなことにユーザーの評判もよく、ヘビーに使っていただいているのでいろいろ作ったかいはあったかなというところです。
  • 使っている技術スタックはバックエンドはRoR、フロントエンドはReact/Redux with TypeScriptでベンチャーによくある構成です。他と多少違うのはバックエンドもフロントエンドもテストを結構ガチガチに書いているところですかね。2Bでユーザーの業務に影響が出るものを作ってるので、速度 < 安定 で開発してます。

個人の開発話

  • アプリを1本クローズしてWebサイトを2本公開しました。 Webサイトは最初はAWSで公開してたのですが、Netlifyに切り替えました。今のスキルセット的にはAWSを習熟するよりはJAMStackな構成で高速にサービスを立ち上げられる方に寄せたほうがいいかなーと思ったのでNetlifyにしてます。
  • 公開してるもの1

play.google.com

2016年の12月にリリースしたやつです。順調にDL数が伸びてこの記事を書いてる時点で13.4Kでした。あんまり機能改善はできてないけど今年はAndroidX対応したり、AAC取り入れたりと、まあまあモダンな感じに手を入れました。

  • 公開してるもの2

https://hyakuninanki.net/

↑のアプリのWeb版てことで春ぐらいにリリースしました。しばらく放置していてユーザーも全然来なかったのですが、10月くらいにSEO意識してコンテンツ見直したら改善しました。今は百人一首で検索すると1ページの3〜5番目の表示で安定しています。↓は劇的に改善されたのがよくわかるグラフ。

f:id:Rei19:20181222225931p:plain

  • 公開してるもの3

https://osechi.jp/

osechi.jpというドメイン買ったのでおせちのメタサーチサイト作りました。が、改善する前にシーズンのピークが過ぎたのでまた来年。

その他四方山

  • 娘が無事に1歳になりました。同時に保育園にも入り妻も仕事し始めたので、土日はワンオペで子供の面倒を見ることも少なくありません。必然的に自分がコードに向き合える時間は深夜くらいしかないので、いろいろやりたいことに対して物理的な時間が足りないですね。まあ子供優先だし人生の中ではそんな時期もあるだろうという感じです。
  • 前ほど勉強会に顔を出さなくなりました。↑な状態なので勉強会のインプットを自分のアウトプットに変換できないためです。DroidKaigiとか大きいカンファレンスには引き続き参加します。
  • 故あって青色申告するために開業届を出しました。屋号はまだない。せっかくならなんか軽い副業くらいしてみたいすな。

MotionLayoutでアニメーション作る

この記事は Android Advent Calendar 2018 の5日目(の代打)です。

だいたいこれ読めば基本は一通りわかる

medium.com

サンプルはこのRepositoryの中のmotionlayoutディレクトリをビルドすれば試せる github.com

雑な概要

  • MotionLayoutはConstraintLayout 2.0に含まれている。ConstraintLayoutのパッケージに含まれているのはMotionLayoutがConstraintLayoutのサブクラスになっているため。
  • オブジェクトのモーション開始状態と終了状態のConstraintを定義して、モーションスタートさせると開始状態から終了状態までオブジェクトが変化する。単に開始・終了を指定しただけだとシンプルに変化するだけだが、KeyFrameSetやCustomAttributeを使うことで、多様な変化を定義できる。
  • 基本の設定
    1. MotionLayout内に動かしたいオブジェクトを配置する。動かしたいオブジェクト以外は配置しない。
    2. オブジェクトのモーションの開始状態と終了状態を定義したSceneを作成する。
    3. Transitionを設定し、開始状態と終了状態を設定する。必要ならモーション開始のハンドラを設定する(クリックやスワイプなど)。設定しなくても他のViewからMotionLayoutのprogressプロパティを操作することでモーションをコントロールできる。
    4. 作成したシーンをMotionLayoutに設定する

基本のサンプル(冒頭のandroid-ConstraintLayoutExamplesより)

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">

    <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@+id/start">
        <OnSwipe
            motion:dragDirection="dragRight"
            motion:touchAnchorId="@+id/button"
            motion:touchAnchorSide="right" />
    </Transition>

    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@id/button"
            android:layout_width="64dp"
            android:layout_height="64dp"
            android:layout_marginStart="8dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintTop_toTopOf="parent" />
    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@id/button"
            android:layout_width="64dp"
            android:layout_height="64dp"
            android:layout_marginEnd="8dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintTop_toTopOf="parent" />
    </ConstraintSet>

</MotionScene>
  • scene_fragment.xml
<androidx.constraintlayout.motion.widget.MotionLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/motionLayout"
    app:layoutDescription="@xml/scene" // 作成したSceneを指定
    app:showPaths="true"
    android:background="@android:color/white"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:id="@+id/button"
        android:background="@color/colorAccent"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:text="Button" />

</androidx.constraintlayout.motion.widget.MotionLayout>
  • ここで注意するポイントはモーションの対象となるオブジェクト(id/button)のMotionLayout内で設定したconstraintは無視されて、Sceneの開始状態のconstraintが優先される。
  • 動かすと↓のようになる。MotionLayoutをスワイプすると対象のオブジェクトが移動しているのがわかる。

f:id:Rei19:20181219022413g:plain

KeyFrame

  • KeyFrameを使うと開始・終了の中間の状態を定義できる。上のサンプルのTransitionの中に以下のようなKeyFrameを追加してみる。
   <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@+id/start">
        <OnSwipe
            motion:dragDirection="dragRight"
            motion:touchAnchorId="@+id/button"
            motion:touchAnchorSide="right" />
        <KeyFrameSet>
            <KeyPosition
                motion:framePosition="33"
                motion:keyPositionType="pathRelative"
                motion:percentY="-0.3"
                motion:target="@id/button" />
            <KeyPosition
                motion:framePosition="66"
                motion:keyPositionType="pathRelative"
                motion:percentY="0.3"
                motion:target="@id/button" />
        </KeyFrameSet>
    </Transition>

f:id:Rei19:20181220013332g:plain

  • framePositonは開始が0で終了が100になる。KeyFrameはその中間の値の時にどのような状態になるかを指定する。上記の例はpositonが33、66の時にy座標の位置を変えるような指定になっている。
  • KeyFrameは以下の種類を指定できる

CustomAttributeを使ってオブジェクトの属性を変化する

  • KeyAttributeで指定できない属性もCustomAttributeを使えば状態を変化できる。属性名はオブジェクトに生えているgetter/setterからget/setを除いた名前を指定する。
  • CustomAttributeはConstraintとKeyAttributeの子要素に指定できる。基本のサンプルを以下のように変更してみる。
<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">

    <Transition
        motion:constraintSetEnd="@+id/end"
        motion:constraintSetStart="@+id/start">
        <OnSwipe
            motion:dragDirection="dragRight"
            motion:touchAnchorId="@+id/button"
            motion:touchAnchorSide="right" />
        <KeyFrameSet>
            <KeyAttribute
                motion:framePosition="50"
                android:scaleX="2"
                android:scaleY="2"
                motion:target="@+id/button">
                <CustomAttribute
                    motion:attributeName="backgroundColor"
                    motion:customColorValue="@color/colorPrimaryDark" />
            </KeyAttribute>
        </KeyFrameSet>
    </Transition>

    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@id/button"
            android:layout_width="64dp"
            android:layout_height="64dp"
            android:layout_marginStart="8dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintStart_toStartOf="parent"
            motion:layout_constraintTop_toTopOf="parent">
            <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="@color/colorAccent" />
        </Constraint>
    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@id/button"
            android:layout_width="64dp"
            android:layout_height="64dp"
            android:layout_marginEnd="8dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintTop_toTopOf="parent">
            <CustomAttribute
                motion:attributeName="backgroundColor"
                motion:customColorValue="@color/colorAccent" />
        </Constraint>
    </ConstraintSet>

</MotionScene>
  • 開始・終了のConstraintに初期表示時のbackgroundColorを追加、framePosition=50の時点のKeyAttributeを追加して、初期表示と異なるbackgroundColorを追加した。これを動かすと以下のようになる。

f:id:Rei19:20181220020706g:plain

既存のComponentとMotionLayoutを連携する

  • MotionLayoutのprogressプロパティを操作すればOnClickやOnSwipeを設定しなくてもオブジェクトを変化させることができる。試しにButtonを押したら10進むようにしてみる。

f:id:Rei19:20181220022756g:plain

  • コードは以下のようになっている。MotionLayoutのprogressをいじっているだけ。
        val motionLayout = view.findViewById<MotionLayout>(R.id.motionLayout)
        val button = view.findViewById<Button>(R.id.go)
        button.setOnClickListener {
            val current = motionLayout.progress
            val next = current + 0.1f
            motionLayout.progress = if (next > 1) 0f else next
        }
  • ConstraintLayoutExamplesにはDrawerやCoordinatorLayoutと連携するサンプルが載っているので試してみてほしい。

おしまい

  • Exampleのプロジェクトがかなり充実していたのでありがたかったです 🙏

手持ちのアプリをAndroidX対応したメモ

特に知見というほどのものでもないのですが、AndroidX対応したやつをStoreに載せたので作業ログなど。ASは3.2.1、対応するアプリはKotlin製です。

読むべきページ

Migrating to AndroidX  |  Android Developers 移行の手順と旧ライブラリ群がどのようにAndroidXにmappingされるのかが書いてあります。

事前準備

Refactor -> Migrate to AndroidXでガツンと変えてくれるらしいのですが、build.gradleが3.2.0より小さいと実行できないと言われるので、まず上げます。このときは3.2.1に上げました。

Migrateする

改めてASのメニューからRefactor -> Migrate to AndroidXします。すると今までSupportライブラリを使っていたファイルのパスがandroidx.XXXまたはcom.google.android.material.XXXに置き換わります。ただ、一部うまく変換できてないところがあったので変換後の差分はちゃんと見たほうがいいです。僕のやつだとimportしてる部分が変換されてるのに引数の型がFQDNで指定されてたりしました。

つまづきポイント1

ビルドするも通らず。。。原因はDataBindingが生成するActivityXXXBinding系のファイルでDataBindingComponentが見つからないと言われました。generatedJavaを見ても確かに見つかりません。gradle.propertiesのenableJetifierが怪しいかなと思い、フラグをfalseにすると生成されました。ただ、このフラグ自体は旧supportライブラリとAndroidXをつなぐために必要な設定なのでfalseにはできません。

似たような事例をさがすとバージョンは微妙に違うものの同じ問題でDagger16に戻すと解消するというPostがあり、戻してみると該当の問題は解消しました。

stackoverflow.com

つまづきポイント2

ビルドするもまだ通らず。。。原因はSQLiteのORMにOrmaを使っていて、Ormaが生成するスキーマのファイル内で旧Support系のライブラリを使っていて、それの参照エラーになっていました。AnnotationProcessorで生成される分についてはgradle.propertiesのenableJetifierは面倒を見てくれないということですね。こちらはちょうど作業中にAndroidX対応のバージョンがリリースされたので、バージョンを更新したことで事なきを得ました。

まとめ

  • Migrate to AndroidXで楽に移行できるけど、完璧ではないので差分は確認したほうがいいです
  • 自分の環境(DaggerでDI、DataBinding使用)だとDagger2.17はビルドエラーとなり、Dagger2.16に落とすとビルドできました。コードを追えていないので(というかAnnotationProcessor系でこけるとどこが問題なのか追うのがつらい)はっきりとした原因とはいえないのですが。。。
  • AnnotationProcessorでサードパーティのライブラリが生成するファイル内で旧supportライブラリが使われているとビルドできませんでした。こちらは生成されるファイル内の旧supportライブラリ系のパスがandroidx対応になっている必要があります。

個人アプリで10万DLまでいったのでいろいろふりかえる

個人で開発したアプリがDL数10万/評価1000件まで育ってくれて、いい機会なので現時点で取れている各種情報をキャプチャしておきます。個人のアプリ開発の参考にどうぞ。

play.google.com

※作ったときに書いたブログ

rei19.hatenablog.com

サマリー

  • Android版のみ。初回リリースしてから1年半強で18回リリースしている。最初Javaで書いたけどKotlinで書き換えて今に。
  • 機能はあまり増やさずにコアの機能を改善するようなリリースがほとんどです。
項目 数値 取得元
インストール数 118.87K(うちアクティブは24,000) PlayStore
平均評価 4.408 PlayStore
レビュー/評価数 375/1,000 PlayStore
ANR/直近30日 99.9% PlayStore
クラッシュ/直近30日 99.5% PlayStore
DAU 直近の8末で700前後。全期間での最高値は3500 Firebase

※補足: クラッシュ率はProguradの設定で事故ったリリースがあったのでそれを抜かすともうちょっといい数字のはず

直近の継続率(Firebase)

f:id:Rei19:20180830204502p:plain:w400

直近のユーザー属性(Firebase)

f:id:Rei19:20180830204506p:plain:w400

技術的なところも少し

  • 今の構成は↓の感じで、MVVM/Fluxな雰囲気の作りになっています。アプリの仕様的にそこまでやらなくてもという設計なのですが、素振りも兼ねて作りたいように作っています。仕事ではちゃんと設計は取捨選択しましょう。
  • 本業ではAndroid全然関係ない物作っていてトレンドを追いきれていないので古いところもあるかも。

全体の構成

https://github.com/rei-m/android_hyakuninisshu

SPA版など

  • iOS対応する代わりに簡易的な仕様のSPA版も作りました。こちらはあんまりメンテしてないのでぼちぼち改善します。

https://hyakuninanki.net/

おしまい

  • Webではなくアプリでリリースするところのいいところはレビュー書いてもらえるところだと思っていて、アプリのおかげで大会で入賞しました!とか書いてくれる人がいて、作った甲斐があったなーという感じです。Webだとエゴサーチでもしないと見つかりませんからね。
  • iOSでリリースしたアプリも含めてStoreに載せたのは3個目ですが継続して何か世に送り出していきたいですね。

AACのViewModel使ってFluxする

結論

出落ちですがAndroidのモダンな設計周りの話はPEAKSで出ているこの本を読んだ方が手っ取り早く吸収できます。AACとFluxの話も載ってます。

Android アプリ設計パターン入門

Android アプリ設計パターン入門

  • 著者:日高 正博,小西裕介,藤原聖,吉岡 毅,今井 智章,
  • 製本版,電子版
  • PEAKSで購入する

とはいえ、いろいろ自分でも試行錯誤しないと理解が進まないので、上記を踏まえた上で自分なりに実装してみた話です。自分でリリースしているアプリ↓をいじっています。

play.google.com

構成

  • パターン本にも書いてありますがFluxで状態を管理するところのStoreはAACのViewModelが担うことができます。ここを軸に以下の構成で作ってみます。

全体の構成

  • お題となる画面は解答で表示する札を表示するだけのやつ

札

Dispatcher

  • dispatchで渡されたActionをpublisherに流し込むだけの責務を持ってます。

action/Dispacher.kt

@Singleton
class Dispatcher @Inject constructor() {

    private val processor = PublishProcessor.create<Action>()

    fun dispatch(action: Action) {
        Logger.action(action)
        processor.onNext(action)
    }

    fun <T : Action> on(clazz: Class<T>): Observable<T> = processor.onBackpressureBuffer()
            .ofType(clazz)
            .observeOn(AndroidSchedulers.mainThread())
            .toObservable()
}

ActionDispatcher

  • 名前はActionCreatorのほうが良かったかも。DBにアクセスしたりユーザーからの入力を処理したりなどのビジネスロジックが詰まっています。
  • SchedulerProviderはDroidKaigiのアプリ参考にさせてもらいました。テスト時にはtrampolineなテスト用のスケジューラーを差し込めます。
  • dispatcherに流しているFetchKarutaActionはpayloadと例外情報を持つだけのclassです。

action/karuta/KarutaActionDispatcher.kt

@Singleton
class KarutaActionDispatcher @Inject constructor(
        private val karutaRepository: KarutaRepository,
        private val dispatcher: Dispatcher,
        private val schedulerProvider: SchedulerProvider
) : SingleExt {
    fun fetch(karutaId: KarutaIdentifier) {
        karutaRepository.findBy(karutaId).scheduler(schedulerProvider).subscribe({
            dispatcher.dispatch(FetchKarutaAction(it))
        }, {
            dispatcher.dispatch(FetchKarutaAction(null, it))
        })
    }
}

Store

  • Dispacherから流れてくるActionを監視して自分の状態を更新します。ReduxのReducer的な責務も兼ねてますね。
  • StoreはAACのViewModelを継承した抽象クラスでDispatcher監視用の共通メソッドが生えてます。onClearedでregisterで登録した監視を解除しているので、ライフサイクルで死ぬときに 監視は解除されます。

presentation/karuta/KarutaStore.kt

class KarutaStore(dispatcher: Dispatcher) : Store() {

    private val karutaLiveData = MutableLiveData<Karuta?>()
    val karuta: LiveData<Karuta?> = karutaLiveData

    private val notFoundKarutaEventLiveData = SingleLiveEvent<Void>()
    val notFoundKarutaEvent = notFoundKarutaEventLiveData

    init {
        register(dispatcher.on(FetchKarutaAction::class.java).subscribe {
            if (it.error == null) {
                karutaLiveData.value = it.karuta
            } else {
                notFoundKarutaEventLiveData.call()
            }
        })
    }

    class Factory @Inject constructor(private val dispatcher: Dispatcher) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return KarutaStore(dispatcher) as T
        }
    }
}

ViewModel

  • Storeを監視してViewにStoreの状態を加工して反映させます。AACのViewModelではありません。。。名前付け難しい。。。

presentation/karuta/KarutaViewModel.kt

class KarutaViewModel(
        store: KarutaStore,
        actionDispatcher: KarutaActionDispatcher,
        karutaId: KarutaIdentifier
) : LiveDataExt {

    val isLoading: LiveData<Boolean> = store.karuta.map { it == null }

    val karutaNo: LiveData<Int?> = store.karuta.map { it?.identifier()?.value }

    val karutaImageNo: LiveData<String?> = store.karuta.map { it?.imageNo?.value }

    val creator: LiveData<String?> = store.karuta.map { it?.creator }

    val kimariji: LiveData<Int?> = store.karuta.map { it?.kimariji?.value }

    val kamiNoKuKanji: LiveData<String?> = store.karuta.map { it?.kamiNoKu?.kanji }

    val shimoNoKuKanji: LiveData<String?> = store.karuta.map { it?.shimoNoKu?.kanji }

    val kamiNoKuKana: LiveData<String?> = store.karuta.map { it?.kamiNoKu?.kana }

    val shimoNoKuKana: LiveData<String?> = store.karuta.map { it?.shimoNoKu?.kana }

    val translation: LiveData<String?> = store.karuta.map { it?.translation }

    val notFoundKarutaEvent = store.notFoundKarutaEvent

    init {
        if (store.karuta.value == null) {
            actionDispatcher.fetch(karutaId)
        }
    }

    class Factory @Inject constructor(private val actionDispatcher: KarutaActionDispatcher) {
        fun create(store: KarutaStore, karutaId: KarutaIdentifier): KarutaViewModel =
                KarutaViewModel(store, actionDispatcher, karutaId)
    }
}

Fragment

  • FragmentはStoreを取得して、それを元にViewModelを作ってDataBinidngにセットするまでを仕事します(必要そうなところだけ抜粋)。

presentation/karuta/KarutaFragment.kt

class KarutaFragment : DaggerFragment(), FragmentExt {

    @Inject
    lateinit var storeFactory: KarutaStore.Factory

    @Inject
    lateinit var viewModelFactory: KarutaViewModel.Factory

    private var listener: OnFragmentInteractionListener? = null

    private val karutaId: KarutaIdentifier
        get() = requireNotNull(arguments?.getParcelable(ARG_KARUTA_ID)) {
            "$ARG_KARUTA_ID is missing"
        }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val viewModel = viewModelFactory.create(obtainActivityStore(KarutaStore::class.java, storeFactory), karutaId)
        viewModel.notFoundKarutaEvent.observe(this, Observer {
            listener?.onError()
        })

        val binding = FragmentKarutaBinding.inflate(inflater, container, false).apply {
            setLifecycleOwner(this@KarutaFragment)
        }

        binding.viewModel = viewModel

        return binding.root
    }

...

}

おしまい。

  • という感じでリリース済のアプリを書き換えてみました。(まだリリースしていなくて、テスト周りを充実させているところ)各クラスの役割は分担できていて自分以外の人も追いやすく読める形になってるのかなと。例えばここに歌を編集するような操作を追加したいときはactionDispatcherにeditとか生やしてそこに歌を編集して永続化する処理を実装、Actionは編集後の歌をpayloadとして流す、storeはそれを受け取って自身の状態を変更する、というような実装の流れになります。ViewModelはstoreの監視対象は変わらないので、ユーザーのactionとactionDispatcherのeditを結びつけるだけですね。
  • READMEにも書きましたがアプリの規模に対してはやや過剰な設計なので、興味があればあまり参考にしないで話半分にコードを読むのがいいと思います。

GitHub - rei-m/android_hyakuninisshu