レガシーコードに挑む!kintone Androidチームが実践した段階的リファクタリングの道のり(第3回:シングルトンパターンの削減とDIツールへの移行準備) – Cybozu Inside Out

はじめに

こんにちは!kintone開発チームのAndroidエンジニアのトニオ(@tonionagauzzi)です。この記事は、CYBOZU SUMMER BLOG FES ’25の記事です。

第1回の記事では、kintone Androidアプリの段階的リファクタリングの全体像およびモジュール再分割と手動テストの一部自動化について解説しました。そして第2回の記事では、RxJavaからKotlin Coroutinesへの移行と独自ユーティリティクラスの利用最小化について解説しました。

最終回となる第3回では、開発効率の向上を果たすうえで重要なシングルトンパターンの削減マルチモジュールに対応したDI(依存性注入)について、具体的な実装例を交えながら詳しく解説します。

前回のおさらい

これまでご紹介した段階的リファクタリングの全体像をあらためて振り返ります。

  1. 技術と機能を境界としたモジュール再分割(第1回で解説済み)
  2. 手動テストの一部自動化(第1回で解説済み)
  3. RxJavaからCoroutinesへの移行(第2回で解説済み)
  4. 独自ユーティリティクラスの利用最小化(第2回で解説済み)
  5. シングルトンパターンの削減(本記事で解説)
  6. マルチモジュールに対応したDI(本記事で解説)

今回はシングルトンパターンの削減とマルチモジュールに対応したDIへの移行準備について紹介します。

6.は前回記事では「一部ViewのJetpack Compose化」と紹介していましたが、弊社技術スタックのManual DIについてよくご質問いただくため、kintone Androidで採用しているDIの仕組みについて解説したいと思い、内容を変更しました。

段階的リファクタリングの詳細

5. シングルトンパターンの削減

背景

kintoneのAndroidアプリでは、アプリ全体で共有したい状態を管理するために、シングルトンパターンを採用してきました。シングルトンは、次の2つのグループに分かれていました。

グループ グループのインターフェース名 説明
シングルトン SingletonComponent アプリプロセス生存中、保持し続けるデータ
ユーザーシングルトン UserSingletonComponent ログインできている間、保持し続けるデータ

ユーザーシングルトンよりもシングルトンのほうが生存期間が長い

ユーザーシングルトンよりもシングルトンのほうが寿命が長いという特徴があります。ユーザーシングルトンにはログアウトで消去したいものを、シングルトンにはログアウトを越えて保持したいものを、それぞれ分けて管理します。

このようなシングルトンパターンは、2019年のリニューアル時に導入された仕組みです。これによって救われた場面もたくさんありましたが、時間の経過とともにいくつかの課題が顕在化してきました。

シングルトンパターンの課題

最大の課題は、ズバリインスタンスの多さでした。リファクタリング着手前(v2.26)の時点で、18個のシングルトンインスタンスと、23個のユーザーシングルトンインスタンスが存在していました。

量が量だけに紹介しきれないので、ここではシングルトンインスタンスを5つだけ紹介します。

シングルトンのクラス名 説明
ErrorModel アプリ内で発生したエラーを拾うモデルクラス
GeneralViewModel ErrorModelから受け取ったエラーを表示処理(メインアクティビティ)に渡すViewModelクラス
PingRepository Webサーバーに疎通確認を行うためのリポジトリクラス
PingModel Webサーバーに疎通確認を行うためのモデルクラス
SettingRepository ログイン情報など永続化したいデータを読み書きするためのリポジトリクラス
アプリ起動時、ユーザーシングルトンの組み立てに使われる

さらに、インスタンス同士には強固な依存関係がありました。たとえば上記のGeneralViewModelErrorModelをパラメーターとして生成され、PingModelPingRepositoryをパラメーターとして生成されます。

こういった依存関係がシングルトンやユーザーシングルトンの中で多発していたため、以下の課題が起きていました。

  1. 保守性の課題:処理の流れや状態の変化がモジュール外のシングルトンを経由して行われると、挙動の調査がしづらい
  2. テスト可能性の課題 : 状態をグローバルで持つ前提のコードになるため、テストが書きづらい
  3. 可変性の課題:可変のインスタンスが乱立することで、1つの処理を行った際、思わぬ副作用が起きる可能性が高まる

