Android cookpadLiveで採用してる技術 2019夏

メディアプロダクト開発部の安部(@STAR_ZERO)です。

Android cookpadLiveで採用してる技術について紹介したいと思います。

cookpadLiveとは

cookpadLive は、料理上手な有名人や料理家がクッキングLiveを生配信しています。一緒に、Live配信でリアルタイムに料理が楽しめるアプリです。

ダウンロード: Android アプリ iOS アプリ

ぜひ、ダウンロードしてLive配信を見てください!

基本環境

基本となる環境です

  • Kotlin
  • minSdkVersion 21
  • targetSdkVersion 28
  • AndroidX

特別な箇所はないですが、最新に追随するように努めています。

比較的新しいアプリなので、最初からすべてKotlinで記述されています。

targetSdkVersionについてはそろそろ29に対応する予定です。29にすることでの影響を調査している状況です。

Android Studio 3.5

Android Studio 3.5はbetaの段階から導入しています。理由としてはIncremental annotation processingを使いたかったためです。

cookpadLiveでは全面的にDataBindingを採用しているため、これの恩恵は非常に大きいものになります。 これまでは、レイアウトファイルを変更してビルドし直さなければコードが生成されず効率がよくありませんでした。3.5からはレイアウトファイルを変更すると同時にコード生成も行われるのでビルドによる待ち時間が減り効率よく開発できるようになりました。

Jetpack

現在、cookpadLiveではJetpackを積極的に採用しています。

DataBinding、LiveData、ViewModelについては、最初は使用されていなかったのですが、徐々に導入を進めて今では全面的に使用しています。

意外と便利だったのが、ViewModelをActivityに関連付けることでActivityとFragment間、それぞれのFragment間でのデータやイベントのやりとりが可能になる機能です。この機能のおかげ実装がだいぶ楽になったこともありました。 例えば、以下のようにActivityのイベントをFragmentでも受け取ることが簡単にできます。

class HogeActivity: AppCompatActivity() {
    private val viewModel by lazy {
        ViewModelProviders.of(this).get(HogeViewModel::class.java)
    }
    fun someEvent() {
        // イベント発行
        viewModel.somaEvent.value = someValue // someEventはLiveData
    }
}

class HogeFragment: Fragment() {
    private val activityViewModel by lazy {
        // thisではなく、Activityを指定することで共通のViewModelを使用できる
       ViewModelProviders.of(requireActivity()).get(HogeViewModel::class.java)
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        activityViewModel.somaEvent.observe(viewLifecycleOwner) {
            // Activityのイベントを受信
        }
    }
}

Pagingはだいぶ癖があるライブラリですが、ちゃんと理解して使う非常に便利です。これも早い段階から導入しています。現状ではネットワークにはライブラリ側で対応はされてないですが、今後される予定らしいので、楽しみにしています。

Navigationについてはそこまで活用できないですが、部分的に使っています。全面的にSingle Activityにすることは考えてないですが、出来る箇所はFragmentに移行していきたいと考えています。また、SafeArgsは画面間の値の受け渡しが便利になるので積極的に使っていこうと思います。

RoomはDBまわりの処理には欠かせないくらい便利です。RoomはLivaData、RxJavaと簡単に連携することができるため、既存の実装に組み合わせることが簡単にできました。SQLも補完とシンタックスハイライトが効くので非常に助かります。

DI

DIについて Dagger を使用しています。こちらも最初は使用されてなかったのですが、徐々に導入をすすめました。

Daggerについては非常に難しい印象がある人が多いと思いますが、一度使うと便利すぎて手放せません。 DaggerでRepositoryクラスなど生成するようにして、あとは使いた箇所でInjectするだけです。ViewModelなどで必要なRepositoryが増えた場合も、生成するコードを意識せずパラメータに追加するだけ済みます。 最初の設定さえうまくやってしまえば、あとは楽になるはずです。

以前、部内でやったDagger勉強会のチュートリアルコードのリンクを貼っておきます。(まだ@Component.Factoryには対応してないです…)

STAR-ZERO/dagger-tutorial

AppSync

