もやもやエンジニア

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

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

実装から考えてしまう癖に気をつけようという話

これはサービス開発してる人にとっては当たり前かもしれないのですが、自分に対しての戒めでもあるので、なんとなく文章として残しときます。

どんなことかというと、自社サービスを開発・運営していると、不定期に○○をこうしてほしい、△△はユーザーが使いづらいと言っていたので直したほうがいいのではーなどの要望が上がってくるわけですが、その時の思考の入り口として、それはユーザーにとってどのようなメリットが有るのか、体験が改善されるのかというところから入るのではなくて、その言われた要望は実装的に出来そうか出来なさそうか、というところから考えてしまうという話です。

これは割と癖になりやすいかなーと思っていて、早いうちにプログラマーの仕事は言われたことを作る仕事であるみたいな感じで染み付いてしまうと、なかなか変えづらいのかなと感じてます。特に今は僕は2Bのサービスを作っていてクライアントが明確なので2Cのサービスよりダイレクトにユーザーの声を聞きやすく、故に「あ、それは実装できますね、やりましょうか」みたいな感じでほいほい言われたものを作るだけだと、容易に統一感がなく使いづらいプロダクトが出来上がってしまいます。

ユーザーのオペレーションも含めて検討した結果、その要望は実は既存の他の機能で満たせることかもしれないし、見せ方を変えるだけで解決するかもしれない。僕らの仕事はモノを作ることではなく、課題を解決することにあって、何かを開発することはその選択肢のひとつにすぎないのですよね。

というようなことをインターンの子に↓の話をしてるときに改めて思ったので、僕も意識しようと思ったという話でした。おしまい。

イシューからはじめよ―知的生産の「シンプルな本質」

イシューからはじめよ―知的生産の「シンプルな本質」

ちはやふるにはまったので百人一首を暗記するサイトをSPAで作った

夜なべしてコツコツ作っていたのがまあまあ形になったので公開しました。ちはやふる ~結び~の公開には間に合わなかった。。。

https://hyakuninanki.net/

去年から仕事でB向けのサービスをSPAで開発してて、ある程度自分の中で知見がたまって自分でも何か作ってみようと思って、Androidアプリで出したやつ(下記参考)の焼き直しをSPAで公開することにしました。見た目はほぼ一緒。

rei19.hatenablog.com

雑なコメント

  • React/Reduxで取り立てて珍しいものはないです。言語はTypeScript。型最高。ReactはできるだけFSCベースでPropsをViewに変換するようなイメージでComponentを組んでます。
  • styleはstyled-componentsで管理。デザインフレームワークのサポートにblueprintjsというのを使ってみたけど、使わなくても良かったかも(material-uiは仕事で使ってるので避けた)
  • テストもぼちぼち書いてます。jestのsnapshot機能は最高なので、テスト書かないプロジェクトでもsnapshotだけでも取るようにすると意図しない変更が検知できていいのかなと思いました。
  • SSRまでは頑張っていなくて、単にbundleしたjsとcssを撒いているだけという手抜き構成。ちゃんとクローラが見てくれるのかはよくわかってない。。。クロールされた後の検索結果見てから考えます。
  • webpack4に上げたらawsome-typescript-loaderが対応してないのか、ファイル修正するたびにエラーが起きてリロードしなくなってしまった。。。多分ts-loaderにしたら直る(気がする)

おしまい

  • ポートフォリオ代わりにコードは公開してます。もうちょいコンテンツ充実させるのと、インタラクションを調整する予定。

GitHub - rei-m/web_hyakuninisshu

Recomposeを使ってReact/ReduxのComponentを整理する

Recompose

元のComponent

  • 対象は何かAPIを呼んで受け取った結果を表示するComponentで、呼び出し中はProgressを表示して完了したら結果を表示するという仕様です。
  • Sampleのコードは色々省略してるので雰囲気です。
  • 言語はTypeScriptです。型はいいものです。

containers/SampleHoc.tsx

const mapStateToProps = ({ isFetched, data }: GlobalState) => {
  // APIが呼び出し終わっていたらisFetchedはtrueになってdataが取得できている。
  return {
    isFetched,
    data
  };
};

const mapDispatchToProps = (dispatch: Dispatch<GlobalState>) => {
  return {
    onStart: () => {
      dispatch(fetchData());
    }
  }
};

export default connect(mapStateToProps, mapDispatchToProps)(Sample);

components/Sample.tsx

interface SampleProps {
  isFetched: boolean;
  data: Data;
  onStart: () => void;
}

export default class Sample extends React.Component<SampleProps> {

  public componentDidMount() {
      // 初回描画時のイベントを発火してContainerでAPI呼ぶ
      this.props.onStart();
  }

