もやもやエンジニア

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

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

containers/Hoge/index.tsx

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 に遷移しているか
  • なおテストは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できました。

参考

dive() · Enzyme

Dagger 2.11 でAndroid Supportを使ってDIする

個人で出してるアプリでDagger使ってるところでDaggerが提供しているAndroid向けのモジュールを使うようにしたときのめも。

公式

google.github.io

  • 基本的には公式のドキュメントの通りなのですが、詰まったポイントがあったので、自分の手順としてまとめておきます。

準備

 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から離れてだいぶキャッチアップが遅れてるのでほどほどに追いかけていく気持ち。

Androidアプリ開発 74のアンチパターン

Androidアプリ開発 74のアンチパターン

現場で役立つシステム設計の原則読んだ

「現場で役立つシステム設計の原則」をちびちび読んでたのを読み終えたので雑記。

一言感想

  • DDDといえばエヴァンス氏のドメイン駆動設計がありますが、そちらを読んでなくても十分理解できる内容でした。本書はガチなDDD本ではなく、現場目線からの変更に強いコードをどう設計して書くかという内容を具体例を交えて説明しています。個人的には3章「業務ロジックをわかりやすく整理する」4章「ドメインモデルの考え方で設計する」辺りはとてもわかりやすいし実践しやすい内容で、新人氏にはリーダブルコードと合わせてお勧めしたいなというところで良書でした。
  • 願わくば本書に出てきたサンプルを実践した形の動くアプリケーションのコードが公開されてると全体が見通せてありがたかったです。

フロントの設計とか

  • 本の内容とは直接の関係は無いのですが、フロント(アプリ/Web問わず)作ってると設計の話はそれなりによく出てくる話題で、スマートUI/MVC/MVP/MVVM/CleanArchitecture/etc。。。と色々な思想が挙げられますが、たまにここにDDDが並ぶのを見かけるのですよね。個人的にはそれはちょっとどうかなと思っていて、戦術的DDD(エンティティとかリポジトリとか技術的な手法)にのみフォーカスしていて本質を忘れていると感じてます。本来は戦略的DDD(ドメインの関心の見つけ方とかコンテキストの切り分け方とかドメインモデルをどう探し出すかとか)がまず先にあって、それをどうコードに表現するかという話が戦術的DDDになるはずです。なので別にエンティティとかリポジトリがいなくてもドメインを綺麗に表現できていればDDDをうまく取り入れられてるよねというところで、設計のコンテキストでDDDが出てくるとちょっともやっとします。

責務を意識してReact/Reduxを使う

rei19.hatenablog.com

  • ↑の続き。仕事で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
  • Reducer
    • APIのレスポンスをそのまま新しいStateとしてReduxStoreに保存する
  • Container
    • Storeから画面で使うレスポンスとdispatchするActionをcomponentに渡す
  • Component
    • APIを呼ぶActionをdispatchする
    • レスポンスから必要な値を抜いて自分が必要な形に加工する。またcomponentによってはそれをstateに保存していたりしている。
      • 加工のためのutilityあるいはhelperという名で切り出されたロジックが大量にある。または各component内のprivate関数で重複して存在しているものもある
    • APIに投げるパラメータを作る(ActionのIF === APIのIFになってる)

という感じで、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を呼ぶActionをdispatchする
    • レスポンスから必要な値を抜いて自分が必要な形に加工する。またcomponentによってはそれをstateに保存していたりしている。
      • 加工のためのutilityあるいはhelperという名で切り出されたロジックが大量にある。または各component内のprivate関数で重複して存在しているものもある
    • APIに投げるパラメータを作る(ActionのIF === APIのIFになってる)
    • containerから受け取ったpropsを表示する
    • 画面で発生するライフサイクル/ユーザーのアクションのイベントをcontainerに通知する

また、複数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ビギナーズガイド ―コンポーネントベースのフロントエンド開発入門

Reactビギナーズガイド ―コンポーネントベースのフロントエンド開発入門

github.com

おわり

  • 久々にWebのフロント書いてるけど楽しい

KotlinとDataBindingとMVVMとか

今までDateBindingをButterKnifeの代わりのような使い方しかしてなかったので、ちゃんとMVVMっぽい作りもやってみようということで前に作ったアプリをごそっと書き換えてみました。アプリはKotlin製です。

rei19.hatenablog.com

そもそものMVVM

  • 以下の記事・スライドを参考にしてます。こういうアーキテクチャ系の話の原点って何を探ればいいのかちょっとわからなかったので印象に残った記事を読みこんでます。

ugaya40.hateblo.jp

qiita.com

techblog.reraku.co.jp

techblog.reraku.co.jp

speakerdeck.com

  • 自分の中で噛み砕いたポイントとしては以下の点
    • 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も同時に更新されます。

UserModel.kt

    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に通知しています。。。が、もっといいやり方がある気がします。
    • TwitterでアドバイスをもらってProgressDialogはViewModelにDIしたり、Activity/Fragmentにどうしても何かさせたい場合はViewModelがEventを公開してActivity/Fragment側でそれを監視するようにしました。EventBusは見通しが悪くなるのでこちらの方が追いやすいですね。

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で書き直してます(わりと前に書いたものがベースなのでちょっと変なところがあるかも)。

百人一首暗記するアプリ作ってからの振り返り

rei19.hatenablog.com

  • 上の記事から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位を取れてる。

レビューの返信

  • Googleの中の人のアドバイスに従ってレビューの返信をできるだけ頑張ってやってみた。

気づき

  • ニッチなジャンルでそんなに使われないかなと思ったけど、思ったよりインストールされたしDAUも安定してる。それなりに競合のアプリもあったがあまり更新されてないものも多いので、丁寧に作り込んで検索で引っかかるようにすればちゃんと使ってもらえる。
  • 上述のバグが原因で1点のレビューしてた人がいたけど、直した連絡をレビューの返信でしたら5点に変えてくれた。少なくとも低い評価のレビューしてる人にはちゃんとケアしてあげるとよさそう。
  • Firebaseを入れてみたけど、想定してた数字が取れていないので使い方の認識がズレてるとこがある。ドキュメント読み直して手を入れたい。