Infinito Nirone 7

白羽の矢を刺すスタイル

持続的なアプリ開発のための DX を支える技術 #DroidKaigi 2020

この記事は、DroidKaigi 2020 で発表予定だったセッション「持続的なアプリ開発のための DX を支える技術」を解説するための記事です。

セッション概要

Android の歴史はすでに 10 年を超え、数多のアプリケーションがストアで公開されています。これらのアプリケーションの中には、何年も継続的にバージョンアップを重ねているものもたくさんあります。

このセッションでは、このような持続的なアプリケーション開発・リリースをうまく回す秘訣として DX という言葉をとらえ、アーキテクチャやテストのほか、日々の開発に関わるワークフローをメンテナンスするための考え方や手立てとして、モバイル CI や Android 向け各種ツールキットの導入と効率化、Gradle をベースにした独自タスク開発の方法などを紹介します。

資料

speakerdeck.com

一部実装の詳細を資料に委ねています。適宜資料と合わせてご覧ください(開設の流れは資料に合わせています)。

解説

このセッションで目指すもの

このセッションでは、エンジニアリングチームとしてよい DX を獲得するための指針として、開発着手前からリリース後の運用に至るまでの間にどんなことに注目するとよいかを示し、モバイルアプリ開発のプロセスを持続的に素早く回していくための考え方や実践例を解説します。

良い DX を支える技術

ここでいう DX とは Developer Experience*1 の略語のことです。大まかに言うと、日々の開発が健全に回せているかどうかを表現するもので、良し悪しを測るもの(良い DX = 開発に健全に取り組めている、悪い DX = 開発に健全に取り組めていない)です。

私たちは普段、コードに様々な変更を加えることでプロダクトを作っています。変更の理由は様々ですが、どれもプロダクトを成長させるために必要なことをしているはずです。ただ、それが本当に成長に寄与するかどうかはリリースしてみなければわかりません。そのため、1週間から2週間程度の期間で区切って定期的にリリースをすることで、それが狙い通り機能しているかどうかをチェックするプラクティスがうまれ、実践されています。

この1週間から2週間程度の期間というのはプロジェクトやチームによって様々ですが、この期間で設計をし、実装からQAまで完了するには、日々のワークフローを素早く健全にまわしていく「良いDXのため」の基礎体力やカルチャーが必要で、かつこれを持続的に取り組んでいくことが必要になります。

このセッション(記事)では、私たち開発者が日々使っているツールやサービスなどを活用しながら、良い DX を支え続けていくためのポイントを抑え、チームでの取り組みに落とし込むための指針を示していきます。

良い DX を支える技術: 事前準備

まずはじめに、チームの中での認識をあわせておくことがあります。たとえば、コーディング規約やブランチの運用方法などがあげられます。

コーディング規約

コードの書き方についてチームで認識を揃え、誰がどのコードを見てもある程度の読みやすさを確保します。

タブインデントかスペースインデントか、一行の長さは何文字までかといったスタイルから、Kotlin や Java での式の書き方など言語ごとの記法についても決めておきます。特に言語ごと特有の記法については、その記法に決めた理由や目的も合わせて明確にしておきましょう。

規約について決めたら、.editorconfig を使って誰でもおなじスタイルでコードを記述できるようにしておきます。Android Studio をはじめさまざまな IDE やエディタは自動でこのファイルを読み取り、スタイルを適用してくれます(プラグインを入れることで対応できるものもあります)。また静的解析ツールもこのファイルを参照できるので、CI で静的解析を実行するときにも利用できます。

次にプロジェクトのルートディレクトリに配置する.editorconfigファイルの例を示します。この例では、すべてのファイルについてインデントはスペース2つで、末尾の空白を取り除くなどを設定し、Java や Kotlin のソースコードについては 1 行につき 140 文字まで許容する設定になっている一方、Markdown では末尾のスペースにも意味がある(スペース2個で改行する)ので、特別に取り除かない設定をしています。

root = true

[*] // apply the following styles to all files
indent_style = space
indent_size = 2

end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.{java,kt,kts,xml}] // only apply to java/kt/kts/xml files
max_line_length = 140

