Upgrade to Pro — share decks privately, control downloads, hide ads and more …

ComposeではないコードをCompose化する case ビズリーチ / DroidKai...

ComposeではないコードをCompose化する case ビズリーチ / DroidKaigi 2025 koyasai

2025年10月8日に開催された「DroidKaigi 2025 後夜祭」の登壇資料です。
https://d-cube.connpass.com/event/367111/

▼関連資料
Androidアプリのリアーキテクチャ 負債解消プロジェクトの成果と課題
https://engineering.visional.inc/blog/699/biz-can-android-re-architecture/

iOSエンジニアの輪を広げたい!社内横断コミュニティ「林檎の会」を立ち上げるまで
https://engineering.visional.inc/blog/633/create-ios-comminuty/

-----
Visionalのエンジニアリングに関する最新情報はX、ブログで発信しています!📣

▼Visional Engineering Blog
https://engineering.visional.inc/blog/

▼VISIONAL ENGINEERING / X
https://twitter.com/VISIONAL_ENG

More Decks by Visional Engineering & Design

Other Decks in Technology

Transcript

  1. 自己紹介
 氏名: 長尾 聡一郎 
 
 所属: 株式会社ビズリーチ
   プロダクト本部 
   ビズリーチプロダクト統括部


      カスタマープロダクト部
   カスタマープロダクトアプリ開発グループ
 
 お仕事: ビズリーチ Androidアプリの開発
 2 発表している人

  2. 自己紹介
 氏名: 長尾 聡一郎 
 
 所属: 株式会社ビズリーチ
   プロダクト本部 
   ビズリーチプロダクト統括部


      カスタマープロダクト部
   カスタマープロダクトアプリ開発グループ
 
 お仕事: ビズリーチ Androidアプリの開発
 
 最近の学び: 社内Slackのプロフィール写真はちゃんとチョイ スした方が良い
 3 発表している人

  3. 自己紹介
 氏名: 長尾 聡一郎 
 
 所属: 株式会社ビズリーチ
   プロダクト本部 
   ビズリーチプロダクト統括部


      カスタマープロダクト部
   カスタマープロダクトアプリ開発グループ
 
 お仕事: ビズリーチ Androidアプリの開発
 
 最近の学び: 社内Slackのプロフィール写真はちゃんとチョイ スした方が良い
 4
  4. 2019/05 Google I/Oにて、新しいAndroid UIツールキットとしてJetpack Composeの最初のプレビュー が発表される
 2019/10 開発者向けに最初のプレビュー版が公開され、フィードバック収集とオープンな開発が開始され ました
 2020/08

    アルファ版がリリース
 2021/02 ベータ版がリリース
 2021/07 Jetpack Composeの最初の安定版である1.0がリリースされ、AndroidのネイティブUIにおけ る推奨ツールキットとなる
 2025/10 現在 Jetpack Composeは、継続的にアップデートされており、ネイティブUIのファーストチョイス として主要な選択肢となっている
 Jetpack Composeの歴史
 18
  5. BizReach Confidential DroidKaigi 2025においてこのようなセッションがありました
 Kikoso
 2025.09.12 / 11:20 〜 12:00


    https://2025.droidkaigi.jp/timetable/946715/
 
 
 
 Composeを使ってUIを書いていると、しばしばComposeに対応していないコードと連携する必要があります。 その一例がGoogle Maps SDKで、同ライブラリは Composeに標準対応していません。 この制限を克服するために開発されたのが Android Maps Composeライブラリです。
 
 このセッションでは、android-maps-composeの開発に用いたテクニックを紹介し、元々 Compose に対応していないライブラリに対して、どのようにして Compose のインタフェースを提供したのかを解説します。 Android Viewブリッジのような相互運用技術を活用し、非 Composeコンポーネントを安全にラップしてライフサイクル を管理し、ユーザーにとって使いやすい Compose APIをどのように設計したのかをお見せします。 
 
 レガシーなUI コードをComposeでラップしたい方や、従来の Androidライブラリに Composeのインターフェースを提供したい方にとって、様々なものを Compose化 する際に役立つ実践的なツールや設計パターン、アーキテクチャの考え方を持ち帰っていただける内容となっています。 
 ComposeではないコードをCompose化する
 
 22
  6. Case 1: 企業詳細画面 Old (XMLでの実装)
 @AndroidEntryPoint class DetailFragment : Fragment(R.layout.fragment_detail)

    { private val binding: FragmentDetailBinding by dataBinding() private val viewModel by viewModels<DetailViewModel>() private val args: DetailFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.getDetail(args.id) setUpSubscriber() } } private fun setUpSubscriber() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { launch { viewModel.detailScreenUiState.collect { uiState -> binding.name.text = uiState.name binding.image.load(uiState.image) { error(R.drawable.ic_placeholder) } binding.summary.text = uiState.summary binding.tagTitle.isVisible = uiState.isTagTitleVisible } } } } } 37
  7. Case 1: 企業詳細画面 Old (XMLでの実装)
 @AndroidEntryPoint class DetailFragment : Fragment(R.layout.fragment_detail)

    { private val binding: FragmentDetailBinding by dataBinding() private val viewModel by viewModels<DetailViewModel>() private val args: DetailFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.getDetail(args.id) setUpSubscriber() } } private fun setUpSubscriber() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { launch { viewModel.detailScreenUiState.collect { uiState -> binding.name.text = uiState.name binding.image.load(uiState.image) { error(R.drawable.ic_placeholder) } binding.summary.text = uiState.summary binding.tagTitle.isVisible = uiState.isTagTitleVisible } } } } } UiStateに表示に関す る情報が集まっている
 画面に表示する情報を 一回Fetchする
 38
  8. Case 1: 企業詳細画面 New (Composeでの実装)
 @AndroidEntryPoint class DetailFragment : Fragment()

    { private val viewModel: DetailViewModel by viewModels() private val args: DetailFragmentArgs by navArgs() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setContent { DetailScreenContent() } } } @Composable private fun DetailScreenContent() { // 画面の状態を収集 val uiState by viewModel.DetailScreenUiState.collectAsStateWithLifecycle() // 初期化イベント LaunchedEffect(Unit) { viewModel.fetchDetail(args.id) } DetailScreen(uiState = uiState) } } 39
  9. Case 1: 企業詳細画面 New (Composeでの実装)
 @AndroidEntryPoint class DetailFragment : Fragment()

    { private val viewModel: DetailViewModel by viewModels() private val args: DetailFragmentArgs by navArgs() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setContent { DetailScreenContent() } } } @Composable private fun DetailScreenContent() { // 画面の状態を収集 val uiState by viewModel.DetailScreenUiState.collectAsStateWithLifecycle() // 初期化イベント LaunchedEffect(Unit) { viewModel.fetchDetail(args.id) } DetailScreen(uiState = uiState) } } UiStateに表示に関す る情報が集まっている
 画面に表示する情報を 一回Fetchする
 40
  10. Case 1: 企業詳細画面 Old (XMLでの実装)
 @AndroidEntryPoint class DetailFragment : Fragment(R.layout.fragment_detail)

    { private val binding: FragmentDetailBinding by dataBinding() private val viewModel by viewModels<DetailViewModel>() private val args: DetailFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.getDetail(args.id) setUpSubscriber() } } private fun setUpSubscriber() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { launch { viewModel.detailScreenUiState.collect { uiState -> binding.name.text = uiState.name binding.image.load(uiState.image) { error(R.drawable.ic_placeholder) } binding.summary.text = uiState.summary binding.tagTitle.isVisible = uiState.isTagTitleVisible } } } } } UiStateに表示に関す る情報が集まっている
 画面に表示する情報を 一回Fetchする
 42
  11. Case 1: 企業詳細画面 New (Composeでの実装)
 @AndroidEntryPoint class DetailFragment : Fragment()

    { private val viewModel: DetailViewModel by viewModels() private val args: DetailFragmentArgs by navArgs() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setContent { DetailScreenContent() } } } @Composable private fun DetailScreenContent() { // 画面の状態を収集 val uiState by viewModel.DetailScreenUiState.collectAsStateWithLifecycle() // 初期化イベント LaunchedEffect(Unit) { viewModel.fetchDetail(args.id) } DetailScreen(uiState = uiState) } } UiStateに表示に関す る情報が集まっている
 画面に表示する情報を 一回Fetchする
 43
  12. Compose API design patterns
 Prefer stateless and controlled @Composable functions(ステートレスで制御されたComposable関数を優先する)


    In this context, “stateless” refers to @Composable functions that retain no state of their own, but instead accept external state parameters that are owned and provided by the caller. “Controlled” refers to the idea that the caller has full control over the state provided to the composable.
 この文脈における「ステートレス」とは、それ自体で状態を保持せず、代わりに呼び出し元が所有し提供する外部の状態パラメータを受け入れ る@Composable 関数を指します。「制御された」とは、呼び出し元がコンポーザブルに提供される状態に対して完全な制御権を持っていると いう考え方を指します。
 
 
 ステートレス:データ(状態)を引数として受け取るだけで、内部に保存しない。
 制御された:データを渡す呼び出し元(親コンポーネントやViewModel)が、データの値や変更のタイミングを完全に決定・管 理する。
 API Guidelines for Jetpack Compose
 引用: API GUidelines for Jetpack Compose https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines. md#prefer-stateless-and-controlled-composable-functions 49
  13. Do
 API Guidelines for Jetpack Compose
 @Composable fun Checkbox( isChecked:

    Boolean, onToggle: () -> Unit ) { // ... // Usage: (caller mutates optIn and owns the source of truth) Checkbox( myState.optIn, onToggle = { myState.optIn = !myState.optIn } ) } Don’t
 @Composable fun Checkbox( initialValue : Boolean, onChecked: (Boolean) -> Unit ) { var checkedState by remember { mutableStateOf (initialValue) } // ... // Usage: (Checkbox owns the checked state, caller notified of changes) // Caller cannot easily implement a validation policy. Checkbox(false, onToggled = { callerCheckedState = it }) } 50
  14. Case 2: 求人詳細画面 Old
 @AndroidEntryPoint class DetailFragment : Fragment(R.layout.fragment_detail) {

    private val binding: FragmentDetailBinding by dataBinding() private val viewModel by viewModels<DetailViewModel>() private val args: DetailFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // – 略 setUpSubscriber() // – 略 } private fun setUpSubscriber() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.detailScreenUiState.collect { // – 略(たくさんの bindingへの設定 ) if (it.detail.bannerFlg == true) { setUpBanner() } else { binding.banner.isVisible = false } } } } } } private fun setUpBanner() { viewModel.bannerUiState.let { binding.banner.isVisible = it.isVisible if (it.isVisible.not()) return@let binding.banner.load(it.imageUrl.value) } } } コードはポイントを抜粋しています
 58
  15. Case 2: 求人詳細画面 Old
 @AndroidEntryPoint class DetailFragment : Fragment(R.layout.fragment_detail) {

    private val binding: FragmentDetailBinding by dataBinding() private val viewModel by viewModels<DetailViewModel>() private val args: DetailFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // – 略 setUpSubscriber() // – 略 } private fun setUpSubscriber() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.detailScreenUiState.collect { // – 略(たくさんの bindingへの設定 ) if (it.detail.bannerFlg == true) { setUpBanner() } else { binding.banner.isVisible = false } } } } } } private fun setUpBanner() { viewModel.bannerUiState.let { binding.banner.isVisible = it.isVisible if (it.isVisible.not()) return@let binding.banner.load(it.imageUrl.value) } } } コードはポイントを抜粋しています
 detailが特定 の状態の時に 呼ばれるメソッ ドがある
 59
  16. Case 2: 求人詳細画面 Old
 @AndroidEntryPoint class DetailFragment : Fragment(R.layout.fragment_detail) {

    private val binding: FragmentDetailBinding by dataBinding() private val viewModel by viewModels<DetailViewModel>() private val args: DetailFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // – 略 setUpSubscriber() // – 略 } private fun setUpSubscriber() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.detailScreenUiState.collect { // – 略(たくさんの bindingへの設定 ) if (it.detail.bannerFlg == true) { setUpBanner() } else { binding.banner.isVisible = false } } } } } } private fun setUpBanner() { viewModel.bannerUiState.let { binding.banner.isVisible = it.isVisible if (it.isVisible.not()) return@let binding.banner.load(it.imageUrl.value) } } } コードはポイントを抜粋しています
 detailが特定 の状態の時に 呼ばれるメソッ ドがある
 bannerUiStat eの状態によっ て表示を制御し たり、非同期処 理をするらしい
 60
  17. Case 2: 求人詳細画面 Old
 @AndroidEntryPoint class DetailFragment : Fragment(R.layout.fragment_detail) {

    private val binding: FragmentDetailBinding by dataBinding() private val viewModel by viewModels<DetailViewModel>() private val args: DetailFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // – 略 setUpSubscriber() // – 略 } private fun setUpSubscriber() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.detailScreenUiState.collect { // – 略(たくさんの bindingへの設定 ) if (it.detail.bannerFlg == true) { setUpBanner() } else { binding.banner.isVisible = false } } } } } } private fun setUpBanner() { viewModel.bannerUiState.let { binding.banner.isVisible = it.isVisible if (it.isVisible.not()) return@let binding.banner.load(it.imageUrl.value) } } } コードはポイントを抜粋しています
 detailが特定 の状態の時に 呼ばれるメソッ ドがある
 bannerUiStat eの状態によっ て表示を制御し たり、非同期処 理をするらしい
 loadの非同期 処理は適正な タイミングで呼 ばれるのか?
 61
  18. Case 2: 求人詳細画面 Old
 @AndroidEntryPoint class DetailFragment : Fragment(R.layout.fragment_detail) {

    private val binding: FragmentDetailBinding by dataBinding() private val viewModel by viewModels<DetailViewModel>() private val args: DetailFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // – 略 setUpSubscriber() // – 略 } private fun setUpSubscriber() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.detailScreenUiState.collect { // – 略(たくさんの bindingへの設定 ) if (it.detail.bannerFlg == true) { setUpBanner() } else { binding.banner.isVisible = false } } } } } } private fun setUpBanner() { viewModel.bannerUiState.let { binding.banner.isVisible = it.isVisible if (it.isVisible.not()) return@let binding.banner.load(it.imageUrl.value) } } } コードはポイントを抜粋しています
 detailが特定 の状態の時に 呼ばれるメソッ ドがある
 bannerUiStat eの状態によっ て表示を制御し たり、非同期処 理をするらしい
 loadの非同期 処理は適正な タイミングで呼 ばれるのか?
 これ
 どうなってる??
 62
  19. Case 2: 求人詳細画面 New
 if (uiState.bannerVisible) { // uiState ==

    detailScreenUiState Banner(imageUrl = bannerUiState.imageUrl.value) } @Composable fun Banner(imageUrl: String?) { AsyncImage(imageUrl) // coil } 該当箇所はこんな感じになりました
 ・ライフサイクルを丁寧に読み解き、ロジックを発生順序を整理
 ・手続き的→宣言的に書き直すと意図が分かりやすい
 63
  20. まとめ
 1. XMLによるUIはまだまだ残っている現状、XMLをComposeで利用できるようにすることは必要で、 XMLをComposeで利用するにはAndroidViewを利用する
 
 2. AndroidViewを使って成熟したXMLによる画面レイアウトをComposeとともに利用するには、いくつ かのコツが必要
 
 3.

    XMLによるレイアウトを、AndroidViewを用いてComposeで利用する際、状態を表現したオブジェク トを設定するだけでUIが更新されるような宣言的な構造にしておくことで、Composeとの親和性が高く なる
 
 4. ComposeではないコードをCompose化するためには、現在の状態をオブジェクトにまとめ、UI構造 へマッピングできるように整えておくことが肝要
 64