また、GeneralViewModelはViewModelという扱いであるものの、AndroidXが提供する標準のViewModelではなく、第2回の4. 独自ユーティリティクラスの利用最小化で触れたDisposableModelという独自クラスを拡張した独自のViewModelでした。そのため、GeneralViewModelではViewModelScopeが使えないなどの実装上の制限がありました。

シングルトン削減のアプローチ

上記の課題を解決するために私たちがしたことは、インスタンスを都度作るということでした。別の表現をするとシングルトンパターンの削減です。

都度作るとはどういうことか、PingRepositoryを例に説明します。PingRepositoryはWebサーバーに疎通確認を行うためのリポジトリクラスで、以下のような実装を持ちます。

class PingRepositoryImpl(private val okHttpClient: OkHttpClient) : PingRepository {
    override suspend fun status(url: String): Boolean {
        ...
    }
}

以前はアプリ起動時にpingRepositoryを生成して、シングルトンがpingRepositoryをずっと保持していました。シングルトンに持たせるのをやめ、pingRepositoryの代わりに以下のprovidePingRepository()を呼ぶようにしました。

object LoginModule {
    fun providePingRepository(): PingRepository {
        return PingRepositoryImpl(
            okHttpClient = createOkHttpClient()
        )
    }
}

このようにして、アプリの挙動を変えずにシングルトンからPingRepositoryを削除することに成功しました。私たちはこういった変更を地道に重ね、依存の少ないインスタンスから順番に脱シングルトンを進めていきました。

本日の最新バージョン(v2025.8.2)では、シングルトンは18個から6個に、ユーザーシングルトンは23個から1個にまで削減できました。この時点で十分に期待通りの効果を得られましたが、ここで終わりではないので、今後さらにシングルトンを減らすことも可能だと考えています。

得られた効果

  1. 保守容易性の向上:処理の流れや状態の変化をモジュール内で追いかけやすくなり、挙動の調査がしやすくなった
  2. テスト容易性の向上 : 状態をグローバルで持たなければならないテスト対象が大幅に減り、モックやフェイクを使ったテストが書きやすくなった
  3. 不変性の向上:外部から変更できないStateFlowなどを用いて適切に状態管理するようになり、処理の副作用が起きにくくなった

また、DisposableModelを廃止してViewModelはAndroidXのものを使うようにしました。それによって、ViewModelScopeを使った画面のライフサイクルに合わせたCoroutinesの自動キャンセルなどの恩恵が受けられるようになり、処理の止め忘れやメモリリークなどのリスクも軽減されました。

受け入れた課題

  1. 移行の複雑さ:シングルトンにしていたクラスを都度作るようにしたので、生成のためのコードは増加した
  2. オーバーヘッドの増加:以前はメモリに保持していたHTTP通信用のインスタンスを通信開始ごとに都度作るようにしたため、オーバーヘッドは誤差ではあるが多少増加した

移行にあたって、すべての依存関係を理解して整理するのが大変でしたが、これらの課題よりも得た効果のほうが大きかったと考えています。

6. マルチモジュールに対応したDI

背景

シングルトンやユーザーシングルトンは、機能横断でアクセスされます。ということは、マルチモジュール時代のシングルトンやユーザーシングルトンは、どのモジュールからでも参照できる位置に置く必要があります。

私たちは、シングルトンやユーザーシングルトンを解決するための共通モジュールが必要だと考え、いずれ脱シングルトンすることを意識して:legacy-sharedと名付けました。

リファクタリング前、シングルトンは:legacy-sharedが持つようにしていた

図の矢印は依存の向きです。class Aがclass Bを利用する場合、:legacy-sharedモジュールのSingletonComponentを使ってclass Bの実体を解決します。

この関係性にしてからDIまわりを改善しようと考えていたのですが、循環参照という問題が立ちはだかりました。

共通モジュールと機能モジュールの間の循環参照

循環参照に触れる前に、もう少し具体的に:legacy-sharedモジュールのコードを説明します。:legacy-sharedモジュールには、各シングルトンのインスタンスがあります。

class SingletonComponentImpl : SingletonComponent {
    val errorModel: ErrorModel = ErrorModel()
    val generalViewModel = GeneralViewModel(errorModel = errorModel)
    ...
}