[*.md] // only apply to markdown files
trim_trailing_whitespace = false

ブランチ戦略

ここでは Git を使うことを前提としますが、ブランチ戦略としてブランチごとに役割を定め、ワークフローを作っていくことで、差分管理を整理します。

Git には Git flowGitHub flow の2つのよく知られたブランチ戦略があります。もちろん、これ以外のブランチ戦略を採用するのもありですが、大事なことは、どのブランチにはどんな差分をコミット・マージすべきかを共有しておくことです。

たとえば、Git flow には master、develop の 2 つの主要なブランチ(GitHub などでは Protected branches に指定するような、全員の差分をまとめておくブランチ)と、作業ごとに使い分ける release、hotfix、feature ブランチがあります。develop ブランチは普段の機能開発用の差分を持っており、リリースに必要な差分が揃ったら release ブランチを作ります。release ブランチでは QA などで見つかったバグを修正する差分のみを保持し、すべてのバグ修正がおわったら master ブランチと develop ブランチにマージします。登場するブランチが多く管理コストはかかりますが、特定のリリースへ向けた差分を管理しやすく、モバイルアプリケーションのリリースライフサイクルとの相性が良いブランチ戦略です。

f:id:KeithYokoma:20200330191311p:plain
Git flow

一方で GitHub flow は Git flow を簡素化し、master のみをを主要なブランチとして差分を管理します。リリースも常に master から行うので、master にある全ての差分がリリース可能な状態であることが求められます。いつでもリリースしてよい状態が保たれることが前提となるので、どちらかといえば日々継続的にデプロイするサーバーアプリケーション向きのブランチ戦略です。

f:id:KeithYokoma:20200330191332p:plain
GitHub flow

あるいは、Git flow と GitHub flow の中間のようなブランチ戦略を考えても良いかもしれません。Git flow では master ブランチにリリース可能な差分を持っており、master ブランチのコミットはタグとほぼ一致するはずです。このタグを打つ場所を、release ブランチや hotfix ブランチの最後のコミットとしてよいのであれば、master ブランチと develop ブランチはひとつにまとめられます。

f:id:KeithYokoma:20200330191350p:plain
Composition of Git flow and GitHub flow

良い DX を支える技術: 設計

設計と一言で言っても、さまざまな考え方があります。クリーンアーキテクチャ、レイヤードアーキテクチャといった大枠の考え方もあれば、MVC や MVP、MVVM などプレゼンテーションにフォーカスした考え方もあります。いずれにしても大事なことは、クラスごとに与える責務をうまく定義して、その責務をユニットテストで検証することです。特に Android アプリなどのモバイルアプリでは、次の 3 つが設計を考える上での大きなトピックになります。

  1. API 通信やビジネスロジックを画面の実装から分離したい
  2. 画面が持つ状態と、その状態遷移をうまく表現したい
  3. 状態に応じた UI の変更ロジックを分離したい

どのトピックでも、それぞれに設計パターンがあるので、もうすこし掘り下げて解説します。

設計パターン

API 通信やビジネスロジックを画面の実装から分離する

MVC、MVP、MVVM ともにプレゼンテーションにフォーカスした設計パターンで、どのパターンでも Model と呼ばれる場所にはさらにいくつもの役割が存在します。API 通信やビジネスロジックはまさにこの Model と呼ばれている役割のなかにあります。Model の役割は多岐にわたりとてもフワッとしているので、API 通信であればそれに沿った命名を、ビジネスロジックであればそれをうまく表現する命名をし関心を分離していくほうが、どこで何をしているのか明確になりテストも容易です。

そこで、プレゼンテーションだけではなく全体としてどんな役割があるかを整理した考え方としてレイヤードアーキテクチャやクリーンアーキテクチャなどの考え方を取り入れます。 レイヤードアーキテクチャでは API 通信のような箇所をインフラストラクチャ層やデータアクセス層と呼び、ビジネスロジックに相当する箇所をビジネス層やドメイン層と呼んで管理します。これによって、それぞれの層のユニットテストで何をチェックしたらよいかもうまく決められるようになります。