  public render() {
    if (!this.props.isFetched) {
      // 読み込み中表示
      return (
        <Progress />
      );
    }

    // Data表示
    return ();
  }
}

SampleをStatelessComponentにする

  • 上ではReact.Componentクラスを継承してライフサイクルのイベントを実装していますが、StatelessComponentはpropsを受け取ってcomponentを返す形式の関数を書きます。

components/Sample.tsx

const Sample = (props: SampleProps) => {
  if (!props.isFetched) {
    // 読み込み中表示
    return (
      <Progress />
    );
  }
    
  // Data表示
  return ();
}

export default Sample;

で、これだけだとライフサイクルのハンドリングができないのでRecomposeのlifecycleを使ってHOCで実装します。

components/Sample.tsx

// 上の続き

export default lifecycle<SampleProps, {}>({
  componentDidMount() {
    this.props.onStart();
  }
})(Sample);

これで同じ挙動になりました。こちらのほうがPropsをViewに変換するというイメージでComponentを書けます。

SampleComponentの責務を分割する

  • Sampleをrenderするときに読み込み中の判定をしているところを改善してSampleは受け取ったdataを表示するだけという形にします。Recomposeのbranchを使って実装してみます。

containers/SampleHoc.tsx

// SamplePropsからisFetchedを分離してこちらに定義する
export interface SampleHocProps {
  isFetched: boolean;
}

// Propsからデータ取得済みか判定する関数を作成
const checkFetched = ({ isFetched }: SampleHocProps) => isFetched;

// checkFetchedがtrueならwithFetchedCheckに渡したcomponentが返る
// checkFetchedがfalseならInitializerComponentが返るという設定。Initializerは次で説明
const withFetchedCheck = branch<SampleHocProps>(
  checkFetched,
  component => component,
  _ => Initializer
);

export default connect(mapStateToProps)(withFetchedCheck(Sample));
  • Progressを表示する際にAPIをコールするようにReduxのHOCを実装します。

containers/Initializer.tsx

const mapDispatchToProps = (
  dispatch: Dispatch<GlobalState>
): ProgressDispatchProps => {
  return {
    onStart: () => {
      dispatch(fetchData());
    }
  };
};

const Initializer = lifecycle<ProgressDispatchProps, {}>({
  componentDidMount() {
    this.props.onStart();
  }
})(Progress);

export default connect(undefined, mapDispatchToProps)(Initializer);

これでAPIの呼び出しとProgressの表示は分離できました。Sample.tsxは以下のように書き直せて、純粋に受け取ったPropsをViewに変換するという形になります。

interface SampleProps {
  data: Data;
}

const Sample = (props: SampleProps) => {
  // Data表示
  return ();
}

export default Sample;

おわり

  • Recomposeを使ってHOCとComponentを整理してみました。他にも便利な関数群が用意されているみたいなので少しずつ試そうかなと思います。
  • 実際にこの形式で実装しているアプリケーションはWIPなので完成したらまた公開します。

ArchitectureComponentsのViewModelとDataBindingを使う

触るがてら既存のDataBinding + MVVMなアプリにAACのViewModelを入れてみました。対象は結構前にKotlinで書いたアプリ。

GitHub - rei-m/HBFav_material: HBFav for Android with kotlin

2018/07/26 更新 こっちが自分なりの最新の設計なのでこっち見たほうがいいです。 GitHub - rei-m/android_hyakuninisshu

AACのViewModel

developer.android.com

  • Activity/Fragmentとは別のライフサイクルをもったDataHolderの模様
  • 説明ざざっと読んだ感じ onSavedInstanceState いらないのか!と思ったもののそうではなく、普通にActivity保持しないとかにすると消し飛ぶので、自分が死ぬタイミングがわかっていて、Activity/Fragmentとは別のライフサイクルをもったModelであると解釈

書いてみる

Model層

  • 既存の作りと変わらずにObservableなプロパティを公開しているアプリケーションスコープなModelとして作っています。

ViewModel層

  • 既存のDataBindingに食わせていたViewModelは基底クラスにライフサイクルメソッドを生やして、Activity/Fragmenntからそれを呼んでModelの購読/解除をしていました。ここを変えてAACのViewModelを継承するようにします。DataBindingに公開するObservableFieldは変えていません。

自分のブックマーク表示する画面のVM / UserBookmarkFragmentViewModel.kt