一方、:legacy-shared以外のモジュールには、各シングルトンのクラスがあります。

class ErrorModel {
    private val errorSubject = PublishSubject.create()
    val error: Observable get() = errorSubject

    fun fire(error: CBMError) {
        this.errorSubject.onNext(error)
    }
}
class GeneralViewModel(private val errorModel: ErrorModel) {
    val error: Observable = this.errorModel.error
}
class RootActivity : AppCompatActivity() {
    private val singleton: SingletonComponent = SingletonComponentImpl()
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_root)

        singleton.generalViewModel.error
            .subscribeBy(
                onNext = {
                    
                    ...
                },
                onError = {
                    
                    ...
                }
            )
            .disposed(by = this.disposeBag)

この関係性で実装すると、次のように循環参照してしまいます。

依存の向き 依存が必要な理由
:legacy-shared → 他 :legacy-sharedは他を使ってシングルトンのErrorModelとGeneralViewModelを生成する
他 → :legacy-shared 他はlegacy-sharedにあるシングルトンのerrorModelgeneralViewModelにアクセスする

:legacy-sharedと:appや各モジュールの間で循環参照が起きる

この循環参照を解消するために、他が持っていたErrorModelなどのクラスを:legacy-sharedに移しました。すると今度は、以下の点で問題があることがわかりました。

  • 再利用性の低下: ErrorModelなど各モジュールのためのクラスが:legacy-sharedに集まり、各モジュールの再利用性や独立性が低下する
  • 保守容易性の低下: :legacy-sharedモジュールをHiltなどのDIツールに置き換える際、ErrorModelなどを移動する大規模なリファクタリングが発生する

再利用性や保守容易性は低下させたくないので、他が持っていたクラスを:legacy-sharedに移すよりも、:legacy-sharedが持っていたインスタンスを他に移すほうが良いです。

しかし、そうすると今度はモジュール同士でインスタンスをどうやって渡し合うのかというマルチモジュールならではの問題に直面します。

モジュールを分割したことで生じたこの問題を解決するために、私たちは:diモジュールを開発しました。

DIツール移行準備

:diモジュールの機能

:diモジュールは、各モジュールでシングルトンやユーザーシングルトンのインスタンスを解決するためのモジュールです。以下のような仕組みです。

  1. :diモジュールにはSingletonComponentUserSingletonComponentのインターフェースおよびプロバイダーがある
    (このインターフェースは各モジュールの持つ詳細に依存しない)
  2. 各モジュールには1.のインターフェースを継承したインターフェースを置く
    (このインターフェースにerrorModelのような各モジュール内のクラスを定義する)

1.のプロバイダーのコードを以下に載せます。

interface SingletonComponent
object SingletonComponentProvider {
    private var instance: SingletonComponent? = null

    fun register(singletonComponent: SingletonComponent) {
        instance = singletonComponent
    }

    fun getInstance(): SingletonComponent {
        return checkNotNull(instance) {
            "SingletonComponent is null"
        }
    }
}

inline fun reified T : SingletonComponent> singleton(): T {
    return SingletonComponentProvider.getInstance().cast()
}

inline fun reified T : SingletonComponent> SingletonComponent.cast(): T {
    return checkNotNull(this as? T) {
        "SingletonComponentImpl has not implemented ${T::class.simpleName} interface."
    }
}

なお、アプリ起動直後にSingletonComponentImplを生成し、register:diモジュールに登録します。

val singleton = SingletonComponentImpl(context = this)
SingletonComponentProvider.register(singletonComponent = singleton)

SingletonComponentのインスタンスを使いたい場所では、以下のようにインスタンスを取得します。

val singleton = singleton()
val errorModel = singleton.errorModel

2.のインターフェースのコードは、各モジュールにこういう形で定義されています。

interface SingletonComponent_DataFile : SingletonComponent {
    val fileExporter: FileExporter
}
interface SingletonComponent_DataLogin : SingletonComponent {
    val errorModel: ErrorModel
}

SingletonComponentImplは依存関係の一番外側である:appが持ち、2.のインターフェースを全て継承します。

