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

あらゆるアプリをCompose Multiplatformで書きたい! -ネイティブアプリの「...

あらゆるアプリをCompose Multiplatformで書きたい! -ネイティブアプリの「あの機能」を私たちはどう作るか-

Kotlin Fest 2024 ホールB 16:50〜
「あらゆるアプリをCompose Multiplatformで書きたい! -ネイティブアプリの「あの機能」を私たちはどう作るか-」のセッション資料です。

Proposal: https://fortee.jp/kotlin-fest-2024/proposal/94b36113-107b-4ea7-a06a-f7c4ca23aad7

ポートフォリオ: https://github.com/subroh0508/portfolio
Mastodonクライアントアプリ「Noctiluca」: https://github.com/subroh0508/noctiluca

subroh_0508

June 22, 2024
Tweet

More Decks by subroh_0508

Other Decks in Programming

Transcript

  1. 自己紹介 2 【¥324,009】にしこりさぶろ〜 @subroh_0508 🎤経歴 1995年生まれ。東京の離島・伊豆大島出身。 Android / Webエンジニア(6年8ヶ月) →

    DevHR(人事職、1年5ヶ月) メインの技術スタックは Kotlin・Android・Rails・React。 初めて触ったKotlinは v1.0.0-rc-1036😎
  2. TOKIUMの志 未 来 へ つ な が る 時 を

    生 む 自己紹介 / 会社紹介 3 経理の領域でDXを実現するBtoB/SaaS 新卒から8年目、人事職2年目 ※このセッションは 個人開発での成果が メインとなります プロダクト本部 プロダクト組織開発部 DevHR 坂上 晴信
  3. 自己紹介 / あとはフルKotlin製のポートフォリオサイトを見てくれ! 4 約5年前から、自分のポートフォリオサイトを フルKotlinで実装する(謎の)挑戦をしています💪 初代: Kotlin/JS + React.js

    2代目: Compose Multiplatform (JS target) 3代目: Compose Multiplatform (Wasm target) Now!! subroh0508.me subroh0508/portfolio 本番環境で動く、貴重なKotlin/Wasm製アプリ(のはず) 興味ある方はチェック!懇親会でもお見せするぞ😎
  4. アジェンダ 5 1. Compose Multiplatformの概要 ➔ フレームワークの歴史 ➔ 現在用意されている、公式のリソース・ドキュメント 2.

    モバイルアプリ開発における頻出機能の実装方法の紹介 ➔ OAuthによるログイン、ローカルデータストレージへの認証情報保持 ➔ 画像・動画の表示、選択、アップロード 3. 開発におけるハマりどころの乗り越え方 ➔ テストコードの書き方、実行方法について ※時間に収まりませんでした CfPで楽しみにしていた方 もしいたらすいません😭
  5. このセッションのゴール 6 🥅 実用的なモバイルアプリ開発に求められる機能について Compose Multiplatformでどのように実装するか理解する ➔ どこまで3rd Partyライブラリに頼れて、どこから自力実装が必要か ➔

    Android / iOS固有のロジックを実装する上でのコツ 🥅 現在のCompose Multiplatformの実力について知る ➔ 具体的なアプリ実装のユースケースを通し、何ができるのか知る Compose MultiplatformでのX-Plat開発に挑戦したいみなさんの 背中を押せるようなセッションを目指します💪
  6. Compose Multiplatformの概要 8 ✔ Jetpack Composeの Kotlin Multiplatform対応版 ✔ iOS・Desktop・WebアプリのUIを

    Jetpack Composeと(ほぼ)同じAPIで 宣言的に実装することができる フレームワーク Compose Multiplatform UI フレームワーク | JetBrains JetBrains/compose-multiplatform
  7. • 2020/09/01 🚀 Jetpack Compose α版 • 2020/11/05 🚀 Compose

    for Desktop M1 • 2021/05/04 🚀 Compose for Web Preview版 • 2021/07/28 🚀 Jetpack Compose v1.0 • 2021/08/04 🚀 Compose Multiplatform α版 • 2021/10/22 🚀 Compose Multiplatform β版 • 2021/12/02 🚀 Compose Multiplatform v1.0 • 2023/04/12 🚀 Compose Multiplatform v1.4 for iOS → α版 • 2024/05/23 🚀 Compose Multiplatform v1.6.10 for iOS → β版 / for Web → α版 Compose Multiplatformの歴史 9
  8. • 2020/09/01 🚀 Jetpack Compose α版 • 2020/11/05 🚀 Compose

    for Desktop M1 • 2021/05/04 🚀 Compose for Web Preview版 • 2021/07/28 🚀 Jetpack Compose v1.0 • 2021/08/04 🚀 Compose Multiplatform α版 • 2021/10/22 🚀 Compose Multiplatform β版 • 2021/12/02 🚀 Compose Multiplatform v1.0 • 2023/04/12 🚀 Compose Multiplatform v1.4 for iOS → α版 • 2024/05/23 🚀 Compose Multiplatform v1.6.10 for iOS → β版 / for Web → α版 Compose Multiplatformの歴史 10 Jetpack Composeの 安定版リリース前から開発
  9. • 2020/09/01 🚀 Jetpack Compose α版 • 2020/11/05 🚀 Compose

    for Desktop M1 • 2021/05/04 🚀 Compose for Web Preview版 • 2021/07/28 🚀 Jetpack Compose v1.0 • 2021/08/04 🚀 Compose Multiplatform α版 • 2021/10/22 🚀 Compose Multiplatform β版 • 2021/12/02 🚀 Compose Multiplatform v1.0 • 2023/04/12 🚀 Compose Multiplatform v1.4 for iOS → α版 • 2024/05/23 🚀 Compose Multiplatform v1.6.10 for iOS → β版 / for Web → α版 Compose Multiplatformの歴史 11 for Desktopは既に 安定版に到達😎 for iOSもβ版まで到達😎
  10. Compose Multiplatformが提供する機能(〜v1.6.10まで) 12 🔧 宣言的な文法でのUIの実装 🔧 Android・iOS・Desktop・Web(JS / Wasm)向けにアプリをビルド ➔

    各ターゲット固有の機能・API、3rd Party製ライブラリも利用可能 🔧 共通コードでのリソース管理(components-resources) ※v1.6.0〜 ➔ 文字列・画像・フォント etc. のリソースを共通コードで扱える 🔧 Jetpack Navigation準拠のナビゲーションAPI ※v1.6.10〜 🔧 Jetpack Lifecycle準拠のLifecycle・ViewModelの利用 🔧 UIテストのサポート ※v1.6.0〜 (Experimental) ※v1.6.10〜 想像以上に色々できる!
  11. Compose Multiplatformに関する公式リソース 13 📕 Kotlin Multiplatform Development ➔ Kotlin Multiplatformを使った開発に関する情報がまとまっている

    Compose Multiplatformに関する情報は “Create an app with shared logic and UI” ”Compose Multiplatform UI framework”以下 アプリのリリース方法についても サポートされている😎
  12. とりあえずCompose Multiplatformで何か作ってみたい! 14 📕 以下のスライドを参考に ➔ Jetpack ComposeでAndroid/iOSアプリを作る @DroidKaigi 2023

    ➔ 今こそ始めたい!Compose Multiplatform @Kotlin Fest 2024 Compose Multiplatformでのアプリ開発、最初の一歩は上記2つでOK👍 このセッションでは、最初の一歩の”先”にたどり着くための知見をまとめます
  13. subroh0508/noctiluca Compose Multiplatform、今どこまでやれるのか? 16 🔧 が現在開発中のMastodonクライアントアプリ「Noctilca」 💪実装済の機能 - OAuth認証によるログイン -

    複数アカウントの認証情報 保持、切り替え - トゥートの閲覧、投稿 - ストリーミングの購読 (→タイムラインの自動更新) - 画像・動画のプレビュー - 画像・動画の選択、投稿
  14. 🔧 が現在開発中のMastodonクライアントアプリ「Noctilca」 ➔ 公式ライブラリ(一部) kotlinx.coroutines 非同期処理/ kotlinx.serialization JSON ↔ data

    class kotlinx-datetime 時間関連の処理 ➔ 3rd Party製ライブラリ(一部) Ktor HTTPクライアント / Voyager ナビゲーション実装 Compose ImageLoader 画像表示 / KotlinPoet コード生成(→リソース管理) Kotest テストフレームワーク / Koin Dependency Injection detekt Linter・コード整形 subroh0508/noctiluca Compose Multiplatform、今どこまでやれるのか? 17 各種ライブラリも充実!モダンで 実用的なアプリを実装できる💪
  15. MastodonのOAuth認証の手順 20 1. OAuth認証によるログイン クライアント POST /api/v1/apps client_id, client_secret リダイレクト

    認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存
  16. MastodonのOAuth認証の手順(1/4) 21 1. OAuth認証によるログイン クライアント POST /api/v1/apps client_id, client_secret リダイレクト

    認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 client_idとclient_secretを取得
  17. MastodonのOAuth認証の手順(2/4) 22 1. OAuth認証によるログイン クライアント POST /api/v1/apps client_id, client_secret リダイレクト

    認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 ブラウザでアクセス クライアントからのアクセスを認可し、認可コードを受け取る
  18. MastodonのOAuth認証の手順(3/4) 23 1. OAuth認証によるログイン クライアント POST /api/v1/apps client_id, client_secret リダイレクト

    認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 Mastodonのサーバーからのリダイレクトを クライアントアプリで受け取り、認可コードを取得
  19. MastodonのOAuth認証の手順(4/4) 24 1. OAuth認証によるログイン クライアント POST /api/v1/apps client_id, client_secret リダイレクト

    認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 認可コードを利用し、アクセストークンを取得 以降、HTTPヘッダにトークンを付与すれば👍 Authorization: Bearer #{access_token} a
  20. MastodonのOAuth認証の手順 25 1. OAuth認証によるログイン クライアント POST /api/v1/apps client_id, client_secret リダイレクト

    認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 単純にREST APIを叩くだけ👍 Ktorのクライアント機能を使えばOK 単純にREST APIを叩くだけ👍 Ktorのクライアント機能を使えばOK
  21. MastodonのOAuth認証の手順 26 1. OAuth認証によるログイン クライアント POST /api/v1/apps client_id, client_secret リダイレクト

    認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 🤔特定のページをブラウザで開く 🤔リダイレクトをクライアントアプリで受け取る → Android / iOS固有のコードの実装が必要
  22. val url: String = "https://…" with (LocalContext.current) { startActivity( Intent(

    Intent.ACTION_VIEW, Uri.parse(url), ), ) } 特定のページをブラウザで開く 27 1. OAuth認証によるログイン startActivityを使うパターン • startActivity メソッド/ CustomTabsIntent クラスを使う
  23. • startActivity メソッド/ CustomTabsIntent クラスを使う val url: String = "https://…"

    CustomTabsIntent.Builder() .setShowTitle(true) .build() .launchUrl( LocalContext.current, Uri.parse(url), ) 特定のページをブラウザで開く 28 1. OAuth認証によるログイン CustomTabsIntentを使うパターン
  24. • UIApplication.sharedApplication.openURL メソッドを使う 特定のページをブラウザで開く 29 1. OAuth認証によるログイン let url: String

    = "https://…" UIApplication.shared.open(URL(string: url)) NSString url = @"https://..."; [[UIApplication sharedApplication] openURL:[NSURL URLWithString:url]]; ※Swiftでは UIApplication.shared.open と書くのがモダンらしい
  25. 特定のページをブラウザで開く 31 1. OAuth認証によるログイン @Composable expect fun openBrowser(url: String) commonMain/Browser.kt

    KMPの共通コードに直すと、こんな感じ ➔ UIApplication.sharedApplication.openURL メソッドを使う ➔ いずれのコードも 「URLを文字列で受け取り」「同期的に処理を実行する」 「ブラウザを開き」「何も返さないメソッド」としてまとめられる • startActivity メソッド/ CustomTabsIntent クラスを使う
  26. 特定のページをブラウザで開く 32 1. OAuth認証によるログイン • Android向け → シンプルに実装 @Composable actual

    fun openBrowser(url: String) { CustomTabsIntent.Builder() .setShowTitle(true) .build() .launchUrl( LocalContext.current, Uri.parse(url), ) } androidMain/Browser.kt
  27. • iOS向け → Obj-CのロジックをKotlinで実装する 特定のページをブラウザで開く 33 1. OAuth認証によるログイン @Composable actual

    fun openBrowser(url: String) { val nsUrl = NSURL.URLWithString(url) ?: return UIApplication.sharedApplication.openURL(nsUrl) } iosMain/Browser.kt
  28. • iOS向け → Obj-CのロジックをKotlinで実装する 特定のページをブラウザで開く 34 1. OAuth認証によるログイン @Composable actual

    fun openBrowser(url: String) { val nsUrl = NSURL.URLWithString(url) ?: return UIApplication.sharedApplication.openURL(nsUrl) } 参照: Apple Developer Documentation iosMain/Browser.kt openURL メソッドの引数は NSURL 型 KotlinのString型から変換する必要がある
  29. リダイレクトをクライアントアプリで受け取る 36 1. OAuth認証によるログイン 🤔 やりたいこと ➔ リダイレクトをクライアントアプリで受け取り、 アクセストークンの取得に必要な認可コードを取得する •

    Androidでの手順 ➔ AndroidManifest.xml に <intent-filters> を追加 ➔ Activityの onCreate / onNewIntent メソッドにIntentが発行 ➔ Intentの getData メソッドから Uri 型のインスタンスを取得 ➔ Uri 型のインスタンスから認可コードを取得
  30. 🤔 やりたいこと ➔ リダイレクトをクライアントアプリで受け取り、 アクセストークンの取得に必要な認可コードを取得する • iOSでの手順 ➔ info.plist にURL

    Schemesを設定 ➔ ContentView に対し、 onOpenURL メソッドを追加 ➔ コールバックに URL 型のインスタンスが流れる ➔ URL 型のインスタンスから認可コードを取得 リダイレクトをクライアントアプリで受け取る 37 1. OAuth認証によるログイン
  31. 🤔 やりたいこと ➔ リダイレクトをクライアントアプリで受け取り、 アクセストークンの取得に必要な認可コードを取得する • iOSでの手順 ➔ info.plist にURL

    Schemesを設定 ➔ ContentView に対し、 onOpenURL メソッドを追加 ➔ コールバックに URL 型のインスタンスが流れる ➔ URL 型のインスタンスから認可コードを取得 リダイレクトをクライアントアプリで受け取る 38 1. OAuth認証によるログイン AndroidとiOSで大きく手順が違う😢 実装つらそうに見えるが…
  32. 🤔 AndroidとiOSの手順を比較すると… ➔ ➔ ➔ Uri / URL 型のインスタンスから認可コードを取得 ➔

    AndroidManifest.xml に <intent-filters> を追加 ➔ info.plist にURL Schemesを設定 リダイレクトをクライアントアプリで受け取る 39 1. OAuth認証によるログイン ➔ Activityの onCreate / onNewIntent メソッドにIntentが発行 Intentの getData メソッドから Uri 型のインスタンスを取得 ➔ ContentView に対し、 onOpenURL メソッドを追加 コールバックに URL 型のインスタンスが流れる
  33. 🤔 AndroidとiOSの手順を比較すると… ➔ ➔ ➔ Uri / URL 型のインスタンスから認可コードを取得 ➔

    AndroidManifest.xml に <intent-filters> を追加 ➔ info.plist にURL Schemesを設定 リダイレクトをクライアントアプリで受け取る 40 1. OAuth認証によるログイン ➔ Activityの onCreate / onNewIntent メソッドにIntentが発行 Intentの getData メソッドから Uri 型のインスタンスを取得 ➔ ContentView に対し、 onOpenURL メソッドを追加 コールバックに URL 型のインスタンスが流れる リダイレクトのリクエストをアプリで受け取れるよう、設定を追加する → ロジック不要、頑張る必要なし
  34. 🤔 AndroidとiOSの手順を比較すると… ➔ ➔ ➔ Uri / URL 型のインスタンスから認可コードを取得 ➔

    AndroidManifest.xml に <intent-filters> を追加 ➔ info.plist にURL Schemesを設定 リダイレクトをクライアントアプリで受け取る 41 1. OAuth認証によるログイン ➔ Activityの onCreate / onNewIntent メソッドにIntentが発行 Intentの getData メソッドから Uri 型のインスタンスを取得 ➔ ContentView に対し、 onOpenURL メソッドを追加 コールバックに URL 型のインスタンスが流れる リダイレクトのリクエストをコードで扱えるようにする → Android / iOSそれぞれ個別のロジックが必要、頑張りどころ
  35. 🤔 AndroidとiOSの手順を比較すると… ➔ ➔ ➔ Uri / URL 型のインスタンスから認可コードを取得 ➔

    AndroidManifest.xml に <intent-filters> を追加 ➔ info.plist にURL Schemesを設定 リダイレクトをクライアントアプリで受け取る 42 1. OAuth認証によるログイン ➔ Activityの onCreate / onNewIntent メソッドにIntentが発行 Intentの getData メソッドから Uri 型のインスタンスを取得 ➔ ContentView に対し、 onOpenURL メソッドを追加 コールバックに URL 型のインスタンスが流れる 似た型のインスタンスから、欲しい値を取得する → 型を揃えれば共通のロジックで実装可能、多少の頑張りでOK
  36. 🤔 AndroidとiOSの手順を比較すると… ➔ ➔ ➔ Uri / URL 型のインスタンスから認可コードを取得 ➔

    AndroidManifest.xml に <intent-filters> を追加 ➔ info.plist にURL Schemesを設定 リダイレクトをクライアントアプリで受け取る 43 1. OAuth認証によるログイン ➔ Activityの onCreate / onNewIntent メソッドにIntentが発行 Intentの getData メソッドから Uri 型のインスタンスを取得 ➔ ContentView に対し、 onOpenURL メソッドを追加 コールバックに URL 型のインスタンスが流れる ただの設定 個別実装 共通実装 個別の実装が最小になるよう ロジックを分解し、設計すると👍
  37. リダイレクトをクライアントアプリで受け取る(1/3) 44 1. OAuth認証によるログイン • リダイレクトを受け取る設定を追加する for Android ➔ AndroidManifest.xml

    に <intent-filters> を追加 <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:path="/noctiluca" android:scheme="oauth2redirect" /> </intent-filter> AndroidManifest.xml Custom Schemeの設定
  38. リダイレクトをクライアントアプリで受け取る(2/3) 47 1. OAuth認証によるログイン • リダイレクトのリクエストをコードで扱う for Android @Composable actual

    fun HandleDeepLink() { val context = LocalContext.current val oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer<Intent> { trySend(it) } activity?.addOnNewIntentListener(consumer) awaitClose { activity?.removeOnNewIntentListener(consumer) } }.collectLatest { intent -> val uri = intent.data ?: return@collectLatest val host = uri.host ?: return@collectLatest val params = MastodonInstanceDetailParams(host, uri.query) // ナビゲーションの処理 } } } androidMain/HandleDeepLink.kt
  39. @Composable actual fun HandleDeepLink() { val context = LocalContext.current val

    oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer<Intent> { trySend(it) } activity?.addOnNewIntentListener(consumer) awaitClose { activity?.removeOnNewIntentListener(consumer) } }.collectLatest { intent -> val uri = intent.data ?: return@collectLatest val host = uri.host ?: return@collectLatest val params = MastodonInstanceDetailParams(host, uri.query) // ナビゲーションの処理 } } } リダイレクトをクライアントアプリで受け取る(2/3) 48 1. OAuth認証によるログイン • リダイレクトのリクエストをコードで扱う for Android callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer<Intent> { trySend(it) } activity?.addOnNewIntentListener(consumer) } androidMain/HandleDeepLink.kt
  40. @Composable actual fun HandleDeepLink() { val context = LocalContext.current val

    oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer<Intent> { trySend(it) } activity?.addOnNewIntentListener(consumer) awaitClose { activity?.removeOnNewIntentListener(consumer) } }.collectLatest { intent -> val uri = intent.data ?: return@collectLatest val host = uri.host ?: return@collectLatest val params = MastodonInstanceDetailParams(host, uri.query) // ナビゲーションの処理 } } } リダイレクトをクライアントアプリで受け取る(2/3) 49 1. OAuth認証によるログイン • リダイレクトのリクエストをコードで扱う for Android callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer<Intent> { trySend(it) } activity?.addOnNewIntentListener(consumer) } androidMain/HandleDeepLink.kt onNewIntent に反応して、発行された Intentを callbackFlow に流す
  41. @Composable actual fun HandleDeepLink() { val context = LocalContext.current val

    oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer<Intent> { trySend(it) } activity?.addOnNewIntentListener(consumer) awaitClose { activity?.removeOnNewIntentListener(consumer) } }.collectLatest { intent -> val uri = intent.data ?: return@collectLatest val host = uri.host ?: return@collectLatest val params = MastodonInstanceDetailParams(host, uri.query) // ナビゲーションの処理 } } } リダイレクトをクライアントアプリで受け取る(2/3) 50 1. OAuth認証によるログイン • リダイレクトのリクエストをコードで扱う .collectLatest { intent -> val uri: Uri = intent.data ?: return@collectLatest val host: String = uri.host ?: return@collectLatest val params = MastodonInstanceDetailParams( host, uri.query, ) // ナビゲーションの処理 } androidMain/HandleDeepLink.kt
  42. @Composable actual fun HandleDeepLink() { val context = LocalContext.current val

    oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer<Intent> { trySend(it) } activity?.addOnNewIntentListener(consumer) awaitClose { activity?.removeOnNewIntentListener(consumer) } }.collectLatest { intent -> val uri = intent.data ?: return@collectLatest val host = uri.host ?: return@collectLatest val params = MastodonInstanceDetailParams(host, uri.query) // ナビゲーションの処理 } } } リダイレクトをクライアントアプリで受け取る(2/3) 51 1. OAuth認証によるログイン • リダイレクトのリクエストをコードで扱う for Android .collectLatest { intent -> val uri: Uri = intent.data ?: return@collectLatest val host: String = uri.host ?: return@collectLatest val params = MastodonInstanceDetailParams( host, uri.query, ) // ナビゲーションの処理 } androidMain/HandleDeepLink.kt Uri インスタンスから必要な情報を String型で取得
  43. • リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 52 1. OAuth認証によるログイン @main struct

    iOSApp: App { var body: some Scene { WindowGroup { ContentView() .onOpenURL(perform: { url in MainViewControllerKt.handleDeepLink( host: url.host, query: url.query, ) }) } } } iOSApp.swift
  44. @main struct iOSApp: App { var body: some Scene {

    WindowGroup { ContentView() .onOpenURL(perform: { url in MainViewControllerKt.handleDeepLink( host: url.host, query: url.query, ) }) } } } iOSApp.swift • リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 53 1. OAuth認証によるログイン struct ContentView: View { var body: some View { ComposeView() } } ContentView.swift ContentView → Compose Multiplatformで 実装したUIが描画されているView
  45. • リダイレクトのリクエストをコードで扱う リダイレクトをクライアントアプリで受け取る(2/3) 54 1. OAuth認証によるログイン @main struct iOSApp: App

    { var body: some Scene { WindowGroup { ContentView() .onOpenURL(perform: { url in MainViewControllerKt.handleDeepLink( host: url.host, query: url.query, ) }) } } } iOSApp.swift ContentView() .onOpenURL(perform: { url in MainViewControllerKt.handleDeepLink( host: url.host, query: url.query, ) })
  46. • リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 55 1. OAuth認証によるログイン @main struct

    iOSApp: App { var body: some Scene { WindowGroup { ContentView() .onOpenURL(perform: { url in MainViewControllerKt.handleDeepLink( host: url.host, query: url.query, ) }) } } } iOSApp.swift ContentView() .onOpenURL(perform: { url in MainViewControllerKt.handleDeepLink( host: url.host, query: url.query, ) }) ContentView に onOpenURL を追加 コールバックメソッドにリクエストが流れる
  47. • リダイレクトのリクエストをコードで扱う リダイレクトをクライアントアプリで受け取る(2/3) 56 1. OAuth認証によるログイン @main struct iOSApp: App

    { var body: some Scene { WindowGroup { ContentView() .onOpenURL(perform: { url in MainViewControllerKt.handleDeepLink( host: url.host, query: url.query, ) }) } } } iOSApp.swift ContentView() .onOpenURL(perform: { url in MainViewControllerKt.handleDeepLink( host: url.host, query: url.query, ) }) Kotlinの世界に情報を渡すコード(後述)
  48. • リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 57 1. OAuth認証によるログイン private val

    deepLinkStateFlow = MutableStateFlow<MastodonInstanceDetailParams?>(null) fun handleDeepLink(domain: String?, query: String?) { deepLinkStateFlow.value = domain?.let { MastodonInstanceDetailParams(it, query) } } @Composable actual fun HandleDeepLink() { val params: MastodonInstanceDetailParams? by deepLinkStateFlow.collectAsState() // ナビゲーションの処理 } iosMain/HandleDeepLink.kt
  49. • リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 58 1. OAuth認証によるログイン private val

    deepLinkStateFlow = MutableStateFlow<MastodonInstanceDetailParams?>(null) fun handleDeepLink(domain: String?, query: String?) { deepLinkStateFlow.value = domain?.let { MastodonInstanceDetailParams(it, query) } } @Composable actual fun HandleDeepLink() { val params: MastodonInstanceDetailParams? by deepLinkStateFlow.collectAsState() // ナビゲーションの処理 } iosMain/HandleDeepLink.kt val params: MastodonInstanceDetailParams? by deepLinkStateFlow.collectAsState() // ナビゲーションの処理
  50. • リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 59 1. OAuth認証によるログイン private val

    deepLinkStateFlow = MutableStateFlow<MastodonInstanceDetailParams?>(null) fun handleDeepLink(domain: String?, query: String?) { deepLinkStateFlow.value = domain?.let { MastodonInstanceDetailParams(it, query) } } @Composable actual fun HandleDeepLink() { val params: MastodonInstanceDetailParams? by deepLinkStateFlow.collectAsState() // ナビゲーションの処理 } iosMain/HandleDeepLink.kt val params: MastodonInstanceDetailParams? by deepLinkStateFlow.collectAsState() // ナビゲーションの処理 トップレベルに宣言した MutableStateFlow から リダイレクトのリクエストを流す
  51. • リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 60 1. OAuth認証によるログイン private val

    deepLinkStateFlow = MutableStateFlow<MastodonInstanceDetailParams?>(null) fun handleDeepLink(domain: String?, query: String?) { deepLinkStateFlow.value = domain?.let { MastodonInstanceDetailParams(it, query) } } @Composable actual fun HandleDeepLink() { val params: MastodonInstanceDetailParams? by deepLinkStateFlow.collectAsState() // ナビゲーションの処理 } iosMain/HandleDeepLink.kt fun handleDeepLink(domain: String?, query: String?) { deepLinkStateFlow.value = domain?.let { MastodonInstanceDetailParams(it, query) } }
  52. • リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 61 1. OAuth認証によるログイン private val

    deepLinkStateFlow = MutableStateFlow<MastodonInstanceDetailParams?>(null) fun handleDeepLink(domain: String?, query: String?) { deepLinkStateFlow.value = domain?.let { MastodonInstanceDetailParams(it, query) } } @Composable actual fun HandleDeepLink() { val params: MastodonInstanceDetailParams? by deepLinkStateFlow.collectAsState() // ナビゲーションの処理 } iosMain/HandleDeepLink.kt fun handleDeepLink(domain: String?, query: String?) { deepLinkStateFlow.value = domain?.let { MastodonInstanceDetailParams(it, query) } } Swiftの世界から渡された情報を MutableStateFlow に渡すメソッド
  53. • Uri / URL 型のインスタンスから認可コードを取得 リダイレクトをクライアントアプリで受け取る(3/3) 62 1. OAuth認証によるログイン internal

    fun buildAuthorizeResult(params: MastodonInstanceDetailParams): AuthorizeResult? { val code = QUERY_CODE_PATTERN.find(params.query ?: "")?.groupValues?.get(1) val error = QUERY_ERROR_PATTERN.find(params.query ?: "")?.groupValues?.get(1) val errorDescription = QUERY_ERROR_DESCRIPTION_PATTERN.find(params.query ?: "")?.groupValues?.get(1) if (code != null) { return AuthorizeResult.Success(code) } if (error == null || errorDescription == null) { return null } return when (error) { "access_denied" -> AuthorizeResult.Failure(AccessDeniedException(errorDescription)) else -> AuthorizeResult.Failure(AuthorizedFailedException(errorDescription)) } } AuthorizeCode.kt
  54. リダイレクトをクライアントアプリで受け取る(3/3) 63 1. OAuth認証によるログイン internal fun buildAuthorizeResult(params: MastodonInstanceDetailParams): AuthorizeResult? {

    val code = QUERY_CODE_PATTERN.find(params.query ?: "")?.groupValues?.get(1) val error = QUERY_ERROR_PATTERN.find(params.query ?: "")?.groupValues?.get(1) val errorDescription = QUERY_ERROR_DESCRIPTION_PATTERN.find(params.query ?: "")?.groupValues?.get(1) if (code != null) { return AuthorizeResult.Success(code) } if (error == null || errorDescription == null) { return null } return when (error) { "access_denied" -> AuthorizeResult.Failure(AccessDeniedException(errorDescription)) else -> AuthorizeResult.Failure(AuthorizedFailedException(errorDescription)) } } AuthorizeCode.kt val code = QUERY_CODE_PATTERN.find(params.query ?: "") ?.groupValues ?.get(1) • Uri / URL 型のインスタンスから認可コードを取得
  55. • Uri / URL 型のインスタンスから認可コードを取得 リダイレクトをクライアントアプリで受け取る(3/3) 64 1. OAuth認証によるログイン internal

    fun buildAuthorizeResult(params: MastodonInstanceDetailParams): AuthorizeResult? { val code = QUERY_CODE_PATTERN.find(params.query ?: "")?.groupValues?.get(1) val error = QUERY_ERROR_PATTERN.find(params.query ?: "")?.groupValues?.get(1) val errorDescription = QUERY_ERROR_DESCRIPTION_PATTERN.find(params.query ?: "")?.groupValues?.get(1) if (code != null) { return AuthorizeResult.Success(code) } if (error == null || errorDescription == null) { return null } return when (error) { "access_denied" -> AuthorizeResult.Failure(AccessDeniedException(errorDescription)) else -> AuthorizeResult.Failure(AuthorizedFailedException(errorDescription)) } } AuthorizeCode.kt val code = QUERY_CODE_PATTERN.find(params.query ?: "") ?.groupValues ?.get(1) Uri / URL 型のインスタンスから取得したクエリ文字列 そのクエリ文字列から認可コードを取得 → 認可コードを利用し、アクセストークンを取得🎉
  56. 2. ローカルデータストレージへの認証情報保持 67 クライアント POST /api/v1/apps client_id, client_secret リダイレクト 認可コード

    取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 ここをどうするか🤔
  57. • Android ➔ DataStore ➔ SharedPreferences • iOS ➔ UserDefaults

    ➔ NSUserDefaults 2. ローカルデータストレージへの認証情報保持 68
  58. • Android ➔ DataStore ➔ SharedPreferences • iOS ➔ UserDefaults

    ➔ NSUserDefaults 2. ローカルデータストレージへの認証情報保持 69 public interface DataStore<T> { public val data: Flow<T> public suspend fun updateData( transform: suspend (t: T) -> T, ): T } public open expect class NSUserDefaults : NSObject { public open expect fun URLForKey(defaultName: String): NSURL? { /* … */ } public open expect fun addSuiteNamed(suiteName: String): { /* … */ } public open expect fun arrayForKey(defaultName: String): List<*>? { /* … */ } /* … */ } DataStoreとNSUserDefaultsの定義 (当然ながら)大きく異なる ラッパークラス作るの面倒くさい…😩
  59. • Android ➔ DataStoreがKotlin Multiplatformに対応!(v1.1.0〜)😎 ➔ SharedPreferences • iOS ➔

    UserDefaults ➔ NSUserDefaults 2. ローカルデータストレージへの認証情報保持 70 参照: Android Developers Android / iOS共通のAPIで ローカルデータストレージを扱える😎
  60. DataStoreを使ってトークンを保存する(1/3) 71 2. ローカルデータストレージへの認証情報保持 • 依存関係を追加 commonMain { dependencies {

    implementation("androidx.datastore:datastore-core:1.1.0") implementation("androidx.datastore:datastore-preferences-core:1.1.0") } }
  61. • DataStoreのインスタンスを生成 for Android DataStoreを使ってトークンを保存する(2/3) 72 2. ローカルデータストレージへの認証情報保持 internal const

    val dataStoreFileName = "data.preferences_pb" fun createDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create( produceFile = { context.filesDir.resolve(dataStoreFileName) }, ) androidMain/DataStore.kt
  62. • DataStoreのインスタンスを生成 for iOS DataStoreを使ってトークンを保存する(2/3) 73 2. ローカルデータストレージへの認証情報保持 internal const

    val dataStoreFileName = "data.preferences_pb" fun createDataStore(): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath( produceFile = { val documentDirectory = NSFileManager.defaultManager .URLForDirectory( directory = NSDocumentDirectory, inDomain = NSUserDomainMask, appropriateForURL = null, create = false, error = null, ) (requireNotNull(documentDirectory).path + "/$dataStoreFileName").toPath() } ) iosMain/DataStore.kt
  63. DataStoreを使ってトークンを保存する(3/3) 74 2. ローカルデータストレージへの認証情報保持 • トークンをDataStoreから読み込む、DataStoreに書き込む private val PREF_TOKEN =

    stringPreferencesKey("key_token") class TokenDataStore( private val dataStore: DataStore<Preferences>, ) { suspend fun get() = dataStore.data.first()[PREF_TOKEN] suspend fun put(token: String) { dataStore.edit { it[PREF_TOKEN] = token } } } commonMain/TokenDataStore.kt
  64. DataStoreを使ってトークンを保存する(3/3) 75 2. ローカルデータストレージへの認証情報保持 • トークンをDataStoreから読み込む、DataStoreに書き込む private val PREF_TOKEN =

    stringPreferencesKey("key_token") class TokenDataStore( private val dataStore: DataStore<Preferences>, ) { suspend fun get() = dataStore.data.first()[PREF_TOKEN] suspend fun put(token: String) { dataStore.edit { it[PREF_TOKEN] = token } } } commonMain/TokenDataStore.kt 読み込みでは data プロパティを利用
  65. DataStoreを使ってトークンを保存する(3/3) 76 2. ローカルデータストレージへの認証情報保持 • トークンをDataStoreから読み込む、DataStoreに書き込む private val PREF_TOKEN =

    stringPreferencesKey("key_token") class TokenDataStore( private val dataStore: DataStore<Preferences>, ) { suspend fun get() = dataStore.data.first()[PREF_TOKEN] suspend fun put(token: String) { dataStore.edit { it[PREF_TOKEN] = token } } } z commonMain/TokenDataStore.kt 書き込みでは edit / updateData メソッドを利用
  66. 画像を表示する 78 3. 画像・動画の表示 • Image コンポーザブルを利用する @Composable @NonRestartableComposable fun

    Image( bitmap: ImageBitmap, contentDescription: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DefaultFilterQuality, ) Image.kt
  67. 画像を表示する 79 3. 画像・動画の表示 @Composable @NonRestartableComposable fun Image( bitmap: ImageBitmap,

    contentDescription: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DefaultFilterQuality, ) Image.kt Compose Multiplatformに 標準で用意されているコンポーザブル • Image コンポーザブルを利用する
  68. 画像を表示する 80 3. 画像・動画の表示 @Composable @NonRestartableComposable fun Image( bitmap: ImageBitmap,

    contentDescription: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DefaultFilterQuality, ) z Image.kt 第一引数として ImageBitmap or ImageVector or Painter を取る → 画像データを上記3つのいずれかに 変換できれば表示できる👍 • Image コンポーザブルを利用する
  69. 画像を表示する 3. 画像・動画の表示 • 画像データをImageコンポーザルが扱える形に変換するライブラリ ➔ Kamel: Kamel-Media/Kamel ➔ Compose

    ImageLoader: qdsfdhvh/compose-imageloader ➔ Coil (v3.0.0-alpha01〜): coil-kt/coil ※KMP対応Issue いずれも画像データの場所を問わず、イイ感じに読み込んでくれる👍 CoilのKMP対応が安定版に移行したらほぼ一択、自作のメリットは薄いかも👀
  70. CompositionLocalProvider( LocalImageLoader provides remember { buildImageLoader() }, ) { AutoSizeBox("https://...")

    { action -> when (action) { is ImageAction.Success -> Image( rememberImageSuccessPainter(action), contentDescription = "image", ) is ImageAction.Loading -> { /* 読み込み中の表示 */ } is ImageAction.Failure -> { /* エラーの表示 */ } } } } 画像を表示する 3. 画像・動画の表示 • Compose ImageLoaderを使ったオンライン上の画像表示
  71. CompositionLocalProvider( LocalImageLoader provides remember { buildImageLoader() }, ) { AsyncImage(

    model = ) { action -> when (action) { is ImageAction.Success -> Image( rememberImageSuccessPainter(action), contentDescription = "image", ) is ImageAction.Loading -> { /* 読み込み中の表示 */ } is ImageAction.Failure -> { /* エラーの表示 */ } } } 画像を表示する 3. 画像・動画の表示 • Coilを使ったオンライン上の画像表示
  72. 動画を表示する 3. 画像・動画の表示 😭 動画をイイ感じに表示するコンポーザブルは…用意されておらず ➔ 3rd Party製ライブラリにも、安心して使えるものがほぼない ➔ Android

    / iOSそれぞれ個別でロジックを実装し、 「動画データのURLを受け取り」「動画を再生する」 @Composable メソッドにまとめればOK(のはず) 自作しましょう!!!👊
  73. • 動画再生の仕組み for Android ➔ MediaPlayer (API Level >= 1)

    ➔ ExoPlayer (API Level >= 16) ✅ 動画を表示する 87 3. 画像・動画の表示 どちらを使っても目的は達成できるが せっかくなので新しい方を使う💪
  74. • 動画再生の仕組み for Android ➔ MediaPlayer (API Level >= 1)

    ➔ ExoPlayer (API Level >= 16) ✅ ➔ オンライン上の動画データも、URLだけ渡せば読み込み可能🙆 ➔ ざっくり実装方針 Compose Multiplatformで実装したUIにAndroidViewを表示し、 AndroidViewの上で動画再生用のViewである PlayerView を描画 動画を表示する 88 3. 画像・動画の表示
  75. • 動画再生の仕組み for iOS ➔ AVPlayerViewController (iOS >= 8.0) ✅

    ➔ VideoPlayer (iOS >= 14.0) 動画を表示する 89 3. 画像・動画の表示 UIKit時代からよく使用されるAVPlayerViewController SwiftUIに最適化されたVideoPlayer → iOS用の個別ロジックもKotlinで実装するには、 現状AVPlayerViewControllerの利用が必須 ※Compose Multiplatformで実装したUIの上に SwiftUIで実装したコンポーネントを描画することができるため、 VideoPlayerでも理論上はやりたいことが実現できる(未検証)
  76. • 動画再生の仕組み for iOS ➔ AVPlayerViewController (iOS >= 8.0) ✅

    ➔ VideoPlayer (iOS >= 14.0) ➔ オンライン上の動画データも、URLだけ渡せば読み込み可能🙆 ➔ ざっくり実装方針 Compose Multiplatformで実装したUIにUIKitViewを表示し、 UIKitViewの上で動画再生用のコンポーネントを描画 動画を表示する 90 3. 画像・動画の表示
  77. • Playerインスタンスの生成 + 動画再生画面の描画 for Android ➔ 依存関係を追加 動画を表示する(2/2) 92

    3. 画像・動画の表示 androidMain { dependencies { implementation("androidx.media3:media3-exoplayer:1.3.1") implementation("androidx.media3:media3-ui:1.3.1") } }
  78. @Composable actual fun VideoPlayer( url: String, modifier: Modifier, ) {

    val context = LocalContext.current val exoPlayer = remember(context) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) prepare() seekTo(0L) play() } } AndroidView( factory = { PlayerView(context).apply { player = exoPlayer } }, modifier = modifier, ) } • Playerインスタンスの生成 + 動画再生画面の描画 for Android 動画を表示する(2/2) 93 3. 画像・動画の表示 androidMain/VideoPlayer.kt
  79. @Composable actual fun VideoPlayer( url: String, modifier: Modifier, ) {

    val context = LocalContext.current val exoPlayer = remember(context) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) prepare() seekTo(0L) play() } } AndroidView( factory = { PlayerView(context).apply { player = exoPlayer } }, modifier = modifier, ) } • Playerインスタンスの生成 + 動画再生画面の描画 for Android 動画を表示する(2/2) 94 3. 画像・動画の表示 androidMain/VideoPlayer.kt ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) prepare() seekTo(0L) play() }
  80. @Composable actual fun VideoPlayer( url: String, modifier: Modifier, ) {

    val context = LocalContext.current val exoPlayer = remember(context) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) prepare() seekTo(0L) play() } } AndroidView( factory = { PlayerView(context).apply { player = exoPlayer } }, modifier = modifier, ) } • Playerインスタンスの生成 + 動画再生画面の描画 for Android 動画を表示する(2/2) 95 3. 画像・動画の表示 androidMain/VideoPlayer.kt ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) prepare() seekTo(0L) play() } ExoPlayer のインスタンスを生成 apply ブロックの中では、動画データのURLを渡し 再生位置を0秒へ移動 → 再生を実行
  81. @Composable actual fun VideoPlayer( url: String, modifier: Modifier, ) {

    val context = LocalContext.current val exoPlayer = remember(context) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) prepare() seekTo(0L) play() } } AndroidView( factory = { PlayerView(context).apply { player = exoPlayer } }, modifier = modifier, ) } • Playerインスタンスの生成 + 動画再生画面の描画 for Android 動画を表示する(2/2) 96 3. 画像・動画の表示 androidMain/VideoPlayer.kt AndroidView( factory = { PlayerView(context).apply { player = exoPlayer } }, modifier = modifier, )
  82. @Composable actual fun VideoPlayer( url: String, modifier: Modifier, ) {

    val context = LocalContext.current val exoPlayer = remember(context) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(url)) prepare() seekTo(0L) play() } } AndroidView( factory = { PlayerView(context).apply { player = exoPlayer } }, modifier = modifier, ) } • Playerインスタンスの生成 + 動画再生画面の描画 for Android 動画を表示する(2/2) 97 3. 画像・動画の表示 androidMain/VideoPlayer.kt AndroidView( factory = { PlayerView(context).apply { player = exoPlayer } }, modifier = modifier, ) PlayerView を描画するAndroidViewを Compose Multiplatform上で表示 PlayerView には ExoPlayer のインスタンスを渡す
  83. • Playerインスタンスの生成 + 動画再生画面の描画 for iOS 動画を表示する(2/2) 98 3. 画像・動画の表示

    @Composable actual fun VideoPlayer( url: Uri, modifier: Modifier, ) { val nsUrl = NSURL.URLWithString(url.value) ?: return val player = remember { AVPlayer(uRL = nsUrl) } val playerViewController = remember { AVPlayerViewController() } playerViewController.player = player playerViewController.showsPlaybackControls = true UIKitView( factory = { playerViewController.view }, update = { player.play() }, onResize = { view: UIView, rect: CValue<CGRect> -> CATransaction.begin() CATransaction.setValue(true, kCATransactionDisableActions) view.layer.frame = rect CATransaction.commit() }, modifier = modifier, ) } iosMain/VideoPlayer.kt
  84. • Playerインスタンスの生成 + 動画再生画面の描画 for iOS 動画を表示する(2/2) 99 3. 画像・動画の表示

    @Composable actual fun VideoPlayer( url: Uri, modifier: Modifier, ) { val nsUrl = NSURL.URLWithString(url.value) ?: return val player = remember { AVPlayer(uRL = nsUrl) } val playerViewController = remember { AVPlayerViewController() } playerViewController.player = player playerViewController.showsPlaybackControls = true UIKitView( factory = { playerViewController.view }, update = { player.play() }, onResize = { view: UIView, rect: CValue<CGRect> -> CATransaction.begin() CATransaction.setValue(true, kCATransactionDisableActions) view.layer.frame = rect CATransaction.commit() }, modifier = modifier, ) } iosMain/VideoPlayer.kt val player = remember { AVPlayer(uRL = nsUrl) } val playerViewController = remember { AVPlayerViewController() } playerViewController.player = player playerViewController.showsPlaybackControls = true
  85. • Playerインスタンスの生成 + 動画再生画面の描画 for iOS 動画を表示する(2/2) 100 3. 画像・動画の表示

    @Composable actual fun VideoPlayer( url: Uri, modifier: Modifier, ) { val nsUrl = NSURL.URLWithString(url.value) ?: return val player = remember { AVPlayer(uRL = nsUrl) } val playerViewController = remember { AVPlayerViewController() } playerViewController.player = player playerViewController.showsPlaybackControls = true UIKitView( factory = { playerViewController.view }, update = { player.play() }, onResize = { view: UIView, rect: CValue<CGRect> -> CATransaction.begin() CATransaction.setValue(true, kCATransactionDisableActions) view.layer.frame = rect CATransaction.commit() }, modifier = modifier, ) } iosMain/VideoPlayer.kt val player = remember { AVPlayer(uRL = nsUrl) } val playerViewController = remember { AVPlayerViewController() } playerViewController.player = player playerViewController.showsPlaybackControls = true AVPlayer と AVPlayerViewController のインスタンスを生成 動画データのURLは NSURL 型に変換し、 AVPlayer に渡す
  86. • Playerインスタンスの生成 + 動画再生画面の描画 for iOS 動画を表示する(2/2) 101 3. 画像・動画の表示

    @Composable actual fun VideoPlayer( url: Uri, modifier: Modifier, ) { val nsUrl = NSURL.URLWithString(url.value) ?: return val player = remember { AVPlayer(uRL = nsUrl) } val playerViewController = remember { AVPlayerViewController() } playerViewController.player = player playerViewController.showsPlaybackControls = true UIKitView( factory = { playerViewController.view }, update = { player.play() }, onResize = { view: UIView, rect: CValue<CGRect> -> CATransaction.begin() CATransaction.setValue(true, kCATransactionDisableActions) view.layer.frame = rect CATransaction.commit() }, modifier = modifier, ) } iosMain/VideoPlayer.kt UIKitView( factory = { playerViewController.view }, update = { player.play() }, onResize = { view: UIView, rect: CValue<CGRect> -> /* Viewのサイズ調整 */ }, modifier = modifier, )
  87. • Playerインスタンスの生成 + 動画再生画面の描画 for iOS 動画を表示する(2/2) 102 3. 画像・動画の表示

    @Composable actual fun VideoPlayer( url: Uri, modifier: Modifier, ) { val nsUrl = NSURL.URLWithString(url.value) ?: return val player = remember { AVPlayer(uRL = nsUrl) } val playerViewController = remember { AVPlayerViewController() } playerViewController.player = player playerViewController.showsPlaybackControls = true UIKitView( factory = { playerViewController.view }, update = { player.play() }, onResize = { view: UIView, rect: CValue<CGRect> -> CATransaction.begin() CATransaction.setValue(true, kCATransactionDisableActions) view.layer.frame = rect CATransaction.commit() }, modifier = modifier, ) } iosMain/VideoPlayer.kt UIKitView( factory = { playerViewController.view }, update = { player.play() }, onResize = { view: UIView, rect: CValue<CGRect> -> /* Viewのサイズ調整 */ }, modifier = modifier, ) AVPlayerViewController に紐づくViewをUIKitViewに描画し Compose Multiplatform上で表示
  88. 動画を表示する 3. 画像・動画の表示 😇 ハマりポイント ➔ AVPlayerViewContollerのコントロールボタンを使って Compose Multiplatformの画面に戻ることができない AVPlayerViewContollerの

    showsPlaybackControls をfalseにすれば、 コントロールボタンを非表示にできる その上で、コントロールボタンの実装を Compose側にもたせることで回避可能👍 104
  89. ここまでのまとめ: Compose Multiplatformでできること 106 👍 モバイルアプリに求められる機能の大半が実装できる ➔ KMP対応のライブラリがJetBrains・Google・3rd Partyいずれも 充実してきており、アプリ開発へのハードルは想像以上に低い

    ➔ ライブラリの手が届かない箇所も、プラットフォームごとに自力で ロジックを実装することでカバーできる 👍 AndroidView・UIKit・SwiftUIとの相互運用性がある ➔ Android開発者なら「限界までKotlinで頑張る」、 iOS開発者なら「SwiftUIで書いて組み込む」等、選択の柔軟性が高い
  90. 結構できる!Compose Multiplatformだけど…つらみもある 108 😇 個別ロジックの実装がやっぱりつらい ➔ 慣れていないプラットフォームの実装は、特にエラーに遭遇した時の 引き出しが少なく、ハマった時の苦しみが長引きがち ➔ デファクト

    or 非推奨の情報のキャッチアップも大変 😇 フレームワークのアップデートがやっぱりつらい ➔ バージョンアップでビルドが通らなくなることがまだある Compose Multiplatform、何でもできるじゃないですか!!! こうなったらバシバシ使っていきましょうよ!!! あ〜〜〜そう思ってくれるのは嬉しいんですが…🫠
  91. 結構できる!Compose Multiplatformだけど…つらみもある 109 😇 直近困ったバージョンアップに関する問題 ➔ GradleのConvention Pluginsを利用していると、 Compose Multiplatform

    v1.6.10へのアップデートでビルドが落ちる ※Kotlinをv2.0.0に上げられないので、本当にこまる😫 ※v1.7.0-dev1686で解決 #4815 ➔ Desktop向けビルドがCompose ImageLoader v1.8.1で落ちる 日々進化を続けるフレームワークであるため、基本的に最新版を使うことが重要 しかし、同時に成熟しきっていない側面もあるため、バージョンアップによって それまで安定していた開発環境が一気に不安定になるリスクもはらむ
  92. つらみを乗り越える、Compose Multiplatformの強力な機能 112 🖋 UIテストコードの外観 class ExampleTest { @OptIn(ExperimentalTestApi::class) @Test

    fun myTest() = runComposeUiTest { setContent { var text by remember { mutableStateOf("Hello") } Text( text = text, modifier = Modifier.testTag("text") ) Button( onClick = { text = "Compose" }, modifier = Modifier.testTag("button") ) { Text("Click me") } } onNodeWithTag("text").assertTextEquals("Hello") onNodeWithTag("button").performClick() onNodeWithTag("text").assertTextEquals("Compose") } } 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation
  93. つらみを乗り越える、Compose Multiplatformの強力な機能 113 🖋 UIテストコードの外観 class ExampleTest { @OptIn(ExperimentalTestApi::class) @Test

    fun myTest() = runComposeUiTest { setContent { var text by remember { mutableStateOf("Hello") } Text( text = text, modifier = Modifier.testTag("text") ) Button( onClick = { text = "Compose" }, modifier = Modifier.testTag("button") ) { Text("Click me") } } onNodeWithTag("text").assertTextEquals("Hello") onNodeWithTag("button").performClick() onNodeWithTag("text").assertTextEquals("Compose") } } 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation Text( text = text, modifier = Modifier.testTag("text") ) Button( onClick = { text = "Compose" }, modifier = Modifier.testTag("button") ) { Text("Click me") } テスト対象のUI ボタンクリックによって、テキストが 「Hello」→「Compose」に変わる
  94. つらみを乗り越える、Compose Multiplatformの強力な機能 114 🖋 UIテストコードの外観 class ExampleTest { @OptIn(ExperimentalTestApi::class) @Test

    fun myTest() = runComposeUiTest { setContent { var text by remember { mutableStateOf("Hello") } Text( text = text, modifier = Modifier.testTag("text") ) Button( onClick = { text = "Compose" }, modifier = Modifier.testTag("button") ) { Text("Click me") } } onNodeWithTag("text").assertTextEquals("Hello") onNodeWithTag("button").performClick() onNodeWithTag("text").assertTextEquals("Compose") } } 出典: Testing Compose Multiplatform UI | Kotlin Multiplatform Development Documentation onNodeWithTag("text").assertTextEquals("Hello") onNodeWithTag("button").performClick() onNodeWithTag("text").assertTextEquals("Compose") 動作検証コード Finder・Assertion・Action・Matcher 全て本家Jetpack Composeと同じAPIで利用可能!🔥
  95. つらみを乗り越える、Compose Multiplatformの強力な機能 115 🖋 UIテストコードの実行コマンド ➔ Android: ./gradlew :connectedAndroidTest a

    ➔ iOS: ./gradlew :iosSimulatorArm64Test a ➔ 単一のテストクラスで Android・iOSそれぞれの 実行環境での 動作検証ができる!🔥
  96. つらみを乗り越える、Compose Multiplatformの強力な機能 116 🖋 UIテストコードの実行コマンド ➔ Android: ./gradlew :connectedAndroidTest a

    ➔ iOS: ./gradlew :iosSimulatorArm64Test a ➔ 単一のテストクラスで Android・iOSそれぞれの 実行環境での 動作検証ができる!🔥 Compose MultiplatformのUIテスト機能については とある勉強会で紹介したので、詳しくはこちらをチェック! 資料→ Spearker Deck
  97. つらみを乗り越える、Compose Multiplatformの強力な機能 117 🏃 実際にCI環境でテストを実行している様子 テストケース115件 通過114件 / スキップ1件 Presentation層は

    Compose MultiplatformのUIテスト 他の層はKotestでの単体テストでカバー 単一のテストで複数プラットフォームを カバーできるので、モチベも維持しやすい👍
  98. まとめ 119 🐥 Compose Multiplatformでは、モバイルアプリに求められる機能の 大半を実装することができる ➔ v1.0のリリースから2年半経過、安定性は日々高まっている ➔ ライブラリ資産やドキュメントも充実しつつある

    🐥 各プラットフォームとの相互運用性があり、実装の選択肢が多い ➔ 個人の習熟度や環境に合わせた、使い方のカスタマイズをしやすい 🐥 自動テストの機能により、不確実性をコントロールしやすい ➔ 単一のテストで複数プラットフォームの動作検証ができ、非常に強力
  99. まとめ 120 🐥 Compose Multiplatformでは、モバイルアプリに求められる機能の 大半を実装することができる ➔ v1.0のリリースから2年半経過、安定性は日々高まっている ➔ ライブラリ資産やドキュメントも充実しつつある

    🐥 各プラットフォームとの高い相互運用性があり、実装の選択肢が多い ➔ 個人の習熟度や環境に合わせた、使い方のカスタマイズをしやすい 🐥 自動テストの機能により、不確実性をコントロールしやすい ➔ 単一のテストで複数プラットフォームの動作検証ができ、非常に強力 実装例が増えれば、Compose Multiplatformが持つ力に 気づく人ももっと増えるはず! あなたのアイディアをCompose Multiplatformで形にして 広めることが、大きなKontributeにつながると思うので、 Compose Multiplatformでの開発にぜひ一度挑戦してみてください! Thank you for listening! Have a nice Kotlin with Compose Multiplatform!