画面が持つ状態と、その状態遷移をうまく表現したい

たとえば、API から1つのデータを取得し表示する画面を考えてみても、その画面が持ちうる状態はすくなくとも API を呼び出す前の初期状態、API を呼び出している最中の状態、API が成功を返してきた状態、API がエラーを返してきた状態の 4つあります。またこの状態の遷移には一定のルールがあり、成功・エラーは必ず呼び出し中の状態から遷移する制約が考えられます。

画面が持つ状態をドメインオブジェクトのようなものとしてとらえ、その状態をどのように遷移させるかをドメインロジックのようなものとしてとらえると、状態を表すクラスと、状態遷移のロジックを書くクラスの2つを作り、状態遷移のロジックの正しさをユニットテストで検証できるとうまく設計できそうです。

これを仕組みとしてフレームワーク化したものが Redux や Flux です。これらはもう少し登場人物が多く作るものが増えていますが、根本の考え方は変わりません。これらを実装に落とし込んだものとして、DroiduxRxReduxK などがあります。

もし Redux や Flux のフレームワークが重厚長大と感じる場合には、Rx のオペレータで状態遷移のロジックを表現し、新たな状態を下流へ通知するような流れを実装しても、同じことができます。Rx から LiveData への変換をすれば、プレゼンテーションでの実装は更に簡素化できます。この実装例は DroidKaigi 2020 のスタッフを支える(はずだった)技術 - 運営スタッフ向けアプリ - Infinito Nirone 7 の「プレゼンテーションレイヤの設計」にかんたんな例を示していますので、詳しい解説はこの記事を参照してください。次に RxJava と LiveData を組み合わせ、Android Architecture Component ViewModel を使った実装を示します。ここでさらに SavedStateHandle を組み合わせると、Activity の破棄と復帰にも対応できますようになります(別途 State を Parcelable にする必要はありますが)。

// State オブジェクト
data class ScheduleViewState(
  val schedule: Loadable<List<Schedule>, FailureReason> = Loadable.Ready
)

// ViewModel
class ScheduleViewModel(
  private val scheduleDataSource: ScheduleDataSource // 行動予定のデータを取ってくるデータアクセス層のインタフェース
) : ViewModel() {

  // 実際には MutableLiveData を公開してしまったまま作り込んでいた。LiveData を公開し、MutableLiveData は公開しないほうがいい…
  val _state: MutableLiveData = MutableLiveData(ScheduleViewState())
  val state: LiveData<ScheduleViewState> = _state

  private val disposables = CompositeDisposable()

  fun loadSchedule() {
    // DataSource は RxJava で結果を返すようにしている
    scheduleDataSource.load()
      .observeOn(AndroidSchedulers.mainThread())
      .doOnSubscribe {
        _state.nextState { // ScheduleViewState の schedule を読込中状態に更新
          it.copy(schedule = Loadable.Loading)
        }
      }
      .subscribe({ result ->
        _state.nextState { // ScheduleViewState の schedule を読込済み状態に更新
          it.copy(schedule = Loadable.Success(result))
        }
      }, { exp ->
        _state.nextState { // ScheduleViewState の schedule を読込失敗状態に更新
          it.copy(schedule = Loadable.Failure(InvalidResponse(exp.message.orEmpty())))
        }
      })
      .addTo(disposables)
  }

  override fun onCleared() {
    disposables.clear()
    super.onCleared()
  }
}

この状態遷移をテストするコードを次に示します。ある操作をしたとき、期待した順番で状態遷移が起きることを LiveData で検証します。

