様々なマンガアプリを素早く開発できる「GigaViewer for Apps」のしくみ Android 編

こんにちは、Android アプリエンジニアの id:mangano-ito です。『Inside GigaViewer for Apps』連載3回目は、iOS/Android エンジニアの id:kouki_dan と一緒に出版社向けマンガビューワのアプリ版である「GigaViewer for Apps」(以下 GigaApps)のAndroidアプリを実現するしくみについて紹介します。

GigaAppsとは

前回の記事でも紹介したように、GigaAppsはビューワだけでなく、作品詳細やマイページなどのマンガアプリの基本的な機能を共通で備えていて、目指すアプリの特性に応じて機能を自由に組み合わせられる設計が特徴です。

そうでありながら、作りおろされた専用の機能を入れることもできますし、共通機能の一部をアプリに合わせてカスタマイズすることも想定しています。

GigaAppsとしてのコアの機能を共通に持ちつつも、機能を柔軟に組み合わせ調整し、それぞれのメディア(テナント)に向けたアプリを作り上げるしくみを「マルチテナント」と呼んでいます。

以下の章では、マルチテナントのしくみをどのように実現しているかを紹介します。

アーキテクチャ

まず前提として、個別のアプリごとに別々のソースコードを管理する形ではありません。GigaAppsは複数のメディア向けのアプリを開発できるようにしつつも、メンテナンス性を考えて単一で共通のコードベースを管理しています。

ベースとなる基本的な技術スタックとしては、近年のAndroidアプリとして標準的な技術スタックを用いています。

たとえばUIにはJetpack Composeを使っていて、Android Viewは広告の部分など必要最低限な一部でしか使いません。以前Jetpack ComposeについてはDeveloper Blogで紹介しました。Jetpack ComposeはGigaApps開発の初期段階で導入しており、今となっては標準的に多くのアプリで使われています。

developer.hatenastaff.com

APIのレイヤーではGraphQLを使っており、クライアントライブラリとしてApollo Kotlinを使っています。このお話については以前 id:nabe1216 が発表していたGigaAppsとGraphQLを説明したスライドが詳しいので、詳細にご興味のある方はぜひご覧ください:

speakerdeck.com

モジュール構成は Android DevelopersのApp architecture にのっとって、マルチモジュールを採用し、UIレイヤーやdataレイヤーといったレイヤーごとのモジュールに分離しています。とりわけ、各機能についてはfeatureモジュールとして個別に分離しています。

developer.android.com

この構成を活かし、作りたいアプリの特性に合わせて機能を組み合わせるスタイルになっています。そのため、個別の機能はモジュールとして分離する必要がありますが、必要なものを依存に追加することで肉付けして最終的なマンガアプリを作れるようになっています。

メディアに応じたモジュールの組み合わせ

このためにDagger/HiltのようなDependency Injectionの機能をフルに活用しています。また変わり種としてはKotlin Symbol Processing API(以下 KSP)とKotlinPoetを使ったコード生成も合わせて活用しています。

developer.android.com

kotlinlang.org

square.github.io

次からは、DIやコード生成を活用してどのようにマルチテナントに活かしているかを紹介します。

GigaAppsでマルチテナントを実現する方法

GigaAppsではマルチテナントを実現するために、さまざまなテクニックを活用しています。現状マルチテナントのための差し替え手法を整理してまとめると、以下の手法に分類されます:

  1. モジュールのconfig
  2. リソースの差し替え
  3. UIプラグイン
  4. その他

それぞれの手法について例を交えて紹介します。*1

モジュールのconfig

機能モジュールの振る舞いをメディアごとに変える手段として、モジュール側でconfigをインターフェースとして提供し、それをメディア側でDIして設定を行う手法を使っています。このしくみはDagger/Hiltによる依存関係の注入の最もシンプルな使用例です。

たとえば、あるfeatureモジュールのfeature-help, feature-profileの機能として必要なconfigがこうあったとします:

interface FeatureHelpConfig {
    val helpUrl: String
}

interface FeatureProfileConfig {
    val nameMaxLength: Int
}

このconfigに対してメディア側でシンプルにDIできます:

