TECH PLAY

Android

イベント

マガジン

技術ブログ

こんにちは。ファインディ株式会社でモバイルエンジニアをしている加藤です。 先日、「 React Native Lunch Talk ~いま選ばれる理由とアプリの現在地~ 」にて、「新規サービス開発におけるReact Nativeのリアル〜技術選定の裏側と実践的OSS活用〜」というテーマで登壇しました。 本記事は、その発表内容を改めてテックブログとして書き起こしたものです。 発表では時間の都合で駆け足になった部分や、質疑応答で答えきれなかった論点もあったため、本記事ではそのあたりも含めて踏み込んで書いています。 背景:Findy Events β版の開発 前回の記事 でも少し触れましたが、昨年、Findy初のモバイルアプリ「Findy Events」をα版としてAndroidアプリのみリリースしました。 現在はα版から得た学びをもとにUI・UXをフルリニューアルし、技術カンファレンス向けのiOS/Androidアプリとしてβ版のリリースを目指して開発を進めています。 Findy Eventsが長期的に目指しているのは、「カンファレンスでの学びとつながりを、その場限りで終わらせず継続的な成長の資産に変える」ことです。 β版では、その第一歩として、カンファレンス当日の体験をスムーズにする次のような機能を提供予定です。 QRコードを提示してのチェックイン 予約済みセッションやタイムテーブルなど、カンファレンス情報の閲覧 カンファレンス前日やセッション開始前に届くリマインダーPush通知 本記事では、そんな新規モバイルアプリの立ち上げにあたって、なぜReact Nativeを選ぶに至ったのか、そしてOSS選定や実装で直面したリアルな所感について紹介します。 前回の記事 では技術選定の結論だけを紹介しましたが、本記事ではその判断に至るまでの比較過程やOSS選定の裏側までを踏み込んで書いています。 背景:Findy Events β版の開発 React Nativeの技術選定の背景 OSSモジュールの選定と活用事例 独自選定の事例1:Sign in with Apple 独自選定の事例2:UIライブラリ React Nativeに対するリアルな所感 立ち上がりは、AI時代でも想像以上に苦労した OSS活用で感じたこと AI時代に、今からReact Nativeをやる意味 まとめ React Nativeの技術選定の背景 まず触れておきたいのは、「なぜ数ある選択肢の中からReact Nativeを選んだのか」という話です。 Findyにとって初のモバイルアプリ開発。しかも、技術選定を一から設計できる、エンジニア冥利に尽きる環境です。 ただ、自由度が高いということは、言い換えれば判断の重みも大きいということ。「モバイルアプリを開発する」と一言で言っても、その道筋はひとつではありません。 出発点として置いたのが、次の3つのアプローチの比較です。 最適な開発手法は、置かれた環境と考え方で変わる。これが私の基本スタンスです。 そして今回の前提は、「最小リソースで最速リリース」。この一点に照らして、まずCross Platformを選びました。 次に、Cross Platformの中から、国内外で実績が豊富なFlutterとReact Nativeの2つに絞って比較しました。 次の表は、iOSエンジニア出身である私の主観も踏まえて、2025年8月頃に整理したものです。 一見、表だけを見ればFlutterが優位に映ります。 正直なところ、私自身も最初はFlutterに馴染みを感じていました。 それでも最終的に選んだのは、React Nativeです。 決め手は、「組織のアセット」と「モバイルエンジニアとしての自身のナレッジ」の掛け算。この2軸をかけ合わせることこそ、「最小リソースで最速リリース」という目標に最短で近づく道だと確信しました。 仮にFlutterを選べば、私だけでなく将来参画するメンバー全員に一定の立ち上げコストがのしかかります。特にモバイルエンジニア出身でないメンバーほど、負担は大きい。 対してReact Nativeなら、ReactとTypeScriptの素養を持つメンバーが多い今の組織にそのまま馴染みます。 自分さえ立ち上がってしまえば、その後の開発速度は中長期的に最も出せる。これが最終的な決め手です。 具体的に、「組織のアセット」と「モバイルエンジニアとしての自身のナレッジ」についてですが、「組織のアセット」としては、 社内の優秀なWebフロントエンドエンジニアからReactやTypeScriptの知識提供、レビュー協力を得られる React製の既存プロダクトの設計思想やソースコードを参考にできる という期待感がありました。 また、「モバイルエンジニアとしての自身のナレッジ」として、 iOS/Androidのプラットフォーム特性への理解 プッシュ通知などのモバイル固有の課題への対応力 を活かすことができると考えていました。 「社内のWeb資産をどこまで活かすことができたのか?」という部分が気になる方もいらっしゃるかもしれません。結果としては、次の技術スタックのとおり、組織のアセットをフル活用することができました。 実は、アーキテクチャに関しても、既存のWebプロダクトをほぼ流用する形で開発しています。 つまり、設計思想や実装パターンまで踏み込んで参考にできるほど、React NativeはReactと親和性が高いということです。 OSSモジュールの選定と活用事例 続いて、OSSモジュールの選定についてです。 iOSエンジニア出身の私がReact Nativeに触れて最初に感じたのは、「とにかくOSSが豊富」ということ。 ただ、裏を返せば「多過ぎて、どれを選べば良いか迷う」という別の課題が立ち上がってきます。 そこで採った方針は、 Expo公式ドキュメント を「羅針盤」として活用すること。 Expo公式ドキュメントは非常に充実していて、Expo公式モジュールはもちろん、推奨される3rd Party OSSも明記されています。第一候補をここに置くだけで、選定コストは大きく圧縮できるというわけです。 もちろん、これで100%をカバーできるわけではありません。その場合は、プロダクトのコンテキストに合わせて独自に選びます。 ここからは、その独自選定した2つのOSSを紹介します。 独自選定の事例1:Sign in with Apple モバイルアプリの認証では、GitHub・Google・Appleの3つのOAuth認証を新規に導入する方針を採りました。中でもSign in with Appleは、iOSの審査要件として必須の機能です。 そこで選定軸に据えたのは次の2点です。 審査で認証ボタンのデザインも厳格に確認されるため、iOS SDK標準の ASAuthorizationAppleIDButton を内部で利用していること iOSだけでなくAndroidでもSign in with Apple機能を提供できること Expo公式には expo-apple-authentication があるものの、Androidが対象外。今回は見送りました。 最終的に選んだのは、両OSに対応した react-native-apple-authentication です。 独自選定の事例2:UIライブラリ 冒頭で述べた通り、自社初のモバイルアプリということもあり、社内にモバイル用のデザイン資産はありません。「最小リソースで最速リリース」を実現するには、UIライブラリの活用が欠かせない要素になります。 選定軸に据えたのは次の2点です。 豊富なUIコンポーネントが提供されていること iOSエンジニア出身の自分にとって、学習コストが高すぎないこと 実はα版では Tamagui を採用していました。ただ、β版でUI・UXをフルリニューアルすることが決まり、より要件に合致するライブラリを改めて探すことに。 たどり着いたのが、選定軸2点をしっかり満たす HeroUI Native です。 ここで一つ頭を悩ませたのが、採用を決めた当時(2026年1〜2月頃)のHeroUI Nativeがまだβ版だったこと。利用によって課題が顕在化するリスクを抱えての判断になります(※現在はstable版が提供されています)。 そこで採った工夫が、「HeroUI NativeのWrapper Componentを実装し、画面側からは直接HeroUI Nativeに触れない構成」にすること。 影響範囲をWrapper層に閉じ込めておけば、将来の差し替えや仕様変更への耐性が確保できる。β版OSSを採用する際のリスクヘッジとして、有効な型の一つだと感じています。 React Nativeに対するリアルな所感 立ち上がりは、AI時代でも想像以上に苦労した React Nativeを選んだ結果として率直に感じたのは、「AI時代と言えど立ち上がりにはそれなりに苦労した」ということ。 実は10年ほど前に少しだけReactに触れたことがあったのですが、React HooksやLifecycleに相当する考え方はほぼ初学者の状態。概念の再学習が必要でした。 また、TypeScriptも「Swiftと似て非なるもの」であることを痛感します。 型による安全性という思想は近いものの、 as によるキャストや enum がベストプラクティスとしては非推奨とされている点など、Swift感覚で書くと足をすくわれる場面が少なくありません。 一方で、React Native自体に対するハードルはそれほど感じません。理由は次の2つです。 宣言的UIによるUI構築は、SwiftUIで経験していた プッシュ通知など、モバイル固有機能の仕組みそのものを理解していた LT会では「どうやってReact Nativeを勉強したか?」という質問もいただきました。取ったアプローチは、過去にSwiftで開発していた個人アプリをReact Nativeで書き直してみるというもの。 題材を一から考えずに済む上に、Swift版という答え合わせの対象がある状態で進められるため、SwiftUIとの共通点や差分を体感しながら学べます。 こうして実感できたのは、React Nativeを選んでも、モバイルエンジニアとしての経験や強みは十分に活かせるということです。 OSS活用で感じたこと OSS活用の面でも、幾つか印象的な気付きがあります。 1つは、OSSの更新速度の速さです。 React / React Native界隈は更新サイクルが早く、iOS Nativeとの文化の違いを肌で感じます。 加えて、脆弱性検知などのエコシステムが整っており、開発者体験として足かせになっていない点も心強いところです。 もう1つは、Expoの利便性への驚きです。 ExpoはReact Native界隈ではデファクトスタンダードと言える立場を確立しており、信頼性も高く、証明書やリリース周りといった「モバイルアプリ開発ならでは」の知識を手厚くカバーしてくれます。 これは、Webフロントエンドエンジニアがモバイルアプリ開発に参入するハードルを相当下げていると言って良いでしょう。 実際、Webフロントエンドエンジニアの方から「React Nativeって実際どうなの?」と聞かれた際は、Expoのおかげで「モバイルアプリ開発は想像以上に始めやすい」と答えることができています。 AI時代に、今からReact Nativeをやる意味 最後に登壇の中では時間の都合で踏み込めなかった論点について書いておきます。 それは、「AI時代に、今からReact Nativeをやることに意味はあるのか?」という問いです。 個人的には、少なくとも次の3点で意味があると考えています。 1つ目は、iOSとAndroidを同時に立ち上げる中で、OS間の差分に向き合う経験が積めることです。 Cross Platformと言えど、プッシュ通知や認証、OSの作法といった領域では差分が必ず顔を出します。 AIに任せれば書ける時代だからこそ、差分の勘所を自分の中に持っているかどうかが効いてくると考えています。 2つ目は、アーキテクチャ選定やOSS選定といった、プロダクトの土台を作る判断を経験できることです。 ゼロからモバイルアプリを立ち上げる機会はそう多くなく、「何を選んで、何を選ばなかったか」を自分の言葉で語れるようになる経験は、AI時代でも色褪せにくい資産だと感じています。 3つ目は、Webフロントエンドへの足掛かりになることです。 React NativeでReactやTypeScriptを書いている時間は、そのままWebフロントエンドの学習コストを前払いしていると言えるかもしれません。 そのため、モバイルエンジニアとしてWebフロントエンドへ領域を広げたい人にとって、React Nativeは自然な入り口になると思います。 (もちろん、Webフロントエンドエンジニアとして、モバイルアプリへ領域を広げたい人にとっても同様です。) まとめ Findy初のモバイルアプリ開発を通じて、React NativeやOSSの選定と実装で多くの知見を得ることができました。 初めは慣れないReact Nativeに四苦八苦することもありましたが、一度慣れてしまえば、モバイルエンジニアとしての強みを存分に活かすことができると実感しました。 AI時代においては、人がガードレールとしてソフトウェアの責任を背負う場面がますます増えていきます。 その観点で見ても、React Nativeは「AI時代に、人が責任を取れる技術」として十分に選定に耐えうると考えています。 モバイルアプリの技術選定に迷っている方や、iOS/Androidの経験を活かしてReact Nativeに踏み出そうとしている方は、ぜひ一度触ってみてください。 本記事がその一歩を踏み出すきっかけになれば幸いです。 ファインディでは一緒に会社を盛り上げてくれるメンバーを募集中です。興味を持っていただいた方はこちらのページからご応募お願いします。 herp.careers
Appium × Remote Test Kit (RTK) でスマホアプリ試験の自動化スクリプトを構築した話 背景:手動試験の限界 手動試験の問題点 試験の目的は、画面UI崩れの確認、異常系・正常系の機能動作確認です。 そのため、画面遷移・入力操作・結果表示の確認・エラーハンドリングの確認など、一連の操作を実施する必要がありました。 しかし、手動試験には以下のような課題がありました。 1端末あたり約80分の作業時間が必要 作業者依存による操作ミスや証跡取得漏れ スクリーンショット取得後の移動・リネーム・管理の手間 再現性の担保が困難(同じ操作を別の作業者や新規参画者が完全
はじめに KINTOテクノロジーズの大沼です。 モビリティサービス「my route」アプリの開発に従事しています。 本記事では、AndroidのKeystore、Cipher、DataStoreを使用して秘匿情報の暗号化と永続化を実装した際の実装詳細とハマった点・注意点をまとめました。 こちら大杉さんの記事 では、Tink を使用したケースとパフォーマンス検証を紹介しているのでぜひご一読ください。 💬 実装の前にディスカッション 🔍 本当に暗号化が必要なのか DroidKaigi 2025のyanzamさんのお話 でも触れられてましたが、Keystore がクラッシュするのは黙認しつつ最低限の機会頻度に抑えたいので、既存で暗号化しているデータが本当に暗号化する必要があるのかの議論をチームメンバーと交わしました。 案の定、不要に暗号化しているものもあり、議論することで最適なものに絞ることができました。 🏗️ アーキテクチャ セキュリティに関するリファクタリングのコードレビューは、心理負荷が高いと考えています。 私のチームはこういう時、大枠の実装方針を事前に共有し合うことで、コードレビュー時の認識違いや負担が減らせます。 今回、データの暗号化とインターフェースを以下のようなスライドで共有し、大きな齟齬なくレビューを進めることができました。 🛠️ 実装の流れ ここからは、実際の実装手順を以下の流れで解説します。 依存関係の追加 — DataStoreライブラリの導入 Keystoreを使った暗号化キーの生成 — AES/GCMの鍵をAndroid Keystoreで安全に管理 Cipherを使った暗号化・復号化 — 初期化ベクトル(IV)の扱いを含む暗号処理の実装 DataStoreへの保存 — 暗号化したデータをPreferences DataStoreで永続化し、Flowで読み出す 📚 依存関係の追加 ライブラリにDataStoreを追加します。 dependencies { // DataStore implementation("androidx.datastore:datastore-preferences:1.1.7") } 🔑 Keystoreを使った暗号化キーの生成 import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.security.KeyStore import javax.crypto.KeyGenerator ... fun getOrCreateSecretKey(): SecretKey? { try { // KeyStoreのインスタンス生成 val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE_PROVIDER).apply { load(null) // KeyStoreを初期化するための必須の呼び出し } // KeyStoreにプロダクトの鍵が存在するか確認し、あれば取得し返す if (keyStore.containsAlias(PROJECT_KEY_STORE_ALIAS)) { val entry = keyStore.getEntry(PROJECT_KEY_STORE_ALIAS, null) if (entry is KeyStore.SecretKeyEntry) { return entry.secretKey } } // KeyStoreにプロダクトの鍵が存在しなければ生成して保存し返す val params = KeyGenParameterSpec.Builder( PROJECT_KEY_STORE_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) .build() val keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE_PROVIDER, ) keyGenerator.init(params) return keyGenerator.generateKey() } catch (e: Exception) { Firebase.crashlytics.recordException(e) return null } } 🔐 Cipherを使った暗号化・復号化 import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase import java.util.Base64 import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec interface CryptographyManager { fun encrypt(plaintext: String): String fun decrypt(encryptedString: String): String } private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val IV_SIZE_BYTES = 12 private const val TAG_SIZE_BITS = 128 class CryptographyManagerImpl : CryptographyManager { override fun encrypt(plaintext: String): String { return try { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) val ivAndCiphertext = cipher.iv + ciphertext // IVと暗号文をバイト配列として結合 Base64.getEncoder().encodeToString(ivAndCiphertext) } catch (e: Exception) { try { CryptographyException.parse(e) } catch (cryptoException: CryptographyException) { Firebase.crashlytics.recordException(cryptoException) "" } } } override fun decrypt(encryptedString: String): String { return try { val cipher = Cipher.getInstance(TRANSFORMATION) val ivAndCiphertext = Base64.getDecoder().decode(encryptedString) // 復号化時に保存したIVを使う val spec = GCMParameterSpec(TAG_SIZE_BITS, ivAndCiphertext, 0, IV_SIZE_BYTES) cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), spec) val plaintext = cipher.doFinal( ivAndCiphertext, IV_SIZE_BYTES, ivAndCiphertext.size - IV_SIZE_BYTES, ) String(plaintext, Charsets.UTF_8) } catch (e: Exception) { try { CryptographyException.parse(e) } catch (cryptoException: CryptographyException) { Firebase.crashlytics.recordException(cryptoException) "" } } } } 💾 DataStoreへの保存 import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map data class SecureDataPreferences( val textData: String, ) object PreferencesKeys { private val TEXT_KEY = stringPreferencesKey("encrypted_text") } private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "encrypted_prefs") class SecureDataRepository( private val cryptographyManager: CryptographyManager ) { suspend fun saveTextData(data: String) { val encryptedData = cryptographyManager.encrypt(data) dataStore.edit { preferences -> preferences[TEXT_KEY] = encryptedData } } private val secureDataFlow: Flow<SecureDataPreferences> = secureDataStore.data .catch { exception -> if (exception is IOException) { emit(emptyPreferences()) } else { throw exception } } .map { it.mapSecureDataPreferences() } private fun Preferences.mapSecureDataPreferences(): SecureDataPreferences { return SecureDataPreferences( textData = this[PreferencesKeys.TEXT_KEY]?.let { cryptographyManager.decrypt(it) } ?: "", // ... Other data ) } suspend fun getTextData(): String { return try { withTimeout(3000L) { secureDataFlow.map { it.textData }.first { it.isNotBlank() } } } catch (_: TimeoutCancellationException) { "" } catch (_: NoSuchElementException) { "" } } } ⚠️ ハマった点・注意点 1. 初期化ベクトル(IV)の保存 暗号化時に生成されるIV(Initialization Vector)は、復号化時に必須です。 IVは暗号文と一緒に保存する必要があります。IVは秘密情報ではないため、平文で保存しても問題ありません。 ハマったポイント: 最初の実装でIVを保存し忘れ、復号化時に javax.crypto.AEADBadTagException が発生しました。 2. KeyStoreのキーのライフサイクル Android Keystoreに保存されたキーは、アプリをアンインストールすると削除されます。 また、デバイスのロック画面が解除されるまでキーにアクセスできない設定も可能です( setUserAuthenticationRequired(true) )。 注意点: keyが存在しない場合の処理を適切に実装する必要があります。 3. GCMモードのタグ長 GCM(Galois/Counter Mode)を使用する場合、タグ長を正しく設定する必要があります。 一般的には128ビット(16バイト)が使用されます。 4. エラーハンドリング 復号化時にはさまざまなエラーが発生する可能性があります: KeyPermanentlyInvalidatedException : キーが無効化された AEADBadTagException : 暗号文が改ざんされた、またはIVが間違っている InvalidKeyException : キーが無効 これらのエラーをハンドリングし、必要に応じてデータをクリアし再生成するなどの対応が必要です。 5. DataStoreの非同期処理 DataStoreはすべての操作が非同期で行われます。 CoroutineまたはFlowを使用して適切に処理する必要があります。 DataStoreのソースコード内で、最新の値を1つだけ取得できる data.first() を使用することを推奨しています。 // ViewModelでの使用例 viewModelScope.launch { repository.saveTextData(sensitiveData) } // Flowの監視 repository.secureDataFlow.map { it.textData }.first { it.isNotBlank() } 6. 無限待機の防止 DataStoreはディスクI/Oを伴う非同期処理です。first { it.isNotBlank() } は条件に一致する値が来るまで無限に待機しますが、 もしディスク読み込みが遅延したり、トークンが空のままだと、アプリがフリーズする可能性があり、タイムアウトを追加しました。 7. ProGuard/R8の設定 DataStore 1.1.5以降では、ProGuardルールがライブラリに内包されています。 巷の記事で ProGuardルール の記載が必要なことを目の当たりにしましたが、ルール記載なくリリースビルドしたところクラッシュせず、なぜ? となっていたところ、リリースノート確認し気づきました。 今回、1.1.7 を使用しているため、DataStore専用のProGuardルールを追加する必要はありません。 https://developer.android.com/jetpack/androidx/releases/datastore バージョン1.2.0-beta01で修正された問題として記載: "Fix java.lang.UnsatisfiedLinkError when using DataStore in an app which is optimized with R8" バージョン1.1.5で修正: "missing Proguard rules issue in the Android artifact of datastore-preferences-core" 8. 標準のSharedPreferencesMigrationが使えない EncryptedSharedPreferencesは特殊な暗号化を使用していて、 標準のマイグレーションでは暗号化されたままのデータが転送される また、EncryptedSharedPreferencesは 読み取り時に自動復号化されるのに対し、 今回は CryptographyManagerによる手動暗号化が必要です。 この部分を認知することができず、標準の標準のSharedPreferencesMigrationで実装し、テストしたところ復号できず判明しました。 マイグレーション時に適切な暗号化変換を実装しました。 まとめ 本記事では、Android Keystore、Cipher、DataStoreを組み合わせた秘匿情報の暗号化・永続化の実装について紹介しました。 実装前のディスカッションが重要 : そもそも暗号化が必要なデータかをチームで議論することで、Keystoreへのアクセス頻度を最小限に抑えられた Keystoreの鍵管理 : AES/GCMモードでの鍵生成とIV(初期化ベクトル)の保存を適切に行う必要がある DataStoreとの組み合わせ : Flowベースの非同期読み出しに対応するため、タイムアウトや無限待機の防止策が必要 EncryptedSharedPreferencesからの移行 : 標準のSharedPreferencesMigrationでは暗号化方式の違いにより復号できないため、手動でのマイグレーション実装が必要 Keystoreのクラッシュは完全には避けられませんが、暗号化対象を最適化し、エラーハンドリングを適切に実装することで、安定したセキュアなデータ管理を実現できました。 📣 追記: DataStore 1.3.0-alpha07 で暗号化サポートが追加 本記事の執筆後、 DataStore 1.3.0-alpha07 (2026年3月11日リリース)で、 Tinkライブラリを使用した暗号化サポート が新たに追加されました。 新しい androidx.datastore:datastore-tink アーティファクトにより、 AeadSerializer を使って既存のシリアライザをラップするだけで暗号化が実現できます。 val aeadSerializer = AeadSerializer( aead = keysetHandle.getPrimitive( RegistryConfiguration.get(), Aead::class.java, ), wrappedSerializer = ExistingSerializer, associatedData = "settings.json".encodeToByteArray(), ) 本記事で紹介したKeystore + Cipher による手動実装と比較すると、Tink統合によりボイラープレートが大幅に削減されます。ただし、現時点ではalpha版であるため、プロダクション導入の際はAPIの安定性を考慮する必要があります。今後のstable版リリースに注目です。 参考資料 Android Keystore System Jetpack DataStore 暗号化されたファイルの使用 Android セキュリティのベスト プラクティス DataStore 1.3.0-alpha07 リリースノート

動画

書籍