Slide 1

Slide 1 text

あらゆるアプリを Compose Multiplatformで書きたい! - ネイティブアプリの「あの機能」を私たちはどう作るか - にしこりさぶろ〜(@subroh_0508) 2024/06/22 ルームB

Slide 2

Slide 2 text

自己紹介 2 【¥324,009】にしこりさぶろ〜 @subroh_0508 🎤経歴 1995年生まれ。東京の離島・伊豆大島出身。 Android / Webエンジニア(6年8ヶ月) → DevHR(人事職、1年5ヶ月) メインの技術スタックは Kotlin・Android・Rails・React。 初めて触ったKotlinは v1.0.0-rc-1036😎

Slide 3

Slide 3 text

TOKIUMの志 未 来 へ つ な が る 時 を 生 む 自己紹介 / 会社紹介 3 経理の領域でDXを実現するBtoB/SaaS 新卒から8年目、人事職2年目 ※このセッションは 個人開発での成果が メインとなります プロダクト本部 プロダクト組織開発部 DevHR 坂上 晴信

Slide 4

Slide 4 text

自己紹介 / あとはフル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製アプリ(のはず) 興味ある方はチェック!懇親会でもお見せするぞ😎

Slide 5

Slide 5 text

アジェンダ 5 1. Compose Multiplatformの概要 ➔ フレームワークの歴史 ➔ 現在用意されている、公式のリソース・ドキュメント 2. モバイルアプリ開発における頻出機能の実装方法の紹介 ➔ OAuthによるログイン、ローカルデータストレージへの認証情報保持 ➔ 画像・動画の表示、選択、アップロード 3. 開発におけるハマりどころの乗り越え方 ➔ テストコードの書き方、実行方法について ※時間に収まりませんでした CfPで楽しみにしていた方 もしいたらすいません😭

Slide 6

Slide 6 text

このセッションのゴール 6 🥅 実用的なモバイルアプリ開発に求められる機能について Compose Multiplatformでどのように実装するか理解する ➔ どこまで3rd Partyライブラリに頼れて、どこから自力実装が必要か ➔ Android / iOS固有のロジックを実装する上でのコツ 🥅 現在のCompose Multiplatformの実力について知る ➔ 具体的なアプリ実装のユースケースを通し、何ができるのか知る Compose MultiplatformでのX-Plat開発に挑戦したいみなさんの 背中を押せるようなセッションを目指します💪

Slide 7

Slide 7 text

1.Compose Multiplatformの概要 7

Slide 8

Slide 8 text

Compose Multiplatformの概要 8 ✔ Jetpack Composeの Kotlin Multiplatform対応版 ✔ iOS・Desktop・WebアプリのUIを Jetpack Composeと(ほぼ)同じAPIで 宣言的に実装することができる フレームワーク Compose Multiplatform UI フレームワーク | JetBrains JetBrains/compose-multiplatform

Slide 9

Slide 9 text

● 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

Slide 10

Slide 10 text

● 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の 安定版リリース前から開発

Slide 11

Slide 11 text

● 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もβ版まで到達😎

Slide 12

Slide 12 text

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〜 想像以上に色々できる!

Slide 13

Slide 13 text

Compose Multiplatformに関する公式リソース 13 📕 Kotlin Multiplatform Development ➔ Kotlin Multiplatformを使った開発に関する情報がまとまっている Compose Multiplatformに関する情報は “Create an app with shared logic and UI” ”Compose Multiplatform UI framework”以下 アプリのリリース方法についても サポートされている😎

Slide 14

Slide 14 text

とりあえずCompose Multiplatformで何か作ってみたい! 14 📕 以下のスライドを参考に ➔ Jetpack ComposeでAndroid/iOSアプリを作る @DroidKaigi 2023 ➔ 今こそ始めたい!Compose Multiplatform @Kotlin Fest 2024 Compose Multiplatformでのアプリ開発、最初の一歩は上記2つでOK👍 このセッションでは、最初の一歩の”先”にたどり着くための知見をまとめます

Slide 15

Slide 15 text

2.モバイルアプリ開発における 2.頻出機能の実装方法の紹介 15

Slide 16

Slide 16 text

subroh0508/noctiluca Compose Multiplatform、今どこまでやれるのか? 16 🔧 が現在開発中のMastodonクライアントアプリ「Noctilca」 💪実装済の機能 - OAuth認証によるログイン - 複数アカウントの認証情報 保持、切り替え - トゥートの閲覧、投稿 - ストリーミングの購読 (→タイムラインの自動更新) - 画像・動画のプレビュー - 画像・動画の選択、投稿

