海外のユーザーから届くレビューやアプリ内の投稿を、その場で読める形にしたい——個人開発で壁紙や癒し系のアプリをいくつも運営していると、これがずっと小さな悩みでした。私自身、App Store のレビュー返信を 30 言語近く相手にする日があり、外部の翻訳 API に一つひとつ投げるたびに、通信・コスト・プライバシーの三つが少しずつ引っかかっていました。
iOS 18 で、Apple が Translation フレームワークを開放しました。デバイス上で完結し、追加課金がなく、一度モデルを落とせばオフラインでも動きます。外部 API にテキストを送らないので、ユーザーが書いた文章が端末の外に出ません。ところが、この機能は React Native では素直に触れません。Rork で生成した Expo アプリからは Swift のフレームワークに橋を架ける手間がかかり、結局ネイティブ拡張を自分で書くことになります。Rork Max が Swift を直接生成するようになって、ようやく現実的な選択肢になった——というのが今回の出発点です。
なぜ「アプリ内のリアルタイム翻訳」がネイティブ前提なのか
Translation フレームワークは、OS に統合された翻訳エンジンと言語モデルをそのまま借りる仕組みです。SwiftUI の .translationTask や TranslationSession といった API は、すべて Swift 側からしか呼べません。React Native の JavaScript 層には対応するブリッジが標準では存在せず、自前で Expo の config plugin とネイティブモジュールを書く必要があります。
無印 Rork(React Native / Expo)でも、ネイティブモジュールを手書きすれば不可能ではありません。ただ、翻訳結果を JS 側へ非同期で返す配線、言語モデルのダウンロード状態の監視、SwiftUI のビュー修飾子として設計された API をブリッジに載せ替える作業——ここまで来ると「ノーコードで作る」利点はほぼ消えます。Rork Max が Swift を生成する前提なら、これらは素直に SwiftUI のビューの中に収まります。後述するコードは、Rork Max が出力した画面コードに手を入れて育てる形を想定しています。
Translation フレームワークでできること・できないこと
最初に守備範囲を確認しておきます。期待をかけすぎると、後で「思っていたのと違う」となりがちな領域です。
項目 挙動
料金 無料。従量課金や API キーは不要
オフライン 対応。ただし言語モデルを事前にダウンロードしておく必要がある
プライバシー テキストは端末外に送信されない(オンデバイス処理)
対応言語 OS の翻訳対応言語に準ずる。ペアによっては未対応
翻訳の起点 原則ユーザー操作を伴うビュー単位。サーバー側バッチ用途には不向き
必要 OS iOS 18.0 以降(一部のシステム提示 UI は 17.4 以降)
ここで一番の落とし穴は「サーバー側バッチ用途には不向き」という点です。Translation はあくまでフォアグラウンドのアプリ内体験のための API で、バックエンドで大量のテキストを夜間に処理する、といった使い方は想定されていません。私の場合は「レビュー返信の下書き支援」「ユーザー投稿の即時翻訳表示」という、画面に出ている一文をその場で訳す用途に絞りました。
最小実装 — .translationTask で1文を翻訳する
まずは、表示中の文章をボタン一つで翻訳する最小形です。.translationTask は構成(Configuration)が設定・更新されたときにセッションを起こし、その中で翻訳を実行します。
import SwiftUI
import Translation
struct ReviewTranslateView : View {
let originalText: String // 海外ユーザーが書いた原文
@State private var translated = ""
@State private var configuration: TranslationSession.Configuration ?
var body: some View {
VStack ( alignment : .leading, spacing : 12 ) {
Text (originalText)
if ! translated. isEmpty {
Text (translated)
. foregroundStyle (.secondary)
}
Button ( "日本語に訳す" ) {
if configuration == nil {
// source を nil にすると、入力文の言語を自動判定します
configuration = . init (
source : nil ,
target : Locale. Language ( identifier : "ja" )
)
} else {
// 同じ構成のまま押し直したときに再実行させる
configuration ? . invalidate ()
}
}
}
. translationTask (configuration) { session in
do {
let response = try await session. translate (originalText)
translated = response.targetText // 訳文はここに入る
} catch {
translated = "翻訳に失敗しました( \( error. localizedDescription ) )"
}
}
}
}
ボタンを押すと configuration がセットされ、.translationTask のクロージャが走ります。期待する出力は、原文の下に薄い文字で日本語訳が現れる、というものです。source: nil の自動判定は、相手の言語がまちまちなレビュー欄では特にありがたい挙動でした。
言語の在庫を確認し、ダウンロードを促す
オフラインで動くということは、裏を返せば「モデルが端末に無ければ動かない」ということです。初回はモデルが未ダウンロードのことが多く、ここを黙って素通りすると「押しても何も起きない」という最悪の体験になります。
まず LanguageAvailability で在庫を確認します。
import Translation
enum TranslateReadiness {
case ready // すぐ翻訳できる
case needsDownload // 対応しているが、モデルが未DL
case unsupported // この言語ペアは非対応
}
func checkReadiness ( from source: Locale.Language) async -> TranslateReadiness {
let availability = LanguageAvailability ()
let target = Locale. Language ( identifier : "ja" )
let status = await availability. status ( from : source, to : target)
switch status {
case .installed : return .ready
case .supported : return .needsDownload
case .unsupported : return .unsupported
@unknown default: return .unsupported
}
}
.supported(=未ダウンロード)が返ってきたら、翻訳を試みる前に prepareTranslation() を呼びます。これがシステムのダウンロード確認シートを表示するトリガーになります。
. translationTask (configuration) { session in
do {
// 未DLならここでシステムのダウンロード確認が出る。DL済みなら即返る
try await session. prepareTranslation ()
let response = try await session. translate (originalText)
translated = response.targetText
} catch {
translated = "翻訳を準備できませんでした( \( error. localizedDescription ) )"
}
}
私はここで一度はまりました。prepareTranslation() を省いて translate だけ呼ぶと、モデル未DLの端末では無言でエラーになり、ユーザーには「壊れている」としか映りません。在庫確認と準備の二段構えは、面倒でも省かないことを推奨します。
複数テキストをまとめて翻訳する
レビュー一覧やコメント欄のように、複数の文章を一度に訳したい場面では、一件ずつ translate を呼ぶより一括 API のほうが速く、モデルの起動コストも一度で済みます。TranslationSession.Request に識別子を持たせ、応答を突き合わせます。
struct UserComment : Identifiable {
let id: Int
let body: String
var translated: String = ""
}
func translateAll ( _ comments: inout [UserComment],
session : TranslationSession) async throws {
// 各リクエストに clientIdentifier を付け、応答と突き合わせる
let requests = comments. map { comment in
TranslationSession. Request (
sourceText : comment.body,
clientIdentifier : " \( comment. id ) "
)
}
let responses = try await session. translations ( from : requests)
// 応答は順不同で届くことがあるため、識別子でインデックスを引く
let indexByID = Dictionary (
uniqueKeysWithValues : comments. enumerated (). map { ( $0 .element.id, $0 .offset) }
)
for response in responses {
if let id = response.clientIdentifier. flatMap ( Int . init ),
let i = indexByID[id] {
comments[i].translated = response.targetText
}
}
}
ここでの肝は clientIdentifier です。応答が必ずしも投げた順で返るとは限らないので、配列の添字に依存せず、識別子で元のデータに戻すこと。私は最初これを怠り、別のコメントに別の訳文が貼り付く取り違えを起こしました。
React Native(無印 Rork)では、なぜここまで難しいのか
無印 Rork で同じことをやろうとすると、おおよそ次の作業が乗ってきます。
ひとつ目は、.translationTask が SwiftUI のビュー修飾子として設計されている点です。ビューのライフサイクルに紐づくため、React Native の命令的なブリッジ呼び出しに素直には載りません。ふたつ目は、prepareTranslation() が出すシステムのダウンロードシートが、ネイティブのビュー階層上で提示される点です。Expo の JS 側からこの提示を制御するには、ネイティブモジュールでラップして状態を往復させる必要があります。みっつ目は、言語在庫の status を非同期で監視し、JS 側の UI に反映する配線です。
どれも不可能ではありませんが、合計すると「ノーコードで素早く」という Rork の良さを打ち消すだけの工数になります。私が無印 Rork と Rork Max を使い分けるときの線引きは、まさにここです。画面を素早く形にする段は無印で十分。OS 深く統合された機能——翻訳、HealthKit、Live Activities のような領域に踏み込む段で、Swift を直接生成する Max に切り替える。この二段構えが、個人開発の限られた時間では一番無駄が少ないと感じています。
つまずいた箇所 — 翻訳が空で返る・無反応になる
実装中に時間を溶かしたポイントを残しておきます。同じ轍を踏まないために。
ひとつ目は、translated が空のまま返るケース。多くは prepareTranslation() を省いたことによるモデル未DLが原因でした。在庫確認を先に挟むだけで回避できます。本番環境では、この一手間を省いた状態でリリースしないことを強く推奨します。
ふたつ目は、同じ文をもう一度訳そうとしても何も起きないケース。Configuration は内容が変わらないと .translationTask を再発火させません。再実行させたいときは configuration?.invalidate() を明示的に呼びます。最小実装のボタンで二段階に分けていたのはこのためです。
みっつ目は、source を固定言語にしていたために、想定外の言語のレビューで失敗するケース。レビュー欄のように相手の言語が読めない場面では、source: nil の自動判定に任せるほうが破綻しません。逆に、入力言語が確実に分かっている UI(特定国向けの画面など)では明示したほうが速くなります。
月200ドルの Rork Max を選ぶ基準として
正直に言えば、翻訳機能ひとつのために Rork Max(月200ドル)へ移る必要はありません。無印 Rork は無料から始められ、有料でも月25ドルです。判断の軸は「OS 統合機能をいくつ束ねるか」だと考えています。
オンデバイス翻訳に加えて、Live Activities や HealthKit、ウィジェットといった React Native では届きにくい機能を二つ三つ重ねて使うアプリなら、Swift を直接生成できる Max の価値が効いてきます。逆に、翻訳が要るのが一画面だけなら、その画面のためにネイティブ拡張を一度書くか、外部 API で割り切るほうが安上がりです。
私の場合は、複数の Dolice 名義アプリで「OS 統合機能の合計数」を物差しにして移行を決めています。今日の壁紙アプリ群では、ウィジェットとオンデバイス処理が重なってきたタイミングで Max 側に寄せました。あなたのアプリで、ネイティブでしか届かない機能が二つ以上見えてきたら、そこが切り替えを検討する頃合いだと思います。
まずは手元の iOS 18 端末で、上の最小実装を一画面だけ動かしてみてください。在庫確認と prepareTranslation() の二段構えさえ押さえれば、無料・オフラインの翻訳は驚くほど素直に動きます。