class ScheduleViewModelTest : Spek({

  // テスト準備
  beforeEachTest {
    RxAndroidPlugins.setMainThreadSchedulerHandler { Scheduler.trampoline() }
    ArchTaskExecutor.getInstance().setDelegate(TestExecutor())
  }

  afterEachTest {
    RxAndroidPlugins.reset()
    ArchTaskExecutor.getInstance().setDelegate(null)
  }

  Feature("ScheduleViewModel#loadSchedule") {

    val dataSource: ScheduleDataSource by memoized(CachingMode.EACH_GROUP) {
      mockk<ScheduleDataSource>(relaxed = true)
    }

    // Schedule が正しく読み込めたケース
    Scenario("Load data successfully") {
      lateinit var viewModel: ScheduleViewModel
      lateinit var observer: Observer<ScheduleViewState>
      lateinit var changedStateSlot: CapturingSlot<ScheduleViewState>

      // ViewModel を準備して…
      Given("ViewModel with initial state and state observer") {
        changedStateSlot = slot()
        observer = mockk(relaxed = true) {
          every { onChanged(capture(changedStateSlot)) } just Runs
        }
        every { dataSource.loadApiData() } returns Observable.just("foo")
        viewModel = NotificationViewModel(dataSource)
      }

      // ScheduleViewState を observe し…
      When("Start observing states") {
        viewModel.state.observeForever(observer)
      }

      // 読み込みを実行すると…
      And("Start loading") {
        viewModel.loadSchedule()
      }

      // 初期状態 -> 読込中 -> 成功 の順で ScheduleViewState が通知される
      Then("State changes observed in the specified order") {
        verifyOrder {
          observer.onChanged(eq(ScheduleViewState(schedule = Initial)))
          observer.onChanged(eq(ScheduleViewState(schedule = Loading())))
          observer.onChanged(eq(ScheduleViewState(schedule = Success(listOf(Schedule(/* mock data */))))))
        }
      }
    }

    // ...
  }
})

class TestExecutor : TaskExecutor() {
  override fun executeOnDiskIO(runnable: Runnable) { runnable.run() }
  override fun isMainThread(): Boolean = true
  override fun postToMainThread(runnable: Runnable) { runnable.run() }
}

状態に応じた UI の変更ロジックを分離したい

状態をうまく表現できれば、プレゼンテーションの責務は「状態に対応して適切に UI を変更すること」になります。一方で、Activity や Fragment はこれ以外にもライフサイクルの管理などモバイルアプリ特有の責務もあります。ライフサイクル管理についてはどうしても切り離せないのでそのままにしておくとして、状態に対応して適切に UI を変更する責務は、何らかの形で委譲ができそうに思います。これは Jetpack の Data Binding を使ってもよいですし、単純な委譲パターンを実装するでもよいでしょう。ユニットテストで UI の更新ロジックを検証するなら、単純な委譲パターンを採用するとやりやすいでしょう。あるいは、View コンポーネントをまとめて取り扱う Custom View を定義し、Activity や Fragment からはその Custom View で取り扱っている型のオブジェクトを渡すような作りにするのもよさそうです。

モジュール構成

設計パターンを考える上で、どんなモジュール構成にするかも少なからず影響があります。

シングルモジュールであれば、設計パターン如何に関わらずモジュールは常に1つなので、構成については何も考えることはありません。マルチモジュールの場合は構成をどうするかについて共通認識が必要になります。 一方、マルチモジュールの場合にはモジュール間の依存関係を定義でき、依存関係のないモジュールにはアクセスできなくなります。必要なモジュールにのみアクセス可能な状態にしておくことができるのは、シングルモジュールにはないマルチモジュールの利点です。

では、マルチモジュールにするとして、どのようにモジュールを分割するとよいでしょうか。いくつかの方法が考えられます。

  1. レイヤードアーキテクチャのレイヤーごとにモジュールを作る
  2. 機能やドメイン単位でモジュールを作る
  3. 1 と 2 を組み合わせてモジュールを作る

どの分け方でも良いですが、分け方に応じてモジュールの命名規則を予め決めておくと、どのモジュールにどのコードがあるかがわかりやすくなります。また、モジュール間の依存関係についても、適切な依存関係と不適切な依存関係についてあらかじめ決めておきましょう。

次にレイヤードアーキテクチャに基づいてモジュールを分けたときの命名規則を示します。

  • アプリケーション本体: app
  • プレゼンテーション/画面: feature_**
  • ユースケース/ドメイン/ビジネスロジック: usecase_**
  • インフラストラクチャ/データアクセス: repository_**
  • 横断的な共通機能: common_**