internal class SingletonComponentImpl(context: Context) :
    SingletonComponent_DataFile,
    SingletonComponent_DataLogin,
    SingletonComponent_DataPushNotification,
    SingletonComponent_DomainCommon,
    SingletonComponent_DomainDebug,
    SingletonComponent_DataAppLinks {
    ...
    override val fileExporter = FileExporter(contentResolver = context.contentResolver)
    override val errorModel = ErrorModel()
}

このプロバイダーとインターフェースがあれば、SingletonComponentImplUserSingletonComponentImpl:appが持ちつつ、各モジュールではインターフェース型を指定して実体を取得することができます。

図で表すと以下のようになります。

:diモジュールがSingletonComponentProviderを使って各モジュールにインスタンスを提供する

:diモジュールがSingletonComponentProviderを使って各モジュールにインスタンスを提供していますが、:diモジュールから他のモジュールへの依存の向きが生じないため、循環参照しなくなるという仕組みです。

DIツールを導入しなかった理由

HiltなどのDIツールを導入すれば、このような仕組みを自前で頑張る必要はないのですが、Hilt移行の前段階として、モジュール間依存関係を整理したほうが良いと考えました。一気にHiltに移行しようとするとツール自体の理解も含めて1スプリント以内では収まらない作業量が発生しますが、:diモジュールの開発は1スプリント以内で手早くできる作業量だったので、小さく分割することでスプリントを区切れるメリットがあって:diモジュールの開発に取り掛かりました。

:diモジュールが生まれたことで、いつでもHiltなどのDIツールに移行できる状態となりました。

得られた効果

  • 循環参照が起きなくなった:diモジュールの導入により、モジュール間の依存関係が整理され循環参照が発生しなくなった
  • DIツール移行への準備が整った:いつでもHiltなどのDIツールに移行できる状態となり、将来的な技術選択の柔軟性が向上した

受け入れた課題

  • 維持コストの増加:HiltなどのDIツールを使わずに自前でDIの仕組みを実装したため、独自コードの保守が必要となっている
  • 学習コストの増加:新しいDI構造について、チームメンバーへの説明と理解の促進が必要となった

本日現在ではHiltにも他のDIツールにも移行していませんが、今後の変化に合わせてプロダクトコードも柔軟に変化していきたいと思います!

まとめ

本連載では、kintone Androidアプリの段階的リファクタリングのうち、シングルトンパターンの削減とDIツールへの移行準備について、その全容を詳しく解説しました。DIツール導入に向けて依存関係を整理したことで、今後の機能開発をより効率的に行える基盤を整えることができました。

全3回でお送りした段階的リファクタリングは、半年ほどかけて文字通り段階的に行いました。半年と聞くと長いな…と感じるかもしれませんが、このリファクタリングを行ったことで、第1回の記事で紹介した「1週間でできるはずの新機能開発に半年かかる」ということ*1がなくなるのであれば、リファクタリングの意味は十分にあると思います。実際に、リファクタリング後のAndroidアプリの機能開発とリリースは以前と比べてとても早くなっています。

これらの取り組みを通して学んだ重要なポイントは以下のとおりです。

  1. 段階的なアプローチの重要性: 一度にすべてを変更するのではなく、小さく安全に進めることの価値
  2. テストの先行投資: 自動テストの強化が、後続のリファクタリングを安全に進める基盤となる
  3. 標準への回帰: 独自実装よりも業界標準に準拠することの長期的メリット
  4. チーム全体での取り組み: 技術的負債の解消は、チーム全員の理解と協力が不可欠

リファクタリングは終わりのない継続的な取り組みですが、今回集中して取り組んだ経験により、私たちは今後も変化に対応できる柔軟で保守性の高いコードベースを維持していく自信を得ることができました。

そして、最後にお伝えしたいことは、リファクタリング前のレガシーコードに対して尊敬の気持ちを抱いたということです。レガシーコードの課題をいくつか述べましたが、当時はベストの技術で開発されたコードだというのも強く実感しました。そして、リファクタリングに至るまでのkintone Androidを長年支え続けてくれた大切で思い入れのあるコードでもあります。そのコードと先人たちの知恵と努力を賞賛しつつ、世代交代で新しいコードを迎えることができたと思います。

最後までお読みいただき、ありがとうございました。本連載が、同様の技術的課題に取り組む皆さまの参考になれば幸いです。




元の記事を確認する

関連記事