Slide 17

Slide 17 text

🔧 が現在開発中の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 各種ライブラリも充実!モダンで 実用的なアプリを実装できる💪

Slide 18

Slide 18 text

1. OAuth認証によるログイン 2. ローカルデータストレージへの認証情報保持 3. 画像・動画の表示 実装例を紹介する機能 18

Slide 19

Slide 19 text

1. OAuth認証によるログイン 2. ローカルデータストレージへの認証情報保持 3. 画像・動画の表示 実装例を紹介する機能 19

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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を取得

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

MastodonのOAuth認証の手順 26 1. OAuth認証によるログイン クライアント POST /api/v1/apps client_id, client_secret リダイレクト 認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 🤔特定のページをブラウザで開く 🤔リダイレクトをクライアントアプリで受け取る → Android / iOS固有のコードの実装が必要

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

● startActivity メソッド/ CustomTabsIntent クラスを使う val url: String = "https://…" CustomTabsIntent.Builder() .setShowTitle(true) .build() .launchUrl( LocalContext.current, Uri.parse(url), ) 特定のページをブラウザで開く 28 1. OAuth認証によるログイン CustomTabsIntentを使うパターン

Slide 29

Slide 29 text

● 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 と書くのがモダンらしい

Slide 30

Slide 30 text

特定のページをブラウザで開く 30 1. OAuth認証によるログイン ➔ UIApplication.sharedApplication.openURL メソッドを使う ➔ いずれのコードも 「URLを文字列で受け取り」「同期的に処理を実行する」 「ブラウザを開き」「何も返さないメソッド」としてまとめられる ● startActivity メソッド/ CustomTabsIntent クラスを使う

Slide 31

Slide 31 text

特定のページをブラウザで開く 31 1. OAuth認証によるログイン @Composable expect fun openBrowser(url: String) commonMain/Browser.kt KMPの共通コードに直すと、こんな感じ ➔ UIApplication.sharedApplication.openURL メソッドを使う ➔ いずれのコードも 「URLを文字列で受け取り」「同期的に処理を実行する」 「ブラウザを開き」「何も返さないメソッド」としてまとめられる ● startActivity メソッド/ CustomTabsIntent クラスを使う

Slide 32

Slide 32 text

特定のページをブラウザで開く 32 1. OAuth認証によるログイン ● Android向け → シンプルに実装 @Composable actual fun openBrowser(url: String) { CustomTabsIntent.Builder() .setShowTitle(true) .build() .launchUrl( LocalContext.current, Uri.parse(url), ) } androidMain/Browser.kt

Slide 33

Slide 33 text

● 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

Slide 34

Slide 34 text

● 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型から変換する必要がある

Slide 35

Slide 35 text

リダイレクトをクライアントアプリで受け取る 35 1. OAuth認証によるログイン 🤔 やりたいこと ➔ リダイレクトをクライアントアプリで受け取り、 アクセストークンの取得に必要な認可コードを取得する

Slide 36

Slide 36 text

リダイレクトをクライアントアプリで受け取る 36 1. OAuth認証によるログイン 🤔 やりたいこと ➔ リダイレクトをクライアントアプリで受け取り、 アクセストークンの取得に必要な認可コードを取得する ● Androidでの手順 ➔ AndroidManifest.xml に を追加 ➔ Activityの onCreate / onNewIntent メソッドにIntentが発行 ➔ Intentの getData メソッドから Uri 型のインスタンスを取得 ➔ Uri 型のインスタンスから認可コードを取得

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

リダイレクトをクライアントアプリで受け取る(1/3) 44 1. OAuth認証によるログイン ● リダイレクトを受け取る設定を追加する for Android ➔ AndroidManifest.xml に を追加 AndroidManifest.xml Custom Schemeの設定

Slide 45

Slide 45 text

● リダイレクトを受け取る設定を追加する for iOS ➔ info.plist にURL Schemesを設定 リダイレクトをクライアントアプリで受け取る(1/3) 45 1. OAuth認証によるログイン Xcodeで info.plist を開き、URL Schemeに Custom Schemeを設定

Slide 46

Slide 46 text

● リダイレクトのリクエストをコードで扱う ➔ リクエストから欲しい値を取得する @Composable メソッドを用意 リダイレクトをクライアントアプリで受け取る(2/3) 46 1. OAuth認証によるログイン @Composable expect fun HandleDeepLink() commonMain/HandleDeepLink.kt

Slide 47

Slide 47 text