命名規則を決めたら、モジュール間の依存関係の作り方についてルールを設けます。

このモジュール構成の場合、app モジュールはすべての機能について知っている必要があるため、すべての feature_** モジュールに依存することは適切であると決めます。

一方 feature_** モジュールは他の feature_** モジュールについて知っているべきでしょうか。これは画面同士のつなぎこみ(ナビゲーション)をどのモジュールで実装するかによります。feature_** モジュール同士を依存させない場合には、画面遷移のためのインタフェースだけを feature_** モジュールで定義し、その実装は app で行い注入する仕組みが必要です。feature_** モジュール同士の依存を許容する場合は、直接画面遷移の実装を feature_** モジュールに書けますが、モジュールが循環参照しないよう気をつける必要があります。どちらも、アプリケーションの設計が必要になります。

レイヤードアーキテクチャに基づいたモジュールの分け方では、同じレイヤー内のモジュールに依存することはなく、違うレイヤーにあるモジュールに依存するのは正しいとするのがスッキリしそうです。

f:id:KeithYokoma:20200330194916p:plain
Good dependency

f:id:KeithYokoma:20200330194935p:plain
OK

f:id:KeithYokoma:20200330194956p:plain
NG

これとは別に、機能横断的に使われるモジュールはどのモジュールからでも依存してよいことにします。ただし、機能横断的なモジュールは往々にして関心が膨らんでコード量がふえがちなので、取り扱う関心事(UI 向けのユーティリティ、API 通信のユーティリティなど)ごとにモジュールを分けておくと、インフラストラクチャのモジュールが UI の共通機能へアクセスしてしまうようなことが減らせます。

良い DX を支える技術: 開発プロセス

どんな設計パターンを採用するかが決まり、モジュールの扱い方も決まったら、アプリケーションの実装が始められますが、日々の開発ではコードを書く以外にも様々なタスクがあります。ビルドが成功するかどうかをチェックしたり、コードが事前に決めた規約に沿って書かれているか、あるいは意図が伝わりやすい書き方かどうかをチェックしたりなど、コードの変更がうまく機能し、品質が保たれていることをチェックをしているはずです。ここでは、それらの取り組みを持続的にまわしていくための技術を解説します。

ビルドの自動化

日々の開発の中で、自分のつくった差分がうまく動かせるかどうかは最低限自分の環境で試すはずです。

それが他の人の環境でも動かせるかどうか、あるいは他のビルド設定でも動かせるかどうかもチェックしておくと、差分を安心してマージできます。しかし、これをいろいろなブランチで手作業でやるにはとても手間がかかるので、差分が正しくコンパイルでき、正常に成果物が生成できることを自動化しましょう。

自動化の手段は CircleCI や TravisCI、Bitrise、Wercker、Jenkins など多種多様なサービスやソリューションがあるので、詳しいセットアップの方法はそれらのサービスのドキュメントを参照してください。

テストの自動化

ビルドの自動化ができたら、次はテストの自動化をしましょう。これで、テストコードで検証している範囲で、差分が意図したとおりに動いていることが確かめられます。多くの CI サービスではテストの結果を、コードをホストするサービス(GitHub など)に通知してくれるので、コードレビューをするときのチェック項目の1つとしてかんたんにチェック可能なものになります。

ただし、テストの自動化はとても重要ですが、プロダクトの成長とともにどうしても実行時間が長くなります。ビルドやテストなど自動化したものが5分ほどで終わるなら問題ないかもしれませんが、15分や20分かかるとなると、その待ち時間は少なくありません。実行時間の増加は避けられないことではありますが、その増加のしかたはできる限り最小限にとどめられるようにしておきましょう。次に、それを実現するいくつかの方法を示します。

  1. Gradle の並列実行オプションを使い、できる限り多くのプロセスでビルドする (e.g. Improving the Performance of Gradle Builds)
  2. 一部の CI サービスで利用できる分散実行のスキームを使い、テストを分散実行する (e.g. CircleCI での Android プロジェクトのビルド設定と自動化の工夫 - Mercari Engineering Blog)
  3. 時間に依存するテストでの待ち時間をへらす (e.g. RxJava で delay など時間に関するオペレータを使ったときのテスト - Infinito Nirone 7)
  4. 不要なテストをへらす

