enzymeでReact RouterのwithRouterで包んだRedux Containerのテストする
- 今の仕事はReact/ReduxなSPAをTypeScriptで書いてて、HOCのテストの書き方ちょっと迷ったのでメモとして残しておきます。
関連する主なライブラリ
- "react": "~16.0.0",
- "react-dom": "~16.0.0"
- "react-redux": "~5.0.6"
- "react-router-dom": "~4.2.2"
- "redux": "~3.7.2"
- "enzyme": "~3.1.0"
- "enzyme-adapter-react-16": "~1.0.1"
- "redux-mock-store": "~1.3.0"
こんな感じ
- ラップするComponent。コードは適当にURIから受け取ったIDが表示 + ボタンを一つ持つだけのやつ。
components/HogeContent/index.tsx
export interface HogeContentOwnProps extends React.ClassAttributes<HogeContent>; export interface HogeContentConnectedProps { readonly disabled: boolean; } export interface HogeContentDispatchProps { readonly onClickButton: () => void; } export interface HogeContentRouterProps = RouteComponentProps<{id: number}>; export type HogeContentProps = HogeContentOwnProps & HogeContentConnectedProps & HogeContentDispatchProps & HogeContentRouterProps; export default class HogeContent extends React.Component<HogeContentProps> { constructor(props: HogeContentProps) { super(props); this.onClickButton = this.onClickButton.bind(this); } public render() { return (this.props.disabled) ? ( <div> {this.props.match.params.id} </div> ) : ( <div> <button type="submit" onClick={this.props.onClickButton} > {this.props.match.params.id} </button> </div> ); } }
- テスト対象となるComponentを包むContainer
const mapStateToProps = (state: GlobalState, _: HogeRouterProps): HogeContentOwnProps & HogeContentConnectedProps { const { disabled } = state.hoge; return { disabled } }; const mapDispatchToProps = (dispatch: Dispatch<GlobalState>, props: HogeRouterProps): HogeContentDispatchProps { return { onClickButton: () => { dispatch(sendHoge();); props.history.push('/fuga'); } }; }; export default withRouter(connect(mapStateToProps, mapDispatchToProps)(HogeContent));
- こんな感じで '/hoge' で来たらHogeContentを表示するみたいなやつに対して以下の項目をテストしたいとします。
- Reduxのstoreから
disabled
を渡しているか - onClickButtonが押されたら
sendHoge
をdispatchしているか - onClickButtonが押されたら
/fuga
に遷移しているか
- Reduxのstoreから
- なおテストはmochaで走らせてます。
describe('containers', () => { describe('<HogeIndex />', () => { let wrapper: ShallowWrapper; let mockStore: MockStore<{}>; let mockRouter: any; beforeEach(() => { mockStore = configureMockStore()({ hoge: { disabled: false } }); mockRouter = { route: { match: { params: { id: 1 } }, location: { pathname: '/hoge' } }, history: { push: sinon.stub() } }; wrapper = shallow( <Hoge match={mockRouter.route.match as any} location={mockRouter.route.location as any} history={mockRouter.history as any} /> ).dive({ context: { router: mockRouter } }).dive({ context: { store: mockStore } }); }); it('should component received props', () => { const actual = wrapper.find(HogeContent).props().disabled; assert.equal(actual, false); }); it('should dispatch sendHoge when button clicked', () => { const actual = wrapper.find(HogeContent).props().onClickButton(); const actions = mockStore.getActions(); // configureMockStoreを使うとこのようにDispatchされたActionが取れる assert.equal(actions[0].type, 'SEND_HOGE_ACTION'); }); it('should go to fuga when button clicked', () => { const actual = wrapper.find(HogeContent).props().onClickButton(); assert(mockRouter.history.push.calledWith('/fuga')); }); }); });
- Enzymeのdiveを使うとラップされたComponentをshallow renderしたのが返ってくるので、その時にHOCから注入されるContextを設定してあげます。Reduxだけの場合はshallowの2番目の引数にContextを設定できるのですが、withRouterで更に包んだ場合は
withRouter
->Router
->connect
->component
という階層になってるので、diveしつつ各階層で必要な情報を注入して上げる感じでテスト対象のComponentをrenderできました。
参考
Dagger 2.11 でAndroid Supportを使ってDIする
個人で出してるアプリでDagger使ってるところでDaggerが提供しているAndroid向けのモジュールを使うようにしたときのめも。
公式
- 基本的には公式のドキュメントの通りなのですが、詰まったポイントがあったので、自分の手順としてまとめておきます。
準備
compile 'com.google.dagger:dagger:2.11' compile 'com.google.dagger:dagger-android:2.11' compile 'com.google.dagger:dagger-android-support:2.11'
- ApplicationComponentに
AndroidInjectionModule.class
を追加します。SupportLibraryを使う場合はAndroidSupportInjectionModule.class
になります。
@Singleton @Component(modules = { AndroidSupportInjectionModule.class, ApplicationModule.class }) public interface ApplicationComponent { void inject(App application); }
- Applicationクラスを継承して
HasActivityInjector
を実装しておきます
public class App extends MultiDexApplication implements HasActivityInjector { @Inject DispatchingAndroidInjector<Activity> dispatchingActivityInjector; @Override public void onCreate() { // ここは諸々の準備が終わってビルドしてDaggerApplicationComponentが作られた後に書く。 DaggerApplicationComponent.builder() .applicationModule(new ApplicationModule(this)) .build() .inject(this); } @Override public AndroidInjector<Activity> activityInjector() { return dispatchingActivityInjector; } }
ActivityにDIする
HogeActivityにDIしたい場合
AndroidInjectorを継承したSubcomponentを作成します。ここではそのActivityに必要なModuleの定義を行います。
@Subcomponent(modules = { ActivityModule.class, // Activity内でDIするインスタンスを提供するモジュール HogeActivityViewModelModule.class, // Activity内でDIするインスタンスを提供するモジュール FugaFragmentModule.class // Activityの下にFragmentがいる場合。後述。 }) public interface HogeActivitySubcomponent extends AndroidInjector<HogeActivity> { @Subcomponent.Builder abstract class Builder extends AndroidInjector.Builder<HogeActivity> { // Moduleがコンストラクタで引数を取る場合、abstractでBuilderを返すメソッドを定義して // seedInstanceでModuleのインスタンスを作って生やしたメソッドに渡してあげます。 public abstract Builder activityModule(ActivityModule module); @Override public void seedInstance(HogeActivity instance) { activityModule(new ActivityModule(instance)); } } }
- 次にSubcomponentに対応するModuleを作成します。
@Module(subcomponents = HogeActivitySubcomponent.class) // 上で定義したSubcomponentを指定 public abstract class HogeActivityModule { @Binds @IntoMap @ActivityKey(HogeActivity.class) abstract AndroidInjector.Factory<? extends Activity> bindHogeActivityInjectorFactory(HogeActivitySubcomponent.Builder builder); }
- もしSubcomponentが単に依存関係を定義するだけだったら、Subcomponentを作らずに
ContributesAndroidInjector
アノテーションを使うこともできます。
@Module public abstract class HogeActivityModule { @ContributesAndroidInjector(modules = { ActivityModule.class, HogeActivityViewModelModule.class, FugaFragmentModule.class }) abstract HogeActivity contributeHogeActivityInjector(); }
- ModuleまでできたらApplicationComponentにModuleを追加します。
@Singleton @Component(modules = { AndroidSupportInjectionModule.class, ApplicationModule.class, // アプリケーションスコープでDIするModule HogeActivityModule.class // 作成したActivityのModule }) public interface ApplicationComponent { void inject(App application); }
- 最後にActivityのonCreateでInjectしてあげれば、Activity内のInjectアノテーションがついたフィールドにModuleからDIされます。
public class HogeActivity extends AppCompatActivity { @Inject HogeActivityViewModel viewModel; @Override protected void onCreate(Bundle savedInstanceState) { AndroidInjection.inject(this); super.onCreate(savedInstanceState); ... } }
FragmentにDIする
さきほどのHogeActivityにFugaFragmentをぶら下げてFragmentにDIします。
HogeActivityにHasSupportFragmentInjectorを実装します。(普通のFragmentを使う場合はHasFragmentInjector)
public class HogeActivity extends AppCompatActivity implements HasSupportFragmentInjector { @Inject DispatchingAndroidInjector<Fragment> fragmentInjector; // 略 @Override public AndroidInjector<Fragment> supportFragmentInjector() { return fragmentInjector; } }
- Activityと同様にSubcomponentとModuleを定義していきます。
@Subcomponent(modules = { FugaFragmentViewModelModule.class }) public interface FugaFragmentSubcomponent extends AndroidInjector<FugaFragment> { @Subcomponent.Builder abstract class Builder extends AndroidInjector.Builder<FugaFragment> { }
@Module(subcomponents = FugaFragmentSubcomponent.class) public abstract class FugaFragmentModule { @Binds @IntoMap @FragmentKey(FugaFragment.class) abstract AndroidInjector.Factory<? extends Fragment> bindFugaFragmentInjectorFactory(FugaFragmentSubcomponent.Builder builder); }
Activityと同様に
ContributesAndroidInjector
を使うこともできます。ModuleまでできたらHogeActivityComponentにModuleを追加して依存関係を繋げます。
最後にFragmentにInjectしてあげます。Fragmentの場合はonAttachでDIします。
public class FugaFragment extends Fragment { @Inject FugaFragmentViewModel viewModel; @Override public void onAttach(Context context) { // SupportライブラリのFragmentの場合はAndroidSupportInjection // 普通のFragmentの場合はAndroidInjection AndroidSupportInjection.inject(this); super.onAttach(context); ... } }
- これでbuildするとDaggerApplicationComponentが生成されるので、上記のAppクラスのonCreateでinjectしてあげればOKです。
追記: DaggerApplication・Activity・Fragmentを使ってもっと楽に書く
- Activity/Fragmentを作るたびに
AndroidInjection.inject
をライフサイクルの中で書くのはちょっとだるいのですが、その辺が定義済みのクラスが用意されています。これを使うと以下のように書き換えることができます。
// ApplicationComponent にAndroidInjectorを継承させる @Singleton @Component(modules = { AndroidSupportInjectionModule.class, ApplicationModule.class, HogeActivityModule.class }) public interface ApplicationComponent extends AndroidInjector<App> {}
// HasActivityInjectorを外してDaggerApplicationを継承するようにする public class App extends DaggerApplication { @Override public void onCreate() { super.onCreate(); // 自前でinjectしてたところは消す } @Override protected AndroidInjector<App> applicationInjector() { // DaggerApplicationに生えているapplicationInjectorを実装する。 // Injectは親クラスでやってくれる return DaggerApplicationComponent.builder() .applicationModule(new ApplicationModule(this)) .build(); } }
// HasSupportFragmentInjectorを外してDaggerAppCompatActivityを継承するようにする public class HogeActivity extends DaggerAppCompatActivity { // fragmentInjectorは親クラスが持ってるので消す // @Inject // DispatchingAndroidInjector<Fragment> fragmentInjector; @Override protected void onCreate(Bundle savedInstanceState) { // Injectは親クラスでやってくれるので消す // AndroidInjection.inject(this); super.onCreate(savedInstanceState); ... } // @Override // public AndroidInjector<Fragment> supportFragmentInjector() { // return fragmentInjector; // } }
// DaggerFragmentを継承するようにする public class FugaFragment extends DaggerFragment { @Override public void onAttach(Context context) { // Injectは親クラスでやってくれるので消す // AndroidSupportInjection.inject(this); super.onAttach(context); ... } }
おわり
- 前は結構頑張ってApplicaiton -> Activity -> Fragmentの依存関係を繋げてたのですが、その辺は楽に書けるようになったのかなーと思います。あとはもうちょい生成されたコードを眺めてどんな感じになったのか把握した方がよいですね。
- 実際に自分のアプリで書いたのはこちら。テストちゃんと書けてないのでDIの恩恵を完全に享受してるとはいえないのですが、ちょいちょい追加していきます。
https://github.com/rei-m/android_hyakuninisshu/pull/86
- 余談ですが、新しいライブラリとか設計を試すときに簡単なTODOアプリとかだといまいち使用感がつかめないので、実際にリリースしたそこそこのアプリで試すようにしてます。次はArchitecture Components使ってオレオレViewModelを置き換える予定。仕事でAndroidから離れてだいぶキャッチアップが遅れてるのでほどほどに追いかけていく気持ち。
- 作者: 深見浩和
- 出版社/メーカー: リックテレコム
- 発売日: 2017/09/27
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
現場で役立つシステム設計の原則読んだ
現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法
- 作者: 増田亨
- 出版社/メーカー: 技術評論社
- 発売日: 2017/07/05
- メディア: Kindle版
- この商品を含むブログ (2件) を見る
「現場で役立つシステム設計の原則」をちびちび読んでたのを読み終えたので雑記。
一言感想
- DDDといえばエヴァンス氏のドメイン駆動設計がありますが、そちらを読んでなくても十分理解できる内容でした。本書はガチなDDD本ではなく、現場目線からの変更に強いコードをどう設計して書くかという内容を具体例を交えて説明しています。個人的には3章「業務ロジックをわかりやすく整理する」4章「ドメインモデルの考え方で設計する」辺りはとてもわかりやすいし実践しやすい内容で、新人氏にはリーダブルコードと合わせてお勧めしたいなというところで良書でした。
- 願わくば本書に出てきたサンプルを実践した形の動くアプリケーションのコードが公開されてると全体が見通せてありがたかったです。
フロントの設計とか
- 本の内容とは直接の関係は無いのですが、フロント(アプリ/Web問わず)作ってると設計の話はそれなりによく出てくる話題で、スマートUI/MVC/MVP/MVVM/CleanArchitecture/etc。。。と色々な思想が挙げられますが、たまにここにDDDが並ぶのを見かけるのですよね。個人的にはそれはちょっとどうかなと思っていて、戦術的DDD(エンティティとかリポジトリとか技術的な手法)にのみフォーカスしていて本質を忘れていると感じてます。本来は戦略的DDD(ドメインの関心の見つけ方とかコンテキストの切り分け方とかドメインモデルをどう探し出すかとか)がまず先にあって、それをどうコードに表現するかという話が戦術的DDDになるはずです。なので別にエンティティとかリポジトリがいなくてもドメインを綺麗に表現できていればDDDをうまく取り入れられてるよねというところで、設計のコンテキストでDDDが出てくるとちょっともやっとします。
責務を意識してReact/Reduxを使う
- ↑の続き。仕事でReact/ReduxなSPAを書き始めているのですが、引き継いだコードを読んだり書き直したりして思ったことなど。
TL;DR
- Componentの仕事はもらってきたpropsを素直に表示するのとEventの発火だけにしような。
FatなComponent
- なかなかに巨大でやたらRender以外の処理が太ってるReactのComponentだな。。。と思いながらコードを読むと、以下のようなロジックになっていました。具体的なコードは書けないので雑なイメージはこんな感じ
containers/Sample.tsx
const mapStateToProps = (state: GlobalState) => { const { hogeList, fugaList } = state; return { hogeList, fugaList }; }; const mapDispatchToProps = { callGetHogeApi, callGetFugaApi, callPostHogeFugaApi } export default connect(mapStateToProps, mapDispatchToProps)(Sample)
components/Sample.tsx
interface Props { hogeList?: Hoge[]; // HogeAPIのレスポンス fugaList?: Fuga[]; // FugaAPIのレスポンス callGetHogeApi: () => void; // 表示に必要な情報をGetするActionをDispatchするやつ callGetFugaApi: () => void; // 表示に必要な情報をGetするActionをDispatchするやつ callPostHogeFugaApi: (param: HogeFugaRequestParam) => void; // 更新をPostするActionをDispatchするやつ } // 略 export default class Sample extends React.Component<Props, State> { // 略 public componentDidMount() { this.props.callHogeApiAction(); this.props.callFugaApiAction(); } // 略 public render() { // 各APIのレスポンスが全部揃っているかpropsを見て判定 // レスポンスが全部揃ってたらrender開始。それ以外はローディング表示 const piyoList = createPiyoList(); return ( // 略 ); } // Componentの状態をサーバー側に送信する的な処理 private onSubmit() { // Props/StateからAPIにリクエストするための情報を作成する const params: HogeFugaRequestParam = { // 略 }; this.props.callPostHogeFugaApi(params); } private createPiyoList() : Piyo[] { const { hogeList, fugaList } = this.props; // 表示するために必要なpiyoListをhogeListとfugaListをいい感じにマージして作る return piyoList; } }
上の例だと表示するために呼んでるAPIは2本ですが、実際には4本とか5本とかあったりします。今の作りでのReact/Reduxなアプリの各レイヤーの責務を見てみると以下の感じになります。
- Action
- APIを呼ぶ
- Reducer
- APIのレスポンスをそのまま新しいStateとしてReduxStoreに保存する
- Container
- Storeから画面で使うレスポンスとdispatchするActionをcomponentに渡す
- Component
という感じで、API呼ぶ->storeにレスポンスが保存される->containerはそれをcomponentにスルーパス->componentが色々頑張るといった形になっていて、componentにロジックが集中する作りになってます。
どうやってcomponentを薄くするか
今の作りの問題は以下の点です。
- Propsとして生のAPIのレスポンスを受け取って自分で加工しているため、componentはAPIのレスポンスを意識しなければいけなく、そのための余計なロジックを実装しなければいけない。
- Actionを通り越してAPIの詳細な実装をcomponentが意識しなければいけない。
本来、Reduxに状態の管理を移譲することでReactのComponentは渡ってきたPropsを素直に表示するだけという作りになるはずなので、componentではなくてcontainerに仕事をさせてcomponentが望む形に加工したものを渡すようにします。また、Action周りも実装を意識させないようなIFにしてAPIの存在をAction内に閉じ込めます。修正後はこのような形になりました。
containers/Sample.tsx
const mapStateToProps = (state: GlobalState) => { const { isLoading, hogeList, fugaList } = state; const piyoList = createPiyoList(this.props.hogeList, this.props.fugaList); return { isLoading, piyoList }; }; const mapDispatchToProps = (dispatch: Dispatch<GlobalState>) => { return { onStart: () => { dispatch(fetchHogeFugaList()); }, onSubmit: (name: string) => { dispatch(createPiyo(name)); } } }; export default connect(mapStateToProps, mapDispatchToProps)(Sample)
components/Sample.tsx
interface Props { isLoading: boolean; // 読み込み中か判定 piyoList: Piyo[]; // 画面で表示するPiyoの一覧 onStart: () => void; // 画面レンダリング開始時のイベント onSubmit: (name: string) => void; // 新しいPiyo作成時のイベント } // 略 export default class Sample extends React.Component<Props, State> { // 略 public componentDidMount() { this.props.onStart(); } // 略 public render() { if (this.props.isLoading) { return ( // くるくる表示する ); } return ( // PiyoList表示する ); } private onSubmit() { // 実際にはこのへんもonChangeで全部reduxに流すのでstateは無くなる。 this.props.onSubmit(this.state.name); } }
という感じになりました。変更後の責務は以下の通り。
- Container
Storeから画面で使うレスポンスとdispatchするActionをcomponentに渡す- Storeから必要なレスポンスを加工してcomponentに渡す
- componentで発火するイベントに対応するActionをmappingする(mapDispatchToPropsの通りですね)
- Component
また、複数API呼んでいたところは1つのアクションとして再定義しています。非同期部分はもともと redux-promise を使ってたのでそのまま使って、Promiseでまとめて2本走らせて両方取れたらreducerに回るようにしています。
actions/fetchHogeFugaList.ts
export function fetchHogeFugaList(): FetchHogeFugaListAction { return { type: FETCH_HOGE_FUGA_LIST, payload: Promise.all([ axios.get(GET_HOGE_LIST), axios.get(GET_FUGA_LIST), ] }; }
おわり
- このように対応を入れたことでcomponentの見通しが良くなり、componentがAPIを意識しなくなりました。また、メリットとしてテストが格段に書きやすくなりました。この記事の例だと大したことなさそうなのですが、実際には大量のPropsが定義されていてテストデータを作ろうにも本当に必要な情報はどれなんだーという感じだし、イベントの確認もAPIと密結合しているので大変でした。
- reduxをちゃんと使えばreactのcomponentは薄く保つことができ、Viewとロジックを分離できるようになるので意識して書いていきたいですね。
- まだredux触り始めな感じなので理解が間違ってたらコメントください m( )m
Webの仕事に戻ったのでReact-ReduxをTypeScriptで再入門してる
5月から知り合いが立ち上げた会社を手伝っていてB向けのWebサービスをspaで作ってるのですが、ここ2年くらいはほぼAndroidの仕事しかしていなくて、2年もたてば昔の知識は陳腐化している(というか忘れかけている)ので改めてチュートリアルやったり本読んだりしてます。今までReactをちゃんと仕事で使う機会もなかったのでちょうどいい機会かなと。
とりあえずTodoアプリから
- 今の仕事は React/ReduxをTypeScriptで書いているので、それに近い感じでサンプルを作りました。yarnでパッケージ管理してwebpack2でbundleしてdeserverで配信みたいな感じ。テストのフレームワークはReactのチュートリアルに従ってJestを使ってます。あとはこれをベースにもうちょっとちゃんとしたアプリを作っていこうかなと。
GitHub - rei-m/typescript_react_redux: Sample of TypeScript/React/Redux with Webpack2
読んでよかったもの
Reactビギナーズガイド ―コンポーネントベースのフロントエンド開発入門
- 作者: Stoyan Stefanov,牧野聡
- 出版社/メーカー: オライリージャパン
- 発売日: 2017/03/11
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
おわり
- 久々にWebのフロント書いてるけど楽しい
KotlinとDataBindingとMVVMとか
※ こちらもどうぞ 2018/7/30 追記 AACのViewModel使ってFluxする - もやもやエンジニア
今までDateBindingをButterKnifeの代わりのような使い方しかしてなかったので、ちゃんとMVVMっぽい作りもやってみようということで前に作ったアプリをごそっと書き換えてみました。アプリはKotlin製です。
そもそものMVVM
- 以下の記事・スライドを参考にしてます。こういうアーキテクチャ系の話の原点って何を探ればいいのかちょっとわからなかったので印象に残った記事を読みこんでます。
- 自分の中で噛み砕いたポイントとしては以下の点
- MVVMのコンテキストでのModelはプロパティの公開と戻り値のないメソッドだけを実装している。ここでいうModelは例えばDDDのコンテキストでのDomainModelのようなものを指しているわけではないので切り離して考えること。
- ViewModelはModelを監視している。メソッドの呼び出しによりModelの状態が変更された場合はその変化を翻訳してViewに伝える。
- Modelは内部の状態が更新されたらViewModelに更新通知を送る
- ※ 勘違いしてたらコメントください。。。
とりあえず作ってみよう
- このアプリでははてぶの情報を取得するために、対象のユーザーのはてなのIDをSharedPreferencesに持っていていつでも更新することができます。なのでできることはユーザーIDの公開、ユーザーIDの更新です。
Model
- ModelはシングルトンでViewModelにDIしてます。このアプリではユーザーIDを設定するダイアログとその呼び元のActivityのViewModelそれぞれでUserModelの更新イベントを監視しています。なのでユーザーIDが更新されたら呼び元のActivityも同時に更新されます。
var user: UserEntity = getUserFromPreferences() private set(value) { field = value storeUserToPreferences(value) userUpdatedEventSubject.onNext(value) } // イベントを流す用のPublishSubject private val userUpdatedEventSubject = PublishSubject.create<UserEntity>() private val unauthorisedEventSubject = PublishSubject.create<Unit>() private val errorSubject = PublishSubject.create<Unit>() // PublishSubjectをObservableに変換して公開。PublishSubjectのままだとonNextが呼び元で使えてしまう val userUpdatedEvent: Observable<UserEntity> = userUpdatedEventSubject val unauthorisedEvent: Observable<Unit> = unauthorisedEventSubject val error: Observable<Unit> = errorSubject fun setUpUserId(userId: String) { // ここは http://b.hatena.ne.jp/[UserId]/ を叩いて200が返って来るかを確認して存在するユーザーかチェックしている hatenaBookmarkService.userCheck(userId).map { // 特定の記号が入っている場合にTopページを取得しているケースがあるのでトップページが返ってきたら 存在しないユーザー = 404として扱う return@map !it.contains("<title>はてなブックマーク</title>") }.onErrorResumeNext { if (it is HttpException) { when (it.code()) { HttpURLConnection.HTTP_NOT_FOUND -> { Single.just(false) } else -> { Single.error(it) } } } else { Single.error(it) } }.subscribeAsync({ isValidId -> // Model内でスレッドを指定して配信。subscribeAsyncは拡張関数 if (isValidId) { // 有効なIDだったら渡されたIDを内部に保存 user = UserEntity(userId) } else { // 無効なIDだったら認証できなかったイベントを発行 unauthorisedEventSubject.onNext(Unit) } }, { // 通信エラーなどの場合は一律エラーのイベントを発行 errorSubject.onNext(Unit) }) }
- ここでModel内のUserが更新されたらSharedPreferecesに保存して、userUpdatedEventを発行してます。
var user: UserEntity = getUserFromPreferences() private set(value) { field = value storeUserToPreferences(value) userUpdatedEventSubject.onNext(value) }
ViewModel
- ViewModelはonStartでModelのイベントの監視を開始、onStopで解除という感じにしてます。
DataBinding周りでProgressDialogとかSnackBarとか表示したい時はEventBus経由でActivityに通知しています。。。が、もっといいやり方がある気がします。
EditUserIdDialogFragmentViewModel.kt
override fun onStart() { super.onStart() // Modelから通知されるイベントを翻訳してViewに伝える registerDisposable(userModel.userUpdatedEvent.subscribe { progressDialog.dismiss() idErrorMessage.set("") userId.set(it.id) dismissDialogEventSubject.onNext(Unit) }, userModel.unauthorisedEvent.subscribe { progressDialog.dismiss() idErrorMessage.set(userIdErrorMessage) }, userModel.error.subscribe { progressDialog.dismiss() snackbarFactory?.create(R.string.message_error_network)?.show() }) } override fun onResume() { super.onResume() userId.set(userModel.user.id) } fun onClickSetUp(view: View) { progressDialog.show() userModel.setUpUserId(userId.get()) }
おわり
Modelのメソッド呼び出しの結果をイベントベースで受け取るのはメソッドでObservableを返すよりわかりやすいかなと思いました。その場合はエラーハンドリングでどんなエラーが流れてくるか内部の実装を知っていなければならないので発生しうるイベントとして公開されてる方がModelを呼ぶ側は使いやすいです。
コードはこちら。主要な画面だけDataBindingとViewModelで書き直してます(わりと前に書いたものがベースなのでちょっと変なところがあるかも)。
続編のようなもの
百人一首暗記するアプリ作ってからの振り返り
- 上の記事から3ヶ月たったので、数字とかやったこととかの振り返りメモです。
3ヶ月たった時点の数字
項目 | 数値 | 取得元 |
---|---|---|
インストール数 | 13,500(うちアクティブは5,500) | play store |
平均評価 | 4.382 | play store |
レビュー/評価数 | 31/76 | play store |
DAU | 400 ~ 600 (うち新規は100 ~ 150くらい) | Fabric |
Crash率 | 99.50% | Fabric |
AdMobからの収益 | 飲み会1回分くらい/month | AdMob |
リリースしてからやったことなど
Bug-Fixの対応
- 4.1系だけ札が真っ黒になる。
- カスタムフォントの使い方がまずくてOOM出てた。
機能追加
- レビューでもらった要望や自分でこれはあってもいいかなと思ったものは追加していった。
- 読み上げの要望が何個か来ていたけどアプリのコンセプトには合わないかなと思ってるのでその辺は対応してない。シンプルが一番。
ASO
- “百人一首"、"百人一首 暗記"で上位に来るようにアプリ名やら紹介文やら変えて様子を見た。後半はPlay StoreでABテストもしてみたけど有為な差が見られなかった。多分、僕の対抗馬の出し方が悪い。
- 今のとこ"百人一首"は2位、"百人一首 暗記"は1位を取れてる。
レビューの返信
気づき
- ニッチなジャンルでそんなに使われないかなと思ったけど、思ったよりインストールされたしDAUも安定してる。それなりに競合のアプリもあったがあまり更新されてないものも多いので、丁寧に作り込んで検索で引っかかるようにすればちゃんと使ってもらえる。
- 上述のバグが原因で1点のレビューしてた人がいたけど、直した連絡をレビューの返信でしたら5点に変えてくれた。少なくとも低い評価のレビューしてる人にはちゃんとケアしてあげるとよさそう。
- Firebaseを入れてみたけど、想定してた数字が取れていないので使い方の認識がズレてるとこがある。ドキュメント読み直して手を入れたい。