cookpadLiveではライブ中のコメントやハートなどのリアルタイム通信にAWSの AppSync を使用しています。

f:id:STAR_ZERO:20190826153800p:plain:w300

この部分が一番特徴的かもしれません。

AppSyncはAWSのマネージド型GraphQLサービスです。 ユーザーが送信したコメントやハートをAppSync(GraphQL)のSubscriptionの機能を使い受信するようにしています。

ライブによって非常に多くのコメントやハートを受信することになりますが、受信のたびに画面に描画するのではなくRxJavaのbufferを使ってある程度まとめて画面に描画するようにしています。このあたりはうまくRxJavaと組み合わせて実装しています。

AppSyncの話は以下の記事の後半部分を見ていただければと思います。

CookpadTVのCTOが語る、料理動画サービス開発の課題と実装 - ログミーTech

その他ライブラリ

このあたりよく使われてるライブラリですね。これらももちろん活用しています。

設計

MVVM + Repositoryパターンを採用しています。Googleが公開してる Guide to app architecture とほぼ同じです。

元々はVIPERだったのですが、DataBindingやLiveDataとViewModelを導入していくと同時にMVVMに移行していきました。今ではすべてMVVMで実装されています。 私個人の経験からもJetpackを導入することで、開発効率と品質に大きく貢献することは明確だったので、これらを導入しました。

VIPERはAndroidではあまりの馴染みがないかもしれませんが、MVPパターンのようにInterfaceを使って各レイヤー間の処理を呼び出します。 すごく簡単な例ですが、以下のような感じです。(例ではViewとPresenterしか登場してないです)

// HogeView.kt
interface HogeView {
    fun show()
    fun hide()
}

// HogeFragment.kt
class HogeFragment: Fragment(), HogeView {

    private val presenter by lazy { HogePresenter(this) }

    override fun onCreateView(/** */): View? {
        // ...
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        presenter.fetch()
    }
    override fun show() {
        // View.VISIBLEにする
    }
    override fun hide() {
        // View.GONEにする
    }
}

// HogePresenter.kt
class HogePresenter(private val view: HogeView) {
    fun fetch() {
        val data == // ...
        if (data != null) {
            view.show()
        } else {
            view.hide()
        }
    }
}

このようにViewへの処理を呼び出すのにInterfaceを使ってPresenterからViewへの処理を呼び出しています。

一見、問題がなさそうですが、この時点で既に問題があります。例えば、Presenterでデータ取得中にActiivty/Fragmentが破棄された場合はどうなるでしょう。破棄されてるオブジェクトにアクセスすることになり、場合によってはクラッシュします。これはPresenterがActivity/FragmentのLifecycleについて何も知らないからです。これを解決するにはPresenter側に破棄されたことを教えてあげる必要があります。

では、これを今の実装で書き換えた場合です。

// HogeFragment.kt
class HogeFragment: Fragment() {

    private val viewModel by lazy {
        ViewModelProviders.of(this).get(HogeViewModel::class.java)
    }

    private lateinit var binding: FragmentHogeBinding

    override fun onCreateView(/** */): View? {
        // ...
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        binding.viewModel = viewModel
        binding.lifecycleOwner = viewLifecycleOwner
        viewModel.fetch()
    }
}

// HogeViewModel.kt
class HogeViewModel: ViewModel() {
    val isShow = MutableLiveData<Boolean>()

    fun fetch() {
        val data == // ...
        isShow.value = data != null
    }
}
<!-- fragment_hoge.xml -->
<layout>
    <data>
        <variable name="viewModel" type="...HogeViewModel" />
        <import type="android.view.View" />
    </data>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="..."
        android:visibility="@{viewModel.isShow ? View.VISIBLE : View.GONE}" />
</layout>

ここではDataBindingを使用していて、ViewModelが保持しているLiveDataとバインドしています。このLiveDataの値を変更すると自動的にView側にも反映される仕組みになっています。 ここで重要なのは、LiveDataはLifecycleのことを知っているので、Activity/Fragmentがアクティブな状態のときしかデータを流しません。そのため、さきほど説明したActivity/Fragmentが破棄されたときの対応を特別にせずとも問題が起こることがありません。またViewModelおいては回転でActivityが再生成されたときもViewModelは状態をもっているため、データ取得を中断することなく処理を継続することができます。