CircleCI におけるテストの分散実行

CircleCI にはコンテナを複数起動し、コンテナごとに別のテストを実行するための仕組みがあります。次の図では、マルチモジュール構成なプロジェクトにおける、単一のコンテナでテストを実行する場合の概念図と複数コンテナでテストを実行する場合の概念図を対比しています。

f:id:KeithYokoma:20200330195128p:plain

もしシングルモジュール構成で分散する場合は、CircleCI が提供している CLI ツールを使ってコンテナごとに実行すべきテストを決定できます。

マルチモジュール構成で分散する場合でも CircleCI の CLI ツールは利用できますが、ファイル単位で分散することになり、ツールの吐き出した結果からモジュール名を取り出して加工しないと、実行時にエラーとなります。このため、モジュールごとに分散させるほうが単純です。次に、どのコンテナでどのモジュールをテストするかを決めるロジックを示します。

// 連番で割り当てられるコンテナの ID。
val containerIndex = System.getenv("CIRCLE_NODE_INDEX")?.toInt() ?: 0
// 起動するコンテナの総数
val totalContainer = System.getenv("CIRCLE_NODE_TOTAL")?.toInt() ?: 0

// 指定された ID のコンテナでどのモジュールのテストを実行するかを絞り込む
val modules = project.subprojects
  .withIndex()
  .filter { it.index % totalContainer == containerIndex }
  .map { it.value }

// 絞り込んだモジュールで対しテストを実行する
modules.forEach { module ->
  "./gradlew :${module.name}:test".runCommand()
}

静的解析の自動化

静的解析の自動化では、はじめにチームで認識を合わせたコーディング規約にあったスタイルでコードが書かれているかを検証します。言語特有の記法や設計について静的解析で検証できることは少ないかもしれませんが、これはコードレビューで私たちの目でチェックし、議論していきます。

静的解析には ktlint や checkstyle、findbugs など様々なツールがあります。どれも一定のフォーマットに従って XML を吐き出してくれるので、その XML を Danger などに食わせると、GitHub などのサービスとうまく連携し、コード上にインラインコメントの形で結果をフィードバックできます。コードに対するフィードバックをインラインコメントにしておくと、どこが問題だったかが分かりやすくなります。

配信の自動化

アプリケーションのビルドやテストがうまく動くことはもちろん重要ですが、それと同じくらい、実際の端末で動作を確かることも重要です。ビルドしたアプリケーションを素早くインストールし確かめられるようにすることで、QA によるアプリケーションの品質チェックをはじめ、チームや社内でのドッグフーディングなどでも、素早くフィードバックをもらえるようになります。

リリース前のアプリケーションの配信には DeployGate や Firebase App Distribution、HockeyApp などが利用できます。ビルドの自動化で成果物ができたあと、これらのサービスに apk や aab をアップロードすれば、同じサービスを使ってアプリケーションを管理している人に通知がとび、インストールできるようになります。

様々なブランチのビルド結果をアップロードしておくと、社外の人との共同プロジェクトなどでもアプリケーションの配信ができるようになり便利です。

ワークフローの自動化

たとえば、Git flow を採用しているプロジェクトで、release ブランチにおけるバグ修正を定常的に master ブランチにマージし続けることを自動化しておくと、リリース完了後に release ブランチをマージするよりもコンフリクトする可能性が減らせます。ブランチ戦略にあわせた自動化の作戦が必要ですが、できるだけ小さく差分を取り込み、コンフリクトなどのリスクを最小化することを目的としましょう。

f:id:KeithYokoma:20200330200447p:plain
release ブランチの定常的マージ

この他、コードレビューのレビュワーを自動でアサインするため CODEOWNERS を設定したり、Pull Reminders を導入して未レビューの Pull Request を通知したり、Pull Request Template を設定して共通のフォーマットで差分の説明を書いてもらうようにするといった各種の設定も、ワークフローをうまく回していく鍵になります。