@Module
@InstallIn(SingletonComponent::class)
class MediaAConfigModule {
    @Provides
    @Singleton
    fun provideFeatureHelpConfig() : FeatureHelpConfig = object : FeatureHelpConfig {
        override val helpUrl = "https://mediaA/help"
    }

    @Provides
    @Singleton
    fun provideFeatureProfileConfig() : FeatureProfileConfig = object : FeatureProfileConfig {
        override val nameMaxLength = 128
    }
}
@Module
@InstallIn(SingletonComponent::class)
class MediaBConfigModule {
    @Provides
    @Singleton
    fun provideFeatureHelpConfig() : FeatureHelpConfig = object : FeatureHelpConfig {
        override val helpUrl = "https://mediaB/help"
    }

    @Provides
    @Singleton
    fun provideFeatureProfileConfig() : FeatureProfileConfig = object : FeatureProfileConfig {
        override val nameMaxLength = 256
    }
}

メディアごとに異なるConfigをDIすることで、機能側で必要となるパラメーターを基に挙動などを変更できます。ビルド時に設定がなければビルドエラーとして検出されますし、シンプルなDIの活用法という感じなので想像にかたくないかと思います。

リソースの差し替え

文言や画像リソースが異なる部分については、リソース定義自体を差し替える手法を採用しています。

どういうものかというと、定義となる共通のインターフェースを用意しておき、メディアごとに異なるマッピングをDI経由で与えることで、リソース定義をメディア用のものに差し替えられます。このDIされた定義を CompositionLocal でUI側から参照できるようにする手法を合わせて活用すると、メディアによって簡単に表示する文言や画像を差し替えできます。

例としてアプリ内通貨の単位の文言の定義を考えます:

interface GigaStrings {
    val pointUnit: String
        @Composable get
}

メディアAではアプリ内通貨を「ポイント」と称し、メディアBでは「コイン」と称したい場合、メディア別の定義で単位の文言を変える必要がありますから、単位の文言をインターフェースに定義します。

// media-a/MediaAStrings.kt
object MediaAStrings : GigaStrings {
    override val pointUnit: String
        @Composable get() = stringResource(R.string.point)

    @Module
    @InstallIn(SingletonComponent::class)
    object Provider {
        @Provides
        @Singleton
        fun provideGigaStrings(): GigaStrings = MediaAStrings
    }
}

// media-b/MediaBStrings.kt
object MediaBStrings : GigaStrings {
    override val pointUnit: String
        @Composable get() = stringResource(R.string.coin)

    @Module
    @InstallIn(SingletonComponent::class)
    object Provider {
        @Provides
        @Singleton
        fun provideGigaStrings(): GigaStrings = MediaBStrings
    }
}

この文言の定義の実装をメディアごとにDIするようにしておいて、CompositionLocalProvider を使ってcomposable内で使えるようにします:

val LocalGigaStrings: ProvidableCompositionLocal<GigaStrings> = compositionLocalOf {  error("LocalGigaStrings is not present.") }
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    // この GigaStrings がメディアごとに DI される
    @Inject
    private lateinit var gigaStrings: GigaStrings

    override fun onCreate(savedInstanceState: Bundle?) {
        // (略)
        setContent {
            CompositionLocalProvider(
                // DI された GigaStrings を CompositionLocal として与えている
                LocalGigaStrings provides gigaStrings,
            ) {
                // (略)
            }
        }
    }
}

CompositionLocal によって暗黙的に差し替えられたリソースが渡され、composable関数内で参照できるので、メディアごとに異なる文言を表示できます。

@Composable
fun PointPurchaseButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        onClick = onClick,
        modifier = modifier,
    ) {
        Text(
            text = stringResource(
                R.string.purchase_point, // "%sを購入する"
                LocalGigaStrings.current.pointUnit, // "ポイント" or "コイン"
            ),
        )
    }
}

この例では、メディアに応じて通貨の購入ボタンが、メディアAでは「ポイントを購入する」、メディアBでは「コインを購入する」と変わります。

同様に画像のリソースについても同じ要領で実装しており、共通モジュール内の文言や画像はこの手法を使って差し替えを実現しています。

UIプラグイン

次に、UIプラグインと呼んでいる独自のしくみについて紹介します。KSP/KotlinPoetを活用して、あらかじめ差し替えたい部分のコンポーネントをスロットのように差し替え可能にするものです。

