Rork フレームワークを使った iOS アプリ開発は、公式のチュートリアルでは説明されていない多くの課題に直面します。スケーラブルなアーキテクチャ、クラッシュの監視、一覧画面のパフォーマンス、そして審査と配信の運用が、本番環境では必須になります。
実装規模で検証されたパターンを順を追って整理していきます。
アーキテクチャ設計: MVVMパターン
Rorkアプリのスケーラビリティはアーキテクチャ設計にかかっています。
// ViewModel
class ProductListViewModel {
@Published var products: [Product] = []
@Published var isLoading = false
@Published var error: Error ?
private let service: ProductService
init ( service : ProductService) {
self .service = service
}
@MainActor
func loadProducts () async {
isLoading = true
defer { isLoading = false }
do {
products = try await service. fetchProducts ()
} catch {
self . error = error
}
}
}
// View
struct ProductListView : View {
@StateObject var viewModel: ProductListViewModel
var body: some View {
List (viewModel.products) { product in
ProductRow ( product : product)
}
. task {
await viewModel. loadProducts ()
}
}
}
パフォーマンス最適化
大規模アプリのパフォーマンスはメモリ管理と最適化にかかっています。
// メモリ効率的なリスト表示
struct OptimizedProductList : View {
@StateObject var viewModel: ProductListViewModel
var body: some View {
List {
ForEach (viewModel.products, id : \.id) { product in
ProductRow ( product : product)
. onAppear {
viewModel. prefetchProduct ( after : product)
}
}
}
. onDisappear {
viewModel. cancelPrefetch ()
}
}
}
// イメージキャッシング
class ImageCache {
static let shared = ImageCache ()
private var cache: NSCache<NSString, UIImage> = NSCache ()
func image ( for url: URL) -> UIImage ? {
return cache. object ( forKey : url.absoluteString as NSString)
}
func setImage ( _ image: UIImage, for url: URL) {
cache. setObject (image, forKey : url.absoluteString as NSString)
}
}
テスト戦略
プロダクション品質を確保するテスト戦略が必須です。
// Unit Tests
@MainActor
class ProductListViewModelTests : XCTestCase {
var viewModel: ProductListViewModel !
var mockService: MockProductService !
override func setUp () {
super . setUp ()
mockService = MockProductService ()
viewModel = ProductListViewModel ( service : mockService)
}
func testLoadProducts () async {
mockService.products = [
Product ( id : 1 , name : "Product 1" ),
Product ( id : 2 , name : "Product 2" )
]
await viewModel. loadProducts ()
XCTAssertEqual (viewModel.products. count , 2 )
XCTAssertFalse (viewModel.isLoading)
}
func testLoadProductsError () async {
mockService.shouldFail = true
await viewModel. loadProducts ()
XCTAssertNotNil (viewModel. error )
XCTAssertTrue (viewModel.products. isEmpty )
}
}
ネットワーク管理
信頼性高いネットワーク処理が重要です。
class ProductService {
func fetchProducts () async throws -> [Product] {
var request = URLRequest ( url : URL ( string : "https://api.example.com/products" ) ! )
request.timeoutInterval = 10
let (data, response) = try await URLSession.shared. data ( for : request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
switch httpResponse.statusCode {
case 200 :
return try JSONDecoder (). decode ([Product]. self , from : data)
case 404 :
throw NetworkError.notFound
case 500 :
throw NetworkError.serverError
default:
throw NetworkError.unknown
}
}
}
ローカルストレージ
データ永続化の実装パターン。
class DataPersistence {
static let shared = DataPersistence ()
func save < T : Codable >( _ object: T, for key: String ) {
let encoder = JSONEncoder ()
if let encoded = try? encoder. encode (object) {
UserDefaults.standard. set (encoded, forKey : key)
}
}
func load < T : Codable >( _ type: T. Type , for key: String ) -> T ? {
guard let data = UserDefaults.standard. data ( forKey : key) else {
return nil
}
let decoder = JSONDecoder ()
return try? decoder. decode (T. self , from : data)
}
}
Rork が生成するのは Expo / React Native アプリです
ここまでのコードはネイティブ(Swift / SwiftUI)の例ですが、Rork が書き出すのは Expo マネージドの React Native アプリです。つまり多くのアプリは、上のような Swift を書かずにそのまま配信します。ネイティブのパターンが効いてくるのは、expo prebuild や config plugin でネイティブモジュールを足したり、処理の重い一部だけをネイティブで作り直すときです。
逆に言うと、本番リリースで最初につまずくのは Swift のアーキテクチャではなく、Expo アプリ特有の運用と審査まわりであることがほとんどです。ここからは、公式の入門では触れられない、実際に審査とユーザーの不具合報告を往復しながら学んだ部分を整理します。
クラッシュと白画面を本番で「見える化」する
個人開発でいちばん怖いのは、ユーザーの端末でだけ落ちていて、自分の手元では再現しないクラッシュです。手元のシミュレータは何度起動しても平気なのに、ストアのレビュー欄に「開いてもすぐ閉じる」と書かれている。あの距離を埋めてくれるのが、本番に仕込んだクラッシュ監視です。
Expo なら Sentry の公式プラグインを app.json に足すだけで、ネイティブクラッシュも JavaScript の例外もまとめて収集できます。
{
"expo" : {
"plugins" : [
[
"@sentry/react-native/expo" ,
{
"organization" : "your-org" ,
"project" : "your-app"
}
]
]
}
}
初期化はアプリのエントリーポイントで一度だけ行います。tracesSampleRate を 1.0 のまま出すと量が膨らむので、本番は絞っておくのが現実的です。
// App.tsx / app/_layout.tsx
import * as Sentry from "@sentry/react-native" ;
Sentry. init ({
dsn: process.env. EXPO_PUBLIC_SENTRY_DSN ,
// 本番は 0.1〜0.2 程度に。開発中だけ 1.0 にして挙動を確認する
tracesSampleRate: __DEV__ ? 1.0 : 0.2 ,
// OTA の更新 ID を載せておくと「どのバージョンで落ちたか」が一発で分かる
enableNativeCrashHandling: true ,
});
export default Sentry . wrap ( App ) ;
React Native で多い「真っ白な画面」は、多くの場合レンダリング中に投げられた例外がそのまま画面を消してしまう状態です。Error Boundary で受け止めて、ユーザーには再試行ボタンを、開発者には Sentry へ報告を送ります。
import React from "react" ;
import { View, Text, Pressable } from "react-native" ;
import * as Sentry from "@sentry/react-native" ;
class AppErrorBoundary extends React . Component <
{ children : React . ReactNode },
{ hasError : boolean }
> {
state = { hasError: false };
static getDerivedStateFromError () {
return { hasError: true };
}
componentDidCatch ( error : Error , info : React . ErrorInfo ) {
// 白画面のまま放置せず、必ず本番に飛ばす
Sentry. captureException (error, { extra: { componentStack: info.componentStack } });
}
render () {
if ( this .state.hasError) {
return (
< View style = { { flex: 1 , alignItems: "center" , justifyContent: "center" , padding: 24 } } >
< Text style = { { fontSize: 16 , marginBottom: 16 } } >
一時的に表示できませんでした
</ Text >
< Pressable onPress = { () => this . setState ({ hasError: false }) } >
< Text style = { { color: "#2563eb" } } >もう一度試す</ Text >
</ Pressable >
</ View >
);
}
return this .props.children;
}
}
export default AppErrorBoundary;
ここで大事なのは、JavaScript 側のクラッシュなら OTA(expo-updates)で当日中に塞げるという点です。Sentry で原因の行が分かったら、JS を直して eas update を打つ。ネイティブの再ビルドと審査を待たずに、落ちている読者を救えます。クラッシュ監視と OTA はセットで初めて意味を持ちます。
一覧画面が重くならない作り方
レビューで「カクつく」「スクロールで固まる」と書かれるアプリの多くは、一覧画面が原因です。React Native で長いリストを出すときは、ScrollView に map で並べるのではなく FlatList を使い、再レンダリングと画像読み込みを抑えます。
import { FlatList } from "react-native" ;
import { Image } from "expo-image" ;
import { memo, useCallback } from "react" ;
// 行コンポーネントは memo 化して、無関係な更新で再描画させない
const ProductRow = memo ( function ProductRow ({ item } : { item : Product }) {
return (
< Image
source = { item.imageUrl }
// expo-image はディスクキャッシュを内蔵。再表示が速く、メモリも安定する
cachePolicy = "memory-disk"
style = { { width: "100%" , height: 200 } }
contentFit = "cover"
transition = { 150 }
/>
);
});
export function ProductList ({ products } : { products : Product [] }) {
const renderItem = useCallback (
({ item } : { item : Product }) => < ProductRow item = { item } />,
[]
);
return (
< FlatList
data = { products }
keyExtractor = { ( item ) => String (item.id) }
renderItem = { renderItem }
// 画面外の行を間引いてメモリを抑える
removeClippedSubviews
initialNumToRender = { 8 }
maxToRenderPerBatch = { 8 }
windowSize = { 7 }
/>
);
}
ポイントは3つです。行を memo で包んで親の更新から切り離すこと、画像は expo-image に任せてキャッシュとデコードを最適化させること、そして initialNumToRender と windowSize で一度に持つ行数を絞ること。壁紙アプリのように一覧に大きな画像が並ぶ場合、この3点だけで体感のなめらかさがはっきり変わります。
行の高さが一定なら getItemLayout を足すと、スクロール位置の計算が省けてさらに軽くなります。逆に高さが可変なら無理に付けず、removeClippedSubviews に任せるほうが安全です。
eas submit と App Store Connect の往復を減らす
ビルドができても、提出の段で止まると一日が溶けます。eas submit の設定を eas.json に固定しておくと、コマンド一発で TestFlight まで届きます。
{
"submit" : {
"production" : {
"ios" : {
"appleId" : "you@example.com" ,
"ascAppId" : "1234567890" ,
"appleTeamId" : "ABCDE12345"
}
}
}
}
# ビルドして、そのまま提出まで流す
eas build --platform ios --profile production
eas submit --platform ios --profile production --latest
App Store Connect 側でつまずきやすいのが、暗号化に関する申告(Export Compliance)です。HTTPS しか使っていなくても毎回ダイアログが出ますが、app.json に一行入れておけば提出のたびの手作業が消えます。
{
"expo" : {
"ios" : {
"config" : {
"usesNonExemptEncryption" : false
}
}
}
}
TestFlight の内部テスターに自分自身を入れておくと、提出後すぐ実機で最終確認できます。私はストア審査に出す前に、必ず一度 TestFlight 経由で初回起動とプッシュ通知の許可ダイアログだけは見るようにしています。シミュレータでは出ない権限文言が、実機で初めて空欄だと気づくことがあるからです。
リリース前チェックリスト
提出ボタンを押す前に、ここを声に出して一つずつ確認します。一度の往復を減らすだけで、リリースのリズムは目に見えて軽くなります。
確認項目 場所 つまずくと起きること
ATT 説明文(広告を入れる場合) app.json の ios.infoPlist Guideline 5.1.1 で差し戻し
カメラ・写真などの権限文言 app.json の ios.infoPlist 空文字のままだとリジェクト
runtimeVersion を appVersion に固定 app.json 古いバイナリに新 JS が落ちて起動クラッシュ
ビルド番号の自動採番 eas.json の production 番号重複で提出が弾かれる
暗号化の申告(usesNonExemptEncryption) app.json の ios.config 提出のたびに手作業ダイアログ
クラッシュ監視の初期化 エントリーポイント 本番の白画面に気づけない
TestFlight で初回起動を実機確認 App Store Connect 権限文言の空欄を見落とす
本番前に必ず潰す iOS 固有のつまずき
チェックリストのうち、特に踏みやすい4点を補足します。
1. AdMob を入れるなら ATT 文言が必須
広告(AdMob)を入れる場合、App Tracking Transparency の説明文(NSUserTrackingUsageDescription)が無いと審査で止まります。Expo では app.json の ios.infoPlist に直接書きます。
{
"expo" : {
"ios" : {
"infoPlist" : {
"NSUserTrackingUsageDescription" : "広告の最適化のために、許可をいただいた場合のみ利用状況を計測します。"
}
}
}
}
文言が空だったり英語だけだと Guideline 5.1.1 で差し戻されることがあります。日本語アプリなら日本語の説明を必ず入れてください。
2. 権限の説明文は「自動で入るが中身は空」
カメラや写真ライブラリを expo-image-picker などで使うと、必要な NS...UsageDescription キー自体は自動で Info.plist に入ります。ただし文言まではよしなにしてくれないため、空文字のまま提出してリジェクトされるケースが頻発します。使う権限ごとに、なぜ必要かを一文で添えるのが安全です。
3. OTA(expo-updates)で配れないものを把握する
JavaScript の修正は OTA で即時配信できますが、ネイティブモジュールの追加や Expo SDK のメジャー更新は OTA では配れません。runtimeVersion を { "policy": "appVersion" } にしておかないと、古いネイティブバイナリに新しい JS が落ちて起動直後にクラッシュします。OTA は「JS だけの修正」と割り切るのが安全です。
4. EAS Build はプロファイルを分ける
eas.json で development / preview / production を分け、production はビルド番号を自動採番にしておくと、提出のたびに番号を手で上げる事故がなくなります。
{
"build" : {
"production" : {
"ios" : { "autoIncrement" : true },
"channel" : "production"
}
}
}
2014年から個人開発で壁紙や癒し系のアプリを運営してきて、収益の柱は今も AdMob です。だからこそ ATT の説明文を入れ忘れて審査を一度往復させてしまったときは、痛い勉強代になりました。コードが完璧でも、Info.plist の一行で止まるのが iOS の本番だと感じています。
次の一歩
次に production ビルドを eas build で流す前に、上のチェックリストを一度上から確認してみてください。そしてリリース後の数日は、Sentry のダッシュボードを朝いちで開く習慣をつけると、白画面の報告がレビュー欄に並ぶ前に気づけます。審査の往復が一度減り、クラッシュの初動が一日早まるだけで、個人開発のリリースはずいぶん穏やかになります。