良い DX を支える技術: リリース

リリースはいつも緊張するものです。新しい機能が期待通り動作するか、バグ修正が別のバグを生み出していないか、気になることは数えだしたらきりがないほど出てくると思います。問題が起きないことに越したことはありませんが、仮になにか起きたとしてもすぐにそれを検知し対処できるようにしておくと安心して修正に取り組めます。また、Google Play Store での段階的公開以外にアプリケーションの機能の公開をコントロールする仕組みを持っておくと、傷が浅いうちにトラブルを解決できるようになります。

フィーチャーフラグ

フィーチャートグルなどとも言うそうです。機能の公開をコントロールするためのフラグで、公開・非公開の2値でコントロールする以外にも、ABテストのような複数パターンを分岐するものも考えられます。

フィーチャーフラグを使う場面としておおきく2つ想定しています。

1. 新しい機能を段階的に公開したいとき

新しい機能を段階的に公開する場合、フィーチャーフラグの値は変えたいときにいつでも変えられるようにするため、Firebase Remote Config などアプリケーションのリリースを伴わない手段が必要です。

2. 1回のリリースサイクルを超えた開発をしたいとき

1回のリリースサイクルを超えた開発をする場合、開発用にブランチを分けて開発終了時にマージする方法がオーソドックスですが、開発差分が大きくなればなるほどマージの作業が手間になります。こまめに master ブランチの差分を開発用ブランチに取り込む方法もありますが、コミット履歴が少し入り組んだ状態になってしまいます。もし機能開発がフィーチャーフラグによる出し分けのコントロールが可能な場合、はじめにフィーチャーフラグを定義し非公開状態にしておき、開発が終わったら公開状態にする差分をいれるようにすることで、開発用ブランチで大きな差分を管理する必要がなくなります。

次にフィーチャーフラグを用いた、リリースサイクルを超えた開発の例を示します。Freature A 機能はバージョン 1.3.0 にリリースをする予定のものですが、開発の始まりは1.1.0 のリリース前から始まっています。フィーチャーフラグを用いれば、1.3.0 の直前でフラグを書き換えるまで、1.1.01.2.0 のリリースでは Feature A は公開されません。

f:id:KeithYokoma:20200331115409p:plain
フィーチャーフラグを用いたリリースサイクルを超えた開発の図

これを実現する手段を次に示します。

  1. BuildConfig にフィーチャーフラグを定義する
  2. 新しい機能へアクセスするためのユーザインタフェースのつなぎこみ作業を最後に実施する
  3. 開発者向けの設定画面で機能の有効・無効を切り替える
BuildConfig にフィーチャーフラグを定義する

BuildConfig にフィーチャーフラグを定義する場合はシンプルに build.gradle に次のようなフィールドを生成するよう書き足します。