まず、新規登録をうながす導線のコンポーネントがあったとします:

@Composable
fun SignUpComponent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text("会員登録をしましょう!")
        Button(onClick = { navigateToSignUp() }) {
            Text("会員登録へ進む")
        }
    }
}

コンポーネント内でメディアごとに訴求したい内容が異なっており、コンポーネントを大幅にカスタマイズしたい場合があります:

// media-a/SignUpComponent.kt
@Composable
fun MediaA_SignUpComponent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Row {
            CharacterImage()
            Column {
                Text("会員登録でもっとお得に!")
                Text("→ もれなく100ptプレゼント!")
            }
        }
        Spacer(height = Modifier.height(16.dp))
        Button(onClick = { navigateToSignUp() }) {
            Text("会員登録をする")
        }
    }
}

// media-b/SignUpComponent.kt
@Composable
fun MediaB_SignUpComponent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text("会員登録してより便利に!")
        Spacer(height = Modifier.height(16.dp))
        Text("※ 以下の注意事項を確認")
        Row {
            SignUpImage()
            Column {
                Button(onClick = { navigateToNotice1() }) {
                    Text("注意事項1")
                }
                Button(onClick = { navigateToNotice2() }) {
                    Text("注意事項2")
                }
            }
        }
        Spacer(height = Modifier.height(16.dp))
        Button(onClick = { navigateToSignUp() }) {
            Text("会員登録をする")
        }
    }
}

こういったコンポーネントの内容をメディアごとに差し替えるしくみを我々はUIプラグインと呼んでいます。

まず、根幹となるしくみはファイルprivateな変数の活用です。差し替えたいコンポーネントのcomposableの型をもつ変数とそのsetterとgetterを用意します:

typealias SignUpComponent = @Composable (modifier: Modifier) -> Unit
private var _signUpComponent: SignUpComponent? = null

fun setSignUpComponent(component: SignUpComponent) {
    _signUpComponent = component
}

@Composable
fun SignUpComponent(modifier: Modifier = Modifier) {
    _signUpComponent?.invoke(modifier = modifier)
}

こうすることでこの変数を経由してcomposableを渡すことができ、渡す内容によってコンポーネントごと差し替えが可能になります。与えるコンポーネントはメディアごとのモジュールから注入できます。また、nullableにしておくことで未設定の場合はそのコンポーネントを取り去ることもできますね。

fun setUpMediaAComponents() {
    setSignUpComponent { modifier ->
        MediaA_SignUpComponent(modifier = modifier)
    }
}

fun setUpMediaBComponent() {
    setSignUpComponent { modifier ->
        MediaB_SignUpComponent(modifier = modifier)
    }
}

使用箇所ではこの実際の実装へのproxyとなるcomposable関数を呼び出すことで、メディア別に設定された実装を使うことができるというのが原理です:

@Composable
fun WelcomeComponent() {
    Column {
        Text("ようこそ!")
        // ↓ 差し替えに応じたコンポーネントが表示される
        SignUpComponent(modifier = Modifier.padding(top = 8.dp))
    }
}

ここまで説明したように、UIプラグインの実装にはボイラープレートが多くなっています。しかしながら、実際にはボイラープレートコードを削減し手間を削減できるように、変数の生成や実際の実装の設定などの記述については、KSP/KotlinPoetによるコード生成を活用しています。実際の記述のイメージとしては以下のようなコードとなります:

// feature-welcome/SignUpComponent.kt
@PluggableUi
typealias SignUpComponent = @Composable (Modifier) -> Unit

// media-a/SignUpComponent.kt
@SignUpComponentImplementation
@Composable
fun MediaA_SignUpComponent(modifier: Modifier) {
    // メディアA向けの実装
}

// media-b/SignUpComponent.kt
@SignUpComponentImplementation
@Composable
fun MediaB_SignUpComponent(modifier: Modifier) {
    // メディアB向けの実装
}

@PluggableUi のアノテーションがつけられた typealias が差し替え可能なcomposableの定義となり、@SignUpComponentImplementation のアノテーションがつけられたcomposableが実際の実装として設定される対象とマークされます。KSPで得られた情報をもとにして、KotlinPoetによって自動でコード生成が行われ、上記で紹介したような変数やsetter, getter、実際の実装へのバインドが楽にできるというしくみです。

