NIKKEI TECHNOLOGY AND CAREER

入社3ヶ月目のエンジニアが「日経電子版 for Android」のレガシーコードに立ち向かった話(序章)

アプリチームでAndroidエンジニアをしている横山(@yokomii)です。
8月に入社して、間も無く3ヶ月が経過しようとしています。

現在は日本経済新聞 電子版(以下、電子版アプリ)の開発業務を担当しています。
電子版アプリは2015年5月のリリース(大規模リニューアル)から様々なアップデートを重ね、2022年10月現在も多くのユーザー様にご利用いただいています。

その一方で長期運用しているサービスの宿命ともいえる、レガシーコード化が進みつつあるのも事実です。
入社研修を通して様々なコードに触れる中で、電子版アプリにおいて今後それらとどう立ち向かうべきかの筋道を立てました。今回はそのプラクティスの一部を紹介します。

非同期処理のCoroutine化

電子版アプリでは非同期処理の多くをRxJavaで実装しています。
今後これらはKotlinのCoroutineに置き換わる予定です。

class ArticleViewModel(
    private val getArticleByRxJava2: GetArticleByRxJava2UseCase,
    private val getArticle: GetArticleUseCase,
) : ViewModel() {

    val article = MutableStateFlow<Article?>(null)

    @Deprecated("Replace with load()", ReplaceWith("load()"))
    fun loadByRxJava2() {
        getArticleByRxJava2(
            onNext = { article.value = it },
            onError = { article.value = null },
        )
    }

    fun load() {
        viewModelScope.launch {
            article.value = getArticle()
        }
    }
}

@Deprecated("Replace with GetArticleUseCase", ReplaceWith("GetArticleUseCase"))
class GetArticleByRxJava2UseCase(private val repository: ArticleRepository) {

    operator fun invoke(
        onNext: Consumer<Article>,
        onError: Consumer<Throwable>,
    ) {
        repository.getArticleSingle()
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(onNext, onError)
    }
}

class GetArticleUseCase(private val repository: ArticleRepository) {

    suspend operator fun invoke() = try {
        repository.getArticle()
    } catch (e: Throwable) {
        null
    }
}

CoroutineはRxJavaよりもAndroidライブラリとの親和性に優れているなど、置き換えをするメリットが様々ありますが、個人的に電子版アプリにおける最大の利点は、「Rxと比べて記述がシンプルであること」と捉えています。

電子版アプリは現状でも規模の大きいアプリですが、今後も新しい機能は増え続けていき、その分複雑さが増していくことが予想されます。
そうした状況下で、高機能がゆえにコードが複雑になりがちなRxからCoroutineに移行することで、将来的な複雑さの解消につながると予想しています。

その他、大規模開発におけるCoroutineの利点として、スレッド操作を必要な処理の近くに書くことで、誤ったスレッドを選択するミスを未然に防げるといったことが挙げられます。

class ArticleDataSource(private val httpClient: HttpClient) {

    suspend fun getArticle() = withContext(Dispatchers.IO) {
        httpClient.getArticle()
    }
}

単方向データフローパターン(UDF)への移行

電子版アプリはリリース当初からMVPアーキテクチャを採用しており、PresenterがViewを直接参照してUI更新のための関数を呼び出していました。

class ArticleListPresenter(
    private val view: ArticleListContract.View,
    private val getArticle: GetArticlesUseCase,
) ArticleListContract.Presenter {

    fun load() {
        val article = getArticle()
        view.updateArticles(article)
    }
}

今後はそれらを単方向データフロー(以下、UDF)パターンへと移行します。

class ArticleListViewModel(private val getArticle: GetArticlesUseCase) : ViewModel() {
    
    // Viewで監視する
    val article = MutableStateFlow<List<Article>>(emptyList())

    fun load() {
        article.value = getArticle()
    }
}

主な理由は下記です。

特に

Guide to app architectureにおいてUDFパターンが推奨されている

を重要視しています。

公式の指針があることでエンジニア間での共通的な認識として設計などの議論を進めやすくなります。また、ガイドラインに則ることで今後新規メンバーがジョインしたときに設計やコード理解がしやすい環境が整うと見込んでいます。
日経のアプリチーム内でもGuide to app architectureの読み合わせ会が実施(私の入社前ですが)され、ガイドラインの理解が進んでいます。

Tips: 電子版アプリにおけるUIの状態管理方法

電子版アプリではAACのViewModelにおいて、Viewに公開する状態をUiStateUiEventの二種類に分類するようにしました。

sealed interface ArticleListUiState {

    val isShowProgress: Boolean