defaultConfig { {
  buildConfigField("boolean", "IS_FEATURE_A_ENABLED", "false")
}

f:id:KeithYokoma:20200331120746p:plain
BuildConfig を使う場合

新しい機能へアクセスするためのユーザインタフェースのつなぎこみ作業を最後に実施する

既存の画面に新しいボタンなどを設置し、新機能へアクセスするような作りの場合、そのボタンの設置をリリースしたいバージョンの前に行うことで、それより前のバージョンでは誰もその機能にアクセスできなくなります。直接フラグを利用するわけではありませんが、同様の効果がある例です。

f:id:KeithYokoma:20200331120805p:plain
ユーザインタフェースのつなぎこみ作業を最後に実施する場合

開発者向けの設定画面で機能の有効・無効を切り替える

開発者向けの設定画面で機能の有効・無効を切り替える場合は、次のスクリーンショットに示すような画面を作っておく必要があります。

f:id:KeithYokoma:20200331121129p:plain
開発者向けのフィーチャーフラグ切替画面の例

フィーチャーフラグを用いる場合の注意点

フィーチャーフラグを用いる場合、フラグで非公開になった部分はすべて既存機能から独立している必要があります。新しい機能の開発のために既存機能に影響のあるような共通部分の変更をしてしまうと、せっかくフィーチャーフラグで新しい機能を非公開にしていても、既存機能の動作が途中で変わってしまう可能性があります。このため、うまく既存機能から分離するための設計が必要になります。

f:id:KeithYokoma:20200331121600p:plain
共通部分の変更がないよう設計する必要がある

また Firebase Remote Config や BuildConfig にフラグを定義する場合、完全に公開がおわったらそのフラグを削除することを忘れないようにしましょう。また、フラグを有効化するタイミングを間違えないようにすることも重要です。これらはどちらも開発者の運用に委ねられるため、可能なら UI のつなぎこみ作業をもって公開とするような他の手段を先に検討しておくほうがよいです*2

リリーストレイン

規模の大きなチームの場合、一度のリリースでたくさんの機能を公開できるようになります。一方で、どこかの機能開発の進捗が全体のリリースに影響を及ぼすような体制だと、リリースの都度いつリリースするのかの調整が必要になります。この問題の解法の1つとして、リリーストレインがあります。

リリーストレインはリリーススケジュールを固定化し、リリースへ向けた準備のスケジュールも固定化します。これにより、固定されたスケジュールを動かすのではなく、機能ごとにリリースのタイミングを固定されたスケジュールから選択するようになります。もし予定していたスケジュールに間に合わない場合は、次のリリーススケジュールを待つことになりますが、全体のリリーススケジュールを調整することはなくなります。

モニタリング

新しいバージョンが無事機能しているかどうかをモニタリングし、問題があれば早期に気づくためのしくみを作っておきます。検知すべき問題にはアプリのクラッシュや ANR (Android Not Responding) のほか、クラッシュはしないがアプリの表示上でエラーと出るような例外ケースの頻発、機能の劣化(リグレッション)やパフォーマンスの劣化などがあげられます。

Crashlytics を使ってモニタリングをする場合、クラッシュや、Crashlytics で集計しているキャッチした例外の件数が著しく伸びた場合にアラートを発する仕組みが用意されています。また Crashlytics は Slack への通知の仕組みももっているので、Slack を中心にモニタリングすることができるようになります。

f:id:KeithYokoma:20200331124314p:plain
ベロシティアラートの設定

f:id:KeithYokoma:20200331124329p:plain
Slack への通知の設定

ロギング

Crashlytics で検知した問題を解決するためには、適切なロギングが重要です。Crashlytics のレポートに添付されている例外のスタックトレースだけで解決できることもあれば、例外が発生する直前でどんなことが起きていたかを知らないと根本的な解決に至らない問題もあります。

Crashlytics には例外以外にも、ログを残しておくための仕組みもあります。Timber などをつかってロギングのインタフェースを統一し、ログ出力の先を Logcat と Crashlytics に振り分けておきましょう。

また key-value ペアとして様々な状態を記録することもできます。アプリのもつ設定値(e.g. ある機能が有効かどうか、別の機能の設定値は1,2,3のうちどれか、など)やユーザの状態(e.g. チュートリアルが完了したかどうか、など)*3を記録しておくと、問題が起きる前提条件を絞り込むための情報となります。

まとめ

プロジェクトが始まってから、リリース後の運用までを総ざらいし、どんなことに注目するとよい DX が得られるかを解説してきました。ここで解説したことを順番に取り組むもよし、つまみ食いして一番困っていることから取り組むもよし、様々な応用のしかたがあると思います。

解説ではプロジェクトの始まりからリリースまでの順序で見てきましたが、すでにプロジェクトが走ってきている場面でも、よい DX を得る改善ができるはずです。大事なことは、現状のよいところや改善したいことの認識をチーム内で合わせておき、何から取り組んでいくかを明文化していくことだと思います。また、失敗は小さく、リカバリーを素早くできるような仕組みを作っておくと、なにか問題がおきても気持ちよく解決に取り組めるようになります。

*1:https://gfx.hatenablog.com/entry/2018/06/28/100103

*2:https://martinfowler.com/bliki/FeatureToggle.html の Release toggles are the last thing you should do のセクションを参照

*3:プライバシーポリシーとも関わる部分なので注意して設計しましょう