この設計にすることでActivity/FragmentはViewModelの状態を反映すれば良いだけになり、責務もしっかり分かれて見通しが良くなりました。また単純にVIPERはファイル数が多くなるため、コードを追う時にコードジャンプであちこち飛ばなければならず、個人的にはコードが追いにくい感じでした。

他にも様々な面で効率・品質を向上させるのに貢献してくれています。その他便利なJetpackライブラリも簡単に導入できるようになっています。

今ではすべての画面が同じような感じになってるので、どういう処理をしてるのかを理解しやすくなっています。

この設計変更ですが、新規画面については新しい設計でやり、既存については少しずつ進めていました。またUIを大きく変更するタイミングもあったので、その時に一気に直した箇所もありました。出来ることからコツコツやってこともあり、大きくコストをかけることなく変更できました。

CI環境

CIに関しては、Jenkinsを使っています。やってることは以下になります。

  • Pull Request
    • テスト、lintを実行
    • 社内テスト環境にAPKをアップロード
  • masterマージ後
    • 社内テスト環境にAPKをアップロード
    • Google Play内部テストへアップロード

可能な限り早い段階でリリース版をビルドして触ることで、問題があったときに早めに気づくことができるようにしています。特にProguardまわりは見落としがちになるを防げます。

リリースするときは内部テスト版を製品版へ昇格するだけになっています。今ここは手作業でやってるのですが、ChatOps等で出来るようにしたいと考えています。

マルチモジュール

現状では、スマホ、AndroidTV、FireTVで共有するようなモジュールと、featureモジュールをいくつか分割しています。

図にすると以下のような感じです。

f:id:STAR_ZERO:20190826154302p:plain:w300

  • core
    • 全モジュールで共通処理
    • APIアクセス、Repository、データモデルなど
  • appcore
    • スマホアプリ共通処理
    • 共通View、ログ、リソースなど
  • feature
    • 各機能を分割したモジュール
  • app
    • スマホアプリメイン
  • smarttv
    • TVアプリ共通処理
    • 共通View、ログ、リソースなど
  • androidtv
    • AndroidTVメイン
  • firetv
    • FireTVメイン

まだfetureモジュールは分割できる箇所があるので、少しずつでも進めていきたいと思います。

課題と今後

テスト

正直、まだそこまでうまく書けてる状況ではないので、なんとかしていきたいと思っています。 せっかくなので、ライブラリの選定から考えようとも思っています。Truth 良さそうですね。

StyleとTheme

StyleとThemeについては結構ちらかってる状態なので、整理したい思っています。画面数もそこそこあるので、だいぶ大変な作業になる気配がしています。まずは、どういうふうに整理するかを検討してから少しずつやっていく感じになりそうです。

Navigation

前に書きましたが、まだまだ活用できる箇所があります。すべてSingleActivityとは考えてないですが、Fragmentでの遷移で良い箇所もあるので、そういった箇所に対応していきたいと考えています。

Coroutines

Coroutinesについては、どうするかを検討している段階です。現状でCoroutinesじゃないと困るような場面は出てきていませんが、JetpackもCoroutinesの対応が進んでいて実装するのに困ることはないと考えています。また、今後Coroutinesによって実装コストが下がるような機能なんかも出てくる可能性ありそうです。 メンバーと会話して、導入する気持ちはありますが、進め方やどこから導入するのかを考えています。

まとめ

cookpadLiveでは積極的にJetpackを使っていき、Googleが推奨しているやり方にどんどん乗っかっていっています。 今後もJetpackも改善されていくと思うので、それにいつでも追随できるような状態を保つようにしています。

これからもcookpadLiveでは新しい技術を積極的に取り入れていきますし、やりたいこともまだまだたくさんあります。

興味がある方いらっしゃいましたら、気軽にお声がけください。一緒に色々チャレンジしていきましょう。

info.cookpad.com

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/