KSPについて、詳しくは以下の id:takuji31 さんの資料をご覧ください:

speakerdeck.com

UIプラグインを利用することでメディアごとに独自のコンポーネントを差し替えることができます。KSPとKotlinPoetによるコード生成を駆使することでコンポーネント単位での差し替えが簡単になりました。こうしたメディアごとのUIのカスタマイズを行うことはマルチテナントアプリにとって重要でGigaAppsの強力な武器のひとつとなっています。

その他

他にも細かな手法があり、用途や文脈によって使い分けられています。社内ドキュメントでは整理のために手法をまとめたうえでフローチャート化しており、判断に困るときの材料として使えるようにしています:

手法のフローチャート

どの手法もメリットやデメリットもあり、また濫用することで今後の実装に複雑性を持ち込んでしまうかもしれません。今の時点での手法が画一的に適用できるものかは検討の余地もあり、リファクタリングや手法の見直し・導入は今後の課題でもあります。

実例紹介

以上のマルチテナントのためのテクニックをもって、具体的にはどのようにしてアプリ内の機能が組み合わせされているのか見ていきましょう。

実例1: ポイントのアイコン・文言

リソース差し替えの手法については、ポイントのアイコンや文言が代表例です:

コミックガルド+ 少年ジャンプ+

コミックガルド+では「ポイント」「pt」、少年ジャンプ+では「コイン」と称されており、関係する文言やアイコンのリソースがメディアに応じて出しわけされています。

実例2: マイページヘッダー

UIプラグイン手法の代表例としては、マイページ画面上部のヘッダー部分が一番わかりやすいかもしれません:

コミックガルド+ 少年ジャンプ+

コミックガルド+と少年ジャンプ+ではレイアウトや表示する内容を含め、コンポーネント自体の内容や導線などが変化していることがわかると思います。

この例では画面が丸ごと変わっているわけではなく、マイページのヘッダー部分のコンポーネントが差しかわっています。加えて、少年ジャンプ+では追加のデータをクエリするようにしており、表示する項目も変わっているという仕組みです。

実例3: 作品詳細ヘッダー

モジュール別のconfigのパターンの例としては、作品詳細のヘッダー部分がわかりやすいでしょう。

コミックガルド+ 少年ジャンプ+

この例で注目したいのが、サムネイルの比率や表示される数字が閲覧数/お気に入り数と異なっていたりする部分で、この出しわけにはconfigの設定が用いられているのです。こういった共通部分の細かい制御の違いに関してはこの手法が用いられています。

実例4: 購入ダイアログの上部

最後に紹介するのがこの例です:

コミックガルド+ 少年ジャンプ+

たしかに差分がありますが、これはどの手法を用いたものだと思いますか?

答えは……複数の手法の合わせ技なのですね。クイズとしては意地悪問題ですが、実際のケースにおいては組み合わせて実現しているものも少なからずあります。

今回の例では「ポイント」と「コイン」の呼び分けは「リソース差し替え」の手法を使いつつ、ラベルの文言や表示するアイテムのレイアウトの違いは「UIプラグイン」を用いて実現しています。

とはいえ、複雑にならない程度に適材適所で手法を使い分けたり、ときには要件や実装が変わったときには別の手法を用いることができないか検討してリファクタリングを行うことも重要です。

終わりに

ここまでアプリ版マンガビューワ「GigaViewer for Apps」のAndroidアプリの開発を支えるしくみを紹介してきました。 マルチテナント開発では従来のアプリ開発では起き得ない課題に直面します。たとえば、あるテナントの機能開発が、別のテナントの機能に影響を与えないための工夫が必要です。難易度は高いですが、技術的チャレンジも多いことがGigaViewer開発の魅力です。連載企画『Inside GigaViewer for Apps』では、今回のようなアーキテクチャやしくみだけではなく、多様なテーマで過去取り組んできた技術的チャレンジを紹介していきます。

次回はGigaViewer for Appsを支えるバックエンドのしくみについてです。お楽しみに!

*1:実際のコードはもう少し周辺コードが多いですが、説明のため簡略化しています