マンガビューワGigaViewer for AppsでJetpack Composeを全面採用してみた

こんにちは、マンガアプリチームの id:nabe1216 です。

2021年11月より、はてな開発のマンガビューワのアプリ版「GigaViewer for Apps」を提供開始し、最初の導入事例として「コミックガルド+(プラス)」がリリースされました。 GigaViewer for Appsは各社のマンガ配信サイトで採用されている「GigaViewer for Web」のアプリ版で、GigaViewer for Webと同様にさまざまな規模のマンガアプリが導入できるように開発しています。

hatenacorp.jp

GigaViewer for AppsのUI周りは全て、Androidで新しく登場した宣言的UIフレームワークであるJetpack Composeで実装しています。この記事ではJetpack Composeの使い方や、関連して採用した技術について紹介します。

Jetpack Composeを採用した背景

Jetpack Composeを採用した理由としては、実装したUIコンポーネントの再利用のしやすさが、GigaViewer for Appsをさまざまなマンガアプリで導入できるビューワにする上で役に立ちそうだったことがあります。

開発をスタートした2021年1月時点で、Jetpack Composeはまだアルファ版の状態でしたが、リリース予定の2021年秋頃には安定版が出ているだろうと予想していました。

社内のエンジニアもJetpack Composeに関心があって、個々でキャッチアップしており、そういった背景からJetpack Composeで開発を進めていくことになりました。

画面遷移にNavigation Composeを使用

Jetpack Composeを採用して悩むポイントに、画面遷移があります。GigaViewer for Appsでは、Navigation Composeを使いました。

Navigation Composeを使うと、遷移や画面間で値を渡すコードが文字列ベースになってしまいます。同じチームのid:takuji31が、画面の定義と引数をコード生成してくれるnavigation-compose-screenというプラグインを開発してくれたので、組み合わせて使用しています。

@AutoScreenId("ExampleScreen")
enum class ExampleScreenId {
    @Route("/")
    Home,
    @Route("/detail/?id={id}")
    Detail,
    ;
}

@Composable
fun Main(navController: ScreenNavController) {
    val currentScreen by navController.currentScreen.collectAsState()
    ScreenNavHost(
        navController = navController,
        startScreen = ExampleScreen.Home,
    ) {
        exampleScreenComposable {
            home {
                Home()
            }
            detail {
                Detail()
            }
        }
    }
}

class DetailViewModel(): ViewModel() {
    private val screen: ExampleScreen.Detail by savedStateHandle.screen()
    val id = screen.id
}

状態管理は公式ドキュメントと似た実装に

状態管理では、ViewModelUiStateを1つ持たせて、これを変更する形にしています。複雑な状態の表現がしやすい実装を模索して、この実装にたどり着きました。

data class UiState<T : Any>(
    val isLoading: Boolean = false,
    val error: Event<Exception>? = null,
    val data: T? = null,
)

@HiltViewModel
class ExampleViewModel @Inject constructor() : ViewModel() {
    private val _state: MutableStateFlow<UiState<*>> = MutableStateFlow(UiState())
    val state: StateFlow<UiState<*>> = _state.asStateFlow()
}

@Composable
fun Example() {
    val viewModel: ExampleViewModel = hiltViewModel()
    val uiState by viewModel.state.collectAsState()
    ...
}

実は、この後に公式ドキュメントの「UI Layer」が同じような実装に刷新され、奇跡的に公式に則った形となりました。

関連して採用した技術

SplashScreen API

開発時期がちょうどAndroid 12のリリース時期と重なっていたこともあり、Android 12で登場したSplashScreen APIも取り入れました。

JetpackからSplashScreen APIをバックポートするライブラリが出たこともあり、このライブラリを採用してスプラッシュ画面を実装しました。

ライトテーマとダークテーマそれぞれにロゴを用意しています。

ライトテーマ ダークテーマ
ライトテーマのスプラッシュ画面 ダークテーマのスプラッシュ画面

Apollo Client (GraphQL)

API周りには、Apollo Client(GraphQL)を採用しています。

UIコンポーネントが必要なデータをGraphQLのFragmentで宣言することで、コンポーネントで必要なデータだけを取得するFragment Colocationという考え方があり、これがJetpack Composeの宣言的UIとも相性がいいということもあります。

GraphQLのクエリは、UIコンポーネントに表示させたいデータに依存しています。そのためGigaViewer for Appsでは、Apollo ClientをREST使用時によくあるRepository層で隠蔽させるのではなく、View側に持たせて使うようにしています。

依存方向のグラフ

その他に採用したライブラリ

その他にも採用したライブラリをいくつか挙げていきます。

アプリの実装上で工夫した点

キーボード操作

Androidアプリは、タブレットやフォルダブル端末だけでなく、Chrome OSのデスクトップ環境で動作することもあります。どの環境でも快適に使えるようにしたいので、アプリをキーボードで操作できるようにしたり、矢印キーでビューワーのページめくりができるようにしています。

Box(
    modifier = Modifier
        .onKeyEvent {
            when (it.key) {
            Key.DirectionLeft -> {
                ...
                true
            }
            Key.DirectionRight -> {
                ...
                true
            }
            else -> {
                false
            }
        }
        .focusable(),
)

タブレットやフォルダブル端末の最適化

タブレットやフォルダブル端末での操作やアプリの使い心地を向上させるため、タブレットやフォルダブルで漫画が見開きで表示されるように実装しています。

フォルダブルかどうかは、JetpackにあるWindowManagerのライブラリで判断しています。

val foldingFeature = windowLayoutInfo?.displayFeatures?.filterIsInstance(FoldingFeature::class.java)?.firstOrNull()

val isSpreadPage by derivedStateOf {
    foldingFeature?.let {
        (it.orientation == FoldingFeature.Orientation.VERTICAL && it.state == FoldingFeature.State.HALF_OPENED) ||
            (it.orientation == FoldingFeature.Orientation.VERTICAL && it.state == FoldingFeature.State.FLAT && isLargeScreen)
    } ?: false
}

Jetpack Composeでアプリを作ってみて

Jetpack Composeは登場したばかりの技術ということもあり、不十分な部分もあって頑張って実装するところも多々ありはしましたが、UIコンポーネントの状態の管理から解放される宣言的UIの恩恵があり、テストが書きやすいこともあって、実装はとても快適で楽しいものでした。

GigaViewer for Appsはまだ必要最低限の機能が揃ったところで、これからさまざまなメディアのアプリを作れるように機能を追加し、発展させていくところです。

はてなでは、一緒にGigaViewer for Appsを作り上げていく仲間を募集しています!

はてなでは、技術に対する向上心を持つ仲間を募集しています