    data class Init(override val isShowProgress: Boolean) : ArticleListUiState
    data class Empty(override val isShowProgress: Boolean) : ArticleListUiState
    data class Column(
        override val isShowProgress: Boolean,
        val articles: List<Article>,
    ) : ArticleListUiState
}

sealed interface ArticleListUiEvent {

    data class UserMessage(val text: String) : ArticleListUiEvent
    sealed interface Navigate : ArticleListUiEvent {
        object GoToArticleDetail : Navigate
    }
}

UiStateGuide to app architectureのUI layerの章で紹介されているUiStateクラスそのもので、UIレンダリングに必要な情報をカプセル化したクラスです。

UiEventはUIで一度だけ処理したい状態です。
例えばユーザーメッセージの表示や画面遷移などがこれに該当します。

class ArticleListViewModel(private val getArticles: GetArticlesUseCase) : ViewModel() {

    private val viewModelState = MutableStateFlow(ArticleListViewModelState())
    val uiState: StateFlow<ArticleListUiState> = viewModelState.map { it.toUiState() }
        .stateIn(
            viewModelScope,
            SharingStarted.Eagerly,
            viewModelState.value.toUiState(),
        )

    private val _uiEvent = Channel<ArticleListUiEvent>(Channel.CONFLATED)
    val uiEvent = _uiEvent.receiveAsFlow()

    fun load() {
        viewModelScope.launch {
            viewModelState.update { it.copy(isShowProgress = true) }
            try {
                val articles = getArticles()
                viewModelState.update { it.copy(articles = articles) }
            } catch (e: Throwable) {
                _uiEvent.send(ArticleListUiEvent.UserMessage("通信エラーです"))
            } finally {
                viewModelState.update { it.copy(isShowProgress = false) }
            }
        }
    }
}

data class ArticleListViewModelState(
    val isShowProgress: Boolean = false,
    val articles: List<Article>? = null,
) {

    fun toUiState(): ArticleListUiState = when {
        articles == null -> ArticleListUiState.Init(isShowProgress)
        articles.isEmpty() -> ArticleListUiState.Empty(isShowProgress)
        else -> ArticleListUiState.Column(isShowProgress, articles)
    }
}

全てのUIの状態を管理するViewModelStateクラスを変換してUiStateクラスを生成しています。
これによりViewModelでは変化する状態だけに関心が向き、それらの状態がUIにどう作用するかの関心は無くなります。
(参考: https://github.com/android/compose-samples/blob/main/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt)

このようにUIの状態をシンプルに保つことで、ViewModelの煩雑化を防いでいます。

Android ViewからJetpack Composeへの移行

一部画面において、Android ViewからJetpack Composeへの置き換えを実施しました。

Compose preview

現在は新しい設計の導入初期で対象範囲を限定的としていることから、置き換えによるメリットをあまり実感できていないのが正直なところです。
電子版アプリには複数の表示状態をもつ画面が多く存在しているので、それらをPreview機能によって可視化することで、状態管理しやすくなることが今後期待されます。 また、似たようなUI要素が各画面に点在しているので、共通UIを適切にコンポーネント化し、再利用することでコードの削減ができると予想しています。

ちなみに、直近リリースされた電子版アプリのWear版のネイティブUIは全てJetpack Composeで実装されています。

Wear image

今後も積極的に導入を進めていく意向です。

その他入社後にやったこと

上記以外で、私が入社後に下記のリファクタを実施しました。

  • Dagger2からDagger Hiltへの移行
  • Gradle version catalogの導入

その他やっていきたいこと

その他、個人的に今後やりたいことを下記に述べます

  • マルチモジュール化
  • Material Design 3対応
  • UI/ユニットテストの拡充
  • etc...

おわりに

今回レガシーコードに焦点をあてて紹介をさせていただいたので、負債が多くて辛そう・・という印象をもたれたかもしれません。
しかし、レガシーコードは長期運用しているサービスの宿命であり、それだけ長い間ユーザーのみなさまにご支持をいただけてきたことの表れでもあります。
今後5年、10年とサービスを継続していくために、引き続きレガシーコードと上手に付き合っていきたいと思います。

また少し記事内でも触れましたが、Wear OS 3に対応したアプリをいち早くリリースするなど、新しい技術にも果敢に取り組める環境が日経のアプリチームにはあります。
技術カンファレンスの協賛もしており、DroidKaigi 2022では2名のエンジニアが登壇者として参加しました。スライドが公開されているので是非ご覧ください。

日経のアプリチームにご興味をもたれた方は、カジュアル面談のご応募をお気軽によろしくお願いします!

横山朝海
ENGINEER横山朝海

Entry

各種エントリーはこちらから

キャリア採用
Entry
新卒採用
Entry
カジュアル面談
Entry