class UserBookmarkFragmentViewModel(private val userBookmarkModel: UserBookmarkModel,
                                    userModel: UserModel,
                                    readAfterFilter: ReadAfterFilter) : ViewModel() {

略)

   init {
        userId.addOnPropertyChangedCallback(userIdChangedCallback)
        hasNextPage.addOnPropertyChangedCallback(hasNextPageChangedCallback)
        this.readAfterFilter.addOnPropertyChangedCallback(readAfterFilterChangedCallback)

        disposable.addAll(userBookmarkModel.bookmarkList.subscribe {
            if (it.isEmpty()) {
                bookmarkList.clear()
            } else {
                bookmarkList.addAll(it - bookmarkList)
            }
            isVisibleEmpty.set(bookmarkList.isEmpty())
        }, userBookmarkModel.hasNextPage.subscribe {
            hasNextPage.set(it)
        }, userBookmarkModel.isLoading.subscribe {
            isVisibleProgress.set(it)
        }, userBookmarkModel.isRefreshing.subscribe {
            isRefreshing.set(it)
        }, userBookmarkModel.isRaisedError.subscribe {
            isVisibleError.set(it)
        }, userModel.user.subscribe {
            userId.set(it.id)
        })
    }

    override fun onCleared() {
        userId.removeOnPropertyChangedCallback(userIdChangedCallback)
        hasNextPage.removeOnPropertyChangedCallback(hasNextPageChangedCallback)
        readAfterFilter.removeOnPropertyChangedCallback(readAfterFilterChangedCallback)
        disposable.dispose()
        super.onCleared()
    }

略)
  • 自分が破棄されるタイミングがわかるようになったので、生成時にModelのプロパティの購読開始。onClearedで購読解除するようにしました。ここではuserModel.userの購読開始するとBehaviorSubjectから値が流れてくる -> ViewModelがUserIdの変更を検知してブックマークとる命令を出す、という感じでViewModel生成時にバックグラウンドでデータを取りに行っています。

ViewModelとFactory

  • ViewModelにBundleから取り出した引数を与えたいというような場合、ViewModelProvidersにFactoryを与えることで、ViewModel生成時の処理に介入できるようになります。FactoryはDagger経由でDIするようにしてます。
viewModel = ViewModelProviders.of(this, viewModelFactory).get(OthersBookmarkFragmentViewModel::class.java)

他人のブックマーク表示する画面のVM / OthersBookmarkFragmentViewModel.kt

略)
    // ViewModelProvidersに渡すViewModelを提供するFactory
    class Factory(private val userBookmarkModel: UserBookmarkModel,
                  private val userId: String) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel?> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(OthersBookmarkFragmentViewModel::class.java)) {
                return OthersBookmarkFragmentViewModel(userBookmarkModel, userId) as T
            }
            throw IllegalArgumentException("Unknown class name")
        }
    }

Factoryを提供するModule / OthersBookmarkFragmentViewModelModule.kt

@Module
class OthersBookmarkFragmentViewModelModule(private val userId: String) {
    @Provides
    @ForFragment
    fun provideViewModelFactory(@Named("othersUserBookmarkModel") userBookmarkModel: UserBookmarkModel): OthersBookmarkFragmentViewModel.Factory =
            OthersBookmarkFragmentViewModel.Factory(userBookmarkModel, userId)
}
  • userBookmarkModelはDaggerからDIされますが対象のユーザーIDはModule生成時に設定してあげる必要があります。

他人のブックマーク表示する画面 / OthersBookmarkFragment.kt

略)
    @dagger.Subcomponent(modules = arrayOf(
            OthersBookmarkFragmentViewModelModule::class,
            BookmarkListItemViewModelModule::class))
    interface Subcomponent : AndroidInjector<OthersBookmarkFragment> {
        @dagger.Subcomponent.Builder
        abstract class Builder : AndroidInjector.Builder<OthersBookmarkFragment>() {

            abstract fun viewModelModule(module: OthersBookmarkFragmentViewModelModule): Builder

            override fun seedInstance(instance: OthersBookmarkFragment) {
                viewModelModule(OthersBookmarkFragmentViewModelModule(instance.userId))
            }
        }
    }

    @dagger.Module(subcomponents = arrayOf(Subcomponent::class))
    abstract inner class Module {
        @Binds
        @IntoMap
        @FragmentKey(OthersBookmarkFragment::class)
        internal abstract fun bind(builder: Subcomponent.Builder): AndroidInjector.Factory<out Fragment>
    }
}
  • Fragment内のinner classとしてSubComponentを定義してその中のseedInstance内でFragmentのインスタンスからuserIdを取り出してModuleを作成しています。onSavedInstanceStateから値を復元したい場合はDIされたviewModelFactoryに取り出した値をセットしてからViewModelProviderに渡すようにしました。

書いてみて

  • 毎回Activity/FragmentのライフサイクルでviewModel.onXXXX を呼んでたのでそれがなくなったのはだいぶ楽
  • こういう使い方でいいのか、あるいはDataBindingのViewModelとは別のレイヤーとして考えるべきなのか?とか一人で書いてるとどっちがいいのかよくわからんという感じになったのでトークしたい。

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のアンチパターン