リダイレクトをクライアントアプリで受け取る(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 { 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

Slide 48

Slide 48 text

@Composable actual fun HandleDeepLink() { val context = LocalContext.current val oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer { 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 { trySend(it) } activity?.addOnNewIntentListener(consumer) } androidMain/HandleDeepLink.kt

Slide 49

Slide 49 text

@Composable actual fun HandleDeepLink() { val context = LocalContext.current val oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer { 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 { trySend(it) } activity?.addOnNewIntentListener(consumer) } androidMain/HandleDeepLink.kt onNewIntent に反応して、発行された Intentを callbackFlow に流す

Slide 50

Slide 50 text

@Composable actual fun HandleDeepLink() { val context = LocalContext.current val oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer { 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

Slide 51

Slide 51 text

@Composable actual fun HandleDeepLink() { val context = LocalContext.current val oauthScheme = "oauth2redirect" LaunchedEffect(Unit) { callbackFlow { val activity = context as? ComponentActivity val consumer = Consumer { 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型で取得

Slide 52

Slide 52 text

● リダイレクトのリクエストをコードで扱う 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

Slide 53

Slide 53 text

@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

Slide 54

Slide 54 text

● リダイレクトのリクエストをコードで扱う リダイレクトをクライアントアプリで受け取る(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, ) })

Slide 55

Slide 55 text

● リダイレクトのリクエストをコードで扱う 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 を追加 コールバックメソッドにリクエストが流れる

Slide 56

Slide 56 text

● リダイレクトのリクエストをコードで扱う リダイレクトをクライアントアプリで受け取る(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の世界に情報を渡すコード(後述)

Slide 57

Slide 57 text

● リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 57 1. OAuth認証によるログイン private val deepLinkStateFlow = MutableStateFlow(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

Slide 58

Slide 58 text

● リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 58 1. OAuth認証によるログイン private val deepLinkStateFlow = MutableStateFlow(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() // ナビゲーションの処理

Slide 59

Slide 59 text

● リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 59 1. OAuth認証によるログイン private val deepLinkStateFlow = MutableStateFlow(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 から リダイレクトのリクエストを流す

Slide 60

Slide 60 text

● リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 60 1. OAuth認証によるログイン private val deepLinkStateFlow = MutableStateFlow(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) } }

Slide 61

Slide 61 text

● リダイレクトのリクエストをコードで扱う for iOS リダイレクトをクライアントアプリで受け取る(2/3) 61 1. OAuth認証によるログイン private val deepLinkStateFlow = MutableStateFlow(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 に渡すメソッド

Slide 62

Slide 62 text

● 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

Slide 63

Slide 63 text

リダイレクトをクライアントアプリで受け取る(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 型のインスタンスから認可コードを取得

Slide 64

Slide 64 text

● 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 型のインスタンスから取得したクエリ文字列 そのクエリ文字列から認可コードを取得 → 認可コードを利用し、アクセストークンを取得🎉

Slide 65

Slide 65 text

1. OAuth認証によるログイン 2. ローカルデータストレージへの認証情報保持 3. 画像・動画の表示 実装例を紹介する機能 65

Slide 66

Slide 66 text

2. ローカルデータストレージへの認証情報保持 66 クライアント POST /api/v1/apps client_id, client_secret リダイレクト 認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存

Slide 67

Slide 67 text

2. ローカルデータストレージへの認証情報保持 67 クライアント POST /api/v1/apps client_id, client_secret リダイレクト 認可コード 取得 POST /oauth/authorize 認可コード POST /oauth/token access_token トークン保存 ここをどうするか🤔

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

● Android ➔ DataStore ➔ SharedPreferences ● iOS ➔ UserDefaults ➔ NSUserDefaults 2. ローカルデータストレージへの認証情報保持 69 public interface DataStore { public val data: Flow 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の定義 (当然ながら)大きく異なる ラッパークラス作るの面倒くさい…😩

Slide 70

Slide 70 text

● Android ➔ DataStoreがKotlin Multiplatformに対応!(v1.1.0〜)😎 ➔ SharedPreferences ● iOS ➔ UserDefaults ➔ NSUserDefaults 2. ローカルデータストレージへの認証情報保持 70 参照: Android Developers Android / iOS共通のAPIで ローカルデータストレージを扱える😎

Slide 71

Slide 71 text

DataStoreを使ってトークンを保存する(1/3) 71 2. ローカルデータストレージへの認証情報保持 ● 依存関係を追加 commonMain { dependencies { implementation("androidx.datastore:datastore-core:1.1.0") implementation("androidx.datastore:datastore-preferences-core:1.1.0") } }

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

● DataStoreのインスタンスを生成 for iOS DataStoreを使ってトークンを保存する(2/3) 73 2. ローカルデータストレージへの認証情報保持 internal const val dataStoreFileName = "data.preferences_pb" fun createDataStore(): DataStore = 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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

DataStoreを使ってトークンを保存する(3/3) 76 2. ローカルデータストレージへの認証情報保持 ● トークンをDataStoreから読み込む、DataStoreに書き込む private val PREF_TOKEN = stringPreferencesKey("key_token") class TokenDataStore( private val dataStore: DataStore, ) { 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 メソッドを利用

Slide 77

Slide 77 text

1. OAuth認証によるログイン 2. ローカルデータストレージへの認証情報保持 3. 画像・動画の表示 実装例を紹介する機能 77

Slide 78

Slide 78 text

画像を表示する 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

Slide 79

Slide 79 text

画像を表示する 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 コンポーザブルを利用する

Slide 80

Slide 80 text

画像を表示する 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 コンポーザブルを利用する

Slide 81

Slide 81 text

画像を表示する 3. 画像・動画の表示 ● 画像データをImageコンポーザルが扱える形に変換するライブラリ ➔ Kamel: Kamel-Media/Kamel ➔ Compose ImageLoader: qdsfdhvh/compose-imageloader ➔ Coil (v3.0.0-alpha01〜): coil-kt/coil ※KMP対応Issue いずれも画像データの場所を問わず、イイ感じに読み込んでくれる👍 CoilのKMP対応が安定版に移行したらほぼ一択、自作のメリットは薄いかも👀

Slide 82

Slide 82 text

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を使ったオンライン上の画像表示

Slide 83

Slide 83 text

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を使ったオンライン上の画像表示

Slide 84

Slide 84 text

動画を表示する 3. 画像・動画の表示 👀 動画をイイ感じに表示するコンポーザブルは…用意されておらず

Slide 85

Slide 85 text

動画を表示する 3. 画像・動画の表示 😭 動画をイイ感じに表示するコンポーザブルは…用意されておらず ➔ 3rd Party製ライブラリにも、安心して使えるものがほぼない KMP対応の3rd Party製ライブラリが数多く紹介されているkmp-awesomeで 検索をかけても、1件しかヒットしない…😢(Stars 97)

Slide 86

Slide 86 text

動画を表示する 3. 画像・動画の表示 😭 動画をイイ感じに表示するコンポーザブルは…用意されておらず ➔ 3rd Party製ライブラリにも、安心して使えるものがほぼない ➔ Android / iOSそれぞれ個別でロジックを実装し、 「動画データのURLを受け取り」「動画を再生する」 @Composable メソッドにまとめればOK(のはず) 自作しましょう!!!👊

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

● 動画再生の仕組み for iOS ➔ AVPlayerViewController (iOS >= 8.0) ✅ ➔ VideoPlayer (iOS >= 14.0) ➔ オンライン上の動画データも、URLだけ渡せば読み込み可能🙆 ➔ ざっくり実装方針 Compose Multiplatformで実装したUIにUIKitViewを表示し、 UIKitViewの上で動画再生用のコンポーネントを描画 動画を表示する 90 3. 画像・動画の表示

Slide 91

Slide 91 text

動画を表示する(1/2) 91 3. 画像・動画の表示 ● URLを受け取り、動画を再生する @Composable メソッドを用意 @Composable expect fun VideoPlayer( url: String, modifier: Modifier = Modifier, ) commonMain/VideoPlayer.kt

Slide 92

Slide 92 text

● 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") } }

Slide 93

Slide 93 text

@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

Slide 94

Slide 94 text

@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() }

Slide 95

Slide 95 text

@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秒へ移動 → 再生を実行

Slide 96

Slide 96 text

@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, )

Slide 97

Slide 97 text

@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 のインスタンスを渡す

Slide 98

Slide 98 text

● 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 -> CATransaction.begin() CATransaction.setValue(true, kCATransactionDisableActions) view.layer.frame = rect CATransaction.commit() }, modifier = modifier, ) } iosMain/VideoPlayer.kt

Slide 99

Slide 99 text

● 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 -> 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

Slide 100

Slide 100 text

● 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 -> 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 に渡す

Slide 101

Slide 101 text

● 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 -> 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 -> /* Viewのサイズ調整 */ }, modifier = modifier, )

Slide 102

Slide 102 text

● 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 -> 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 -> /* Viewのサイズ調整 */ }, modifier = modifier, ) AVPlayerViewController に紐づくViewをUIKitViewに描画し Compose Multiplatform上で表示

Slide 103

Slide 103 text

動画を表示する 3. 画像・動画の表示 😎 ここまで実装すると… 103

Slide 104

Slide 104 text

動画を表示する 3. 画像・動画の表示 😇 ハマりポイント ➔ AVPlayerViewContollerのコントロールボタンを使って Compose Multiplatformの画面に戻ることができない AVPlayerViewContollerの showsPlaybackControls をfalseにすれば、 コントロールボタンを非表示にできる その上で、コントロールボタンの実装を Compose側にもたせることで回避可能👍 104

Slide 105

Slide 105 text

3.開発における 3.ハマりどころの乗り越え方 105

Slide 106

Slide 106 text

ここまでのまとめ: Compose Multiplatformでできること 106 👍 モバイルアプリに求められる機能の大半が実装できる ➔ KMP対応のライブラリがJetBrains・Google・3rd Partyいずれも 充実してきており、アプリ開発へのハードルは想像以上に低い ➔ ライブラリの手が届かない箇所も、プラットフォームごとに自力で ロジックを実装することでカバーできる 👍 AndroidView・UIKit・SwiftUIとの相互運用性がある ➔ Android開発者なら「限界までKotlinで頑張る」、 iOS開発者なら「SwiftUIで書いて組み込む」等、選択の柔軟性が高い

Slide 107

Slide 107 text

結構できる!Compose Multiplatformだけど… 107 Compose Multiplatform、何でもできるじゃないですか!!! こうなったらバシバシ使っていきましょうよ!!! あ〜〜〜そう思ってくれるのは嬉しいんですが…🫠

Slide 108

Slide 108 text

結構できる!Compose Multiplatformだけど…つらみもある 108 😇 個別ロジックの実装がやっぱりつらい ➔ 慣れていないプラットフォームの実装は、特にエラーに遭遇した時の 引き出しが少なく、ハマった時の苦しみが長引きがち ➔ デファクト or 非推奨の情報のキャッチアップも大変 😇 フレームワークのアップデートがやっぱりつらい ➔ バージョンアップでビルドが通らなくなることがまだある Compose Multiplatform、何でもできるじゃないですか!!! こうなったらバシバシ使っていきましょうよ!!! あ〜〜〜そう思ってくれるのは嬉しいんですが…🫠

Slide 109

Slide 109 text

結構できる!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で落ちる 日々進化を続けるフレームワークであるため、基本的に最新版を使うことが重要 しかし、同時に成熟しきっていない側面もあるため、バージョンアップによって それまで安定していた開発環境が一気に不安定になるリスクもはらむ

Slide 110

Slide 110 text

つらみを乗り越える、Compose Multiplatformの強力な機能 110 😣 アップデートのつらみを乗り越えたい…そうだ、テストを書こう!

Slide 111

Slide 111 text

つらみを乗り越える、Compose Multiplatformの強力な機能 111 👊 アップデートのつらみを乗り越えたい…そうだ、テストを書こう! ➔ v1.6.0から、Compose MultiplatformはUIテストを書ける! ➔ Kotlinで書いたUIテストコードを各プラットフォームの環境で 実行し、自動で動作検証をすることができる! 参照: Compose Multiplatform 1.6.0 |JetBrains Blog

Slide 112

Slide 112 text

つらみを乗り越える、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

Slide 113

Slide 113 text

つらみを乗り越える、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」に変わる

Slide 114

Slide 114 text

つらみを乗り越える、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で利用可能!🔥

Slide 115

Slide 115 text

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

Slide 116

Slide 116 text

つらみを乗り越える、Compose Multiplatformの強力な機能 116 🖋 UIテストコードの実行コマンド ➔ Android: ./gradlew :connectedAndroidTest a ➔ iOS: ./gradlew :iosSimulatorArm64Test a ➔ 単一のテストクラスで Android・iOSそれぞれの 実行環境での 動作検証ができる!🔥 Compose MultiplatformのUIテスト機能については とある勉強会で紹介したので、詳しくはこちらをチェック! 資料→ Spearker Deck

Slide 117

Slide 117 text

つらみを乗り越える、Compose Multiplatformの強力な機能 117 🏃 実際にCI環境でテストを実行している様子 テストケース115件 通過114件 / スキップ1件 Presentation層は Compose MultiplatformのUIテスト 他の層はKotestでの単体テストでカバー 単一のテストで複数プラットフォームを カバーできるので、モチベも維持しやすい👍

Slide 118

Slide 118 text

まとめ 118

Slide 119

Slide 119 text

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

Slide 120

Slide 120 text

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