はじめに
こんにちは。猪俣晴生と申します。
2025年2月の1ヶ月間、実践就業型インターンシップ「CA Tech JOB」として受け入れていただきました。
今回、私はメディア事業部でAndroidネイティブエンジニアとして「CL」の開発に関わらせていただき、同部署での開発スタイルや技術的な挑戦について実務を通して学ぶ機会をいただきました。
このブログポストでは、私がこの1ヶ月間に学び体験したことの整理と記録を主な目的として、技術的なトピックを中心に執筆します。
ドメインについて
このインターンを振り返るにあたってまず、今回私が関わらせていただいたサービスであるCLについて簡単に説明します。
CL
CLは、LDHとCAの合弁会社として設立した株式会社CyberLDHが運営するサービスです。
LDHのオリジナル番組や生配信、限定コンテンツなどのエンターテインメントサービスを提供しています。
LDHの所属アーティストのCLでしか見れない一面を見ることができる配信サービスとして親しまれています。
関わったタスク群について
関わらせていただいたタスクは大別すると以下の3つとなります。
- オンボーディングタスク
- 既存コンポーネントの改修
- 新規画面の構築
オンボーディングタスク
オンボーディングタスクはCLの開発フローに慣れるために用意していただいた単純なタスクで、CLのエンジニア同士がどのように協働しているかを知ることができました。
具体的に印象的だったのは、トランクベース開発とFeature Flagsを採用していることです。これらについては技術的な学びについての章で後述します。
既存コンポーネントの改修
オンボーディングタスクの次には、既存コンポーネントの改修タスクを任せていただきました。
これは主にUI層で完結するタスクで、デザイナーの方に用意していただいたデザインに沿うようにJetpack Composeを用いてレイアウト組みを行いました。
Composeを用いたUIの実装は私自身ある程度触ったことがあり、CLのコードベース上で共通UIコンポーネントの資産が充実していたことも相まって、実務のコードを初めてしっかり触るタスクとしてとてもやりやすく感じました。
新規画面の構築
既存コンポーネント改修タスクを終えると、今回のインターンで中心的に取り組むことになる新規画面の構築を任せていただくことになりました。
CL Androidチームでは、今回メンターを担当いただいた@blackbrackenさんの主導でアーキテクチャ等の改善タスクに積極的に取り組んでおり、
今回私が担当した新規画面の作成も、@blackbrackenさんが提案する新しいアーキテクチャを実践する施策の一つでした。
私自身ソフトウェアアーキテクチャの議論や実践には特に興味を持っており、インターンの選考時点でもその旨を話していたので、このようなタスクに取り組むことで挑戦的なアーキテクチャを用いたコーディングを体感できたことをとても嬉しく思っています。
その中で特に、Flowを管理する責務を持つべきクラスの整理と、二重で入れ子になったFlowのテストについてこのブログ上で詳しく記録したいと感じたので、技術的な学びについてで後述します。
技術的な学びについて
本章では、取り組んだタスクに関連して取り組んだトピック/議論のうち特に印象的だったものを抜粋して紹介します。
トランクベース開発とFeature Flags
トランクベース開発と比較して語られることが多いのは、git-flowのようなfeatureブランチを活用するバージョン管理手法です。
私がこれまで個人や有志で開発してきたレポジトリはほぼfeatureブランチを利用してバージョン管理していたため、mainブランチに小さな単位で頻繁にmergeするトランクベース開発には当初驚きました。
なぜfeatureブランチを利用したバージョン管理が支持されているかというと、実装途中の機能をmainブランチへmergeすることで不完全な実装がユーザーに露出してしまう事態を恐れているからです。
git-flowのような手法は、featureブランチに実装途中の機能をコミットし、ユーザーの利用に耐えられると判断されたタイミングで初めてmainブランチへmergeすることでこれを対策しています。
その一方でトランクベース開発では、Feature Flagsという概念を導入することでこの問題を解決しました。
Feature Flags(Feature Toggles)は開発者がネットワーク経由で更新できるBooleanで、開発中の機能はFeature Flagsをfalseに設定しておくことでユーザーから隠蔽できます。
余談: Feature Flagsの用途
Feature Flagsの用途はリリースの管理だけでなく以下の4つに分類できるそうですが、今回のインターンでは1のみに触れました。
- release: 開発中の機能の隠蔽する
- experiment: ABテストなど、特定のユーザーグループのみに機能を公開する
- ops: 障害時に一時的に機能を制限するなど、運用上の理由で機能をオフにする
- permission: 社内ユーザやベータユーザーのみに機能を公開する
Flowを管理する責務を持つべきクラスの整理
新規画面の実装に際して、トレーナーの方からの助言のもと、既存の画面とは異なる方法での状態表現を用いた実装に挑戦しました。
既存画面と比べて異なる点の一つは、Flowの管理の責務をUi層のstateHolderからデータ層のStoreに移行したことです。
従来の他の画面では、UI層がデータ層に対してT
型をその都度(Repository.getT()
などで)要求し、Ui側でFlow<T>
に格納し保持していました。
@Singleton
class HogeRepositoryOld @Inject constructor(
private val hogeDataSource: HogeDataSource,
) {
/* ... */
suspend fun getHogeOld(): Hoge {
/*...*/
}
}
class hogeStateHolder(
hogeRepository: HogeRepositoryOld,
) : DefaultIndependentComposableStateHolder<hogeUiState>() {
private val hoge = mutableStateFlow<Hoge?>(null) // hoge:MutableStateFlow<Hoge>
/* ... */
}
この実装でも、Uiの特定の箇所でFlow<T>
の値が更新されると同じ画面上で同じFlowを購読している別の箇所を即座に更新できるという点で、Flowのメリットを部分的に享受できています。
しかしながら、T
の更新は他の画面によっても行われることが想定されるため、Flowのライフサイクルが画面(fragment)と紐付き短命であることも相まって、この実装ではデータとUIに不整合が発生する恐れがあります。
ここで、新規画面ではUi層ではなくデータ層のStoreがFlow<T>
を管理するような実装を行いました。
@Singleton
class HogeRepositoryNew @Inject constructor(
private val hogeDataSource: HogeataSource,
) {
/* ... */
fun getHogeNew(): Flow<Hoge> {
/*...*/
}
}
class hogeStateHolder(
hogeRepository: HogeRepositoryNew
) : DefaultIndependentComposableStateHolder<hogeUiState>() {
private val hoge = hogeRepository.getHogeNew() // hoge:Flow<Hoge>
/* ... */
}
これにより、UI層はデータ層が管理するFlowを購読するだけでよく、画面を跨いだデータの変更も即座にUIに反映されるようになりました。
このような状態表現を用いて実装することで、異なる画面間でのデータ整合を保証することができました。
CL独自のアーキテクチャ
CLでは、Googleの推奨アーキテクチャに大枠としては従いつつも、独自に内製した型を用いて状態を管理している画面が多くありました。
その中でも特徴的だと感じたのは以下の項目です。
StateHolderとして独自クラスを活用
CL内部で独自に定義したStateHolder
は、 Uiが利用する個々の状態:State<T>
を、独自に定義した1つのUiState
クラスに集約して出力します。
class hogeStateHolder(
piyoRepository: PiyoRepository
) : DefaultIndependentComposableStateHolder<hogeUiState>() {
private val isFuga = mutableStateOf(false)
private val piyos = piyoRepository.getPiyos() //piyos:Flow<LoadedValue<List<Piyo>>>
/* ... */
@Composable
override fun presenter(): hogeUiState {
val isFuga by isFuga
val piyos by piyos.collectAsState(initial = LoadedValue.Loading) // piyos:LoadedValue<List<Piyo>>
return HogeUiState(
isFuga = isFuga,
piyos = piyos
)
}
}
ここで、UiState
は単なるデータクラスです。
StateHolderはpresenter()
というComposable関数を介してUiState
を出力するため、UiState
はComposable runtimeによって更新を監視できます。
具体的には、cashapp/moleculeを用いて購読します。
import app.cash.molecule.launchMolecule
/* ... */
@Composable
private fun <S : IndependentComposableState> IndependentComposableStateHolder<S>.observeState(): S? {
val coroutineScope = rememberCoroutineScope()
var state by remember { mutableStateOf<S?>(null) }
LaunchedEffect(Unit) {
coroutineScope
.launchMolecule(RecompositionMode.ContextClock) { presenter() }
.collect { state = it }
}
return state
}
これらの詳細は、Partial and Independent Composablesに公開されています。
LoadedValue型を用いた状態表現
上記のサンプルコードでも少し登場しましたが、CLではエラーハンドリングのための状態表現にLoadedValueという直和型を定義しています。
sealed interface LoadedValue<out T> {
fun getOrNull(): T?
data class Done<T>(val value: T) : LoadedValue<T> {
override fun getOrNull(): T? = value
}
data object Loading : LoadedValue<T> {
override fun getOrNull(): Nothing? = null
}
data class Failed(val throwable: Throwable? = null) : LoadedValue<Nothing> {
override fun getOrNull(): Nothing? = null
}
}
このようにsealed interfaceを用いることで、ResultやEnumで定義するよりも柔軟に状態を表現できることを学びました。
二重で入れ子になったFlowのテスト
CLはPagingの実装に関して内製の独自クラスを利用しており、その内部にはFlow<PagingState>
を持ちます。
ここで、データ層がFlow<PagingList<T>>
を返すようなケースを考えると、二重で入れ子構造になったFlowを扱うことになります。外側と内側のFlowは以下のように異なる責務を負います。
PagingList.loadMore()
などを利用する際は、Flow<PagingState>
に新しい値が流れる。- リフレッシュなどにより、異なるPagingListを取得しなおす際は
Flow<PagingList<T>>
に新しい値が流れる。
今回、このような構造を扱うのがCLで初めてだったために、単体テストの書き方を工夫する必要がありました。
まず、入れ子になっていないFlowをどのようにテストしているかを調べました。
Flowのテストでは一般的に、Turbineを利用してFlowをListに変換して評価することが多いです。
CLでは加えてTurbineに拡張関数を実装し、Turbineが返すList<Event<T>>
をList<T>
として扱えるようにしていました。
suspend fun <T> ReceiveTurbine<T>.awaitAllItemsAndCancel(): List<T> =
cancelAndConsumeRemainingEvents()
.filterIsInstance<Event.Item<T>>()
.map { it.value }
上記はFLow<T>
(実際にはReceiveTurbine<T>)をList<T>
に変換する拡張関数です。
一方で、今回のケースではFlow<PagingList<T>>
をList<PagingState<T>>
に変換する関数が必要となります。
やりたいことは、Flow<PagingList<T>>
を購読しているとき、流れてきたPagingList
ごとにその内部のFlow<PagingState<T>>
を購読し、時系列順にフラットなList<PagingState<T>>
を作ることです。
Flow<PagingList<T>>
として流れてきた要素それぞれについてadvancedUnitIdle
してから購読する必要があります。
その要件を満たすような実装は、トレーナーの方からの助言も受け最終的に以下のようになりました。
suspend fun <T> ReceiveTurbine<PagingList<T>>.awaitAllItemsAsFlattenPagingStateAndCancel(scope: TestScope): List<PagingState<T>> {
return awaitAllItemsAndCancel()
.map { it.state.testIn(scope) }
.onEach { scope.advanceUntilIdle() }
.flatMap { it.awaitAllItemsAndCancel() }
}
このような比較的複雑な要件のタスクへの取り組み を通して、kotlin.collectionの利用について実践的な理解を深めることに繋がったと考えています。
大きなコードベースへの向き合い方
オンボーディング時点で、私が今回のインターンで初めて実業務のコードに触れるということを踏まえ、Androidの大きなコードベースを読み始める際に有効な手法を紹介いただきました。それは、トップダウンではなくボトムアップを中心にコードを読み進めることです。
私ははじめ、エントリーポイントからコードベース全体を把握しようとしていました。しかしながら、
- daggerなどの導入によって、単純なコードジャンプのみではコードを辿ることが困難
- コードを理解するには、具体例の観察から始めるのが良い
のような理由から、具体例としてわかりやすいコードの表層部分から読み進めることを勧めていただきました。
それに倣い、今回はUI層から読み始め、UIがデータ層などの各種コンポーネントをどう利用しているかを順に読んでいくことで、膨大なコードベースのうちタスクに深く関わるものだけに関心を集中させながら理解を深められました。
実際に「新規画面の構築」タスクを行う際も、メンターの方と相談しつつタスクの細分化を行い、
- Composableを用いたScreenの構築とUiState/UiActionの定義
- UiState/UiActionを管理するStateHolderの作成
- Fragmentを作成し、ScreenにUiStateとUiAction,依存関係を流し込む
- StateHolderが利用するデータ層のメソッドを実装/改善
- StateHolderのメソッド群を実装
という順序で進めました。これも、Ui層からはじめ、依存先のコードに段階的に触れられる流れになっており、タスクを進めるに連れてコードベースへの理解が進んでいったと感じました。
ソフトスキル/キャリアに関する学び
インターン期間中、週次の人事面談や、トレーナーの方との毎日の1on1、部署やポジションを超えた幅広いバックグラウンドの社員の方々とのランチ会などを通じて、自身がエンジニアとして成長していく上で重視したいことを見つめ直すきっかけを何度もいただきました。
特に印象的だったのは、技術者としての信頼に関する社員の方々の認識を知れたことです。
上長の視点で、安心して仕事を任せられる粒度を大きくすることがそのままキャリアのグレードを進めることに繋がるという認識は部署問わず共通しているように感じました。
似たような文脈で、再現性という言葉がよく出てきたことも印象深いです。同じような難易度の課題に取り組むときに安定して達成できているかを指標の一つとしているそうです。
CAのような大きな企業に所属する技術者がどのような尺度で評価されているのかを知れたことで、自身の価値観、目指すべき方向の羅針盤が強固になったように感じました。
結びに
インターン全体を通して、1ヶ月間濃密で貴重な経験をさせていただきました。関わっていただいたトレーナー/サブトレーナーの方、CLチームの皆様、お話していただいた他部署の方々、人事の皆様に、この場を借りて心からお礼申し上げます。
参考
https://speakerdeck.com/blackbracken/exploring-partial-and-independent-composables
https://www.atlassian.com/ja/continuous-delivery/continuous-integration/trunk-based-development