見出し画像

iOS 18のTranslation APIを使って自動翻訳機能を実装したら、iOS 17でクラッシュしたけどなんとか直した話 REALITY Advent Calendar 2024

iOSエンジニア兼ローカライズの番人の @chuymaster です。最近愛媛に観光しに行ったら、しまなみ海道のきれいな海と美味しいみかんに魅了されました。映えスポットとしてJR下灘駅がおすすめです。

この記事はREALITY Advent Calendar 2024の11日目の記事です!

REALITY iOSアプリでは、自動字幕起こし機能を提供しており、視聴者が音声が聞こえづらい状況でもリアルタイムで配信者の会話をある程度理解できます。iOSDC Japan 2024で実装の裏話を紹介しているので、詳しくは下記をご覧ください!

そして、次の一歩として、字幕をリアルタイムで翻訳する機能も実装しました。この記事では、字幕の自動翻訳の機能紹介と、実装時につまずいた想定外のクラッシュの修正について説明します。


配信会話の自動翻訳機能をリリース!

REALITY iOSアプリv24.48.0 以降で、配信者の会話の字幕を自動翻訳できるようになりました!iOS 18以上の端末限定で、設定画面の「NEXT REALITY」から機能を有効化できるので、ぜひ使ってみてください!🤟

新機能のリリースノート
日本語で話している配信者の会話が
リアルタイムで英語に翻訳されます!

実は、字幕と翻訳機能は去年の開発合宿で欲しいなぁと思って実装してみたものです。あれから約1年、iOS 18の機能追加のおかげで実現できました🙌。当時の記事はこちらです。

私は中国語も勉強していますが、まだまだ配信を理解するには及びません。ですが、この機能おかげで、台湾の方の配信がある程度理解できるようになり、世界のユーザーを繋ぐ夢に一歩近づいたのかなと思います😁

iOS 18のTranslation APIでの翻訳実装

Translation APIはiOS 18から登場しており、WWDC2024のセッションでユースケースや実装方法が紹介されています。SwiftUIで実装しているアプリであれば、サンプルアプリを参考にしたら、基本的に難しいことなく実装できるでしょう。

REALITYでは、字幕の翻訳結果を表示するViewはこのように実装しました。

import SwiftUI
import Translation

@available(iOS 18.0, *)
struct TranslationView: View {
    @Binding private var text: String
    @State private var configuration: TranslationSession.Configuration
    @State private var translatedText: String?

    init(text: Binding<String>, sourceLocale: Locale, targetLocale: Locale) {
        _text = text
        configuration = .init(source: sourceLocale.language, target: targetLocale.language)
    }

    var body: some View {
        Text(translatedText ?? "")
            .translationTask(configuration) { session in
                if let response = try? await session.translate(text) {
                    translatedText = response.targetText
                } else {
                    translatedText = nil
                }
            }
            .onChange(of: text) { _, _ in
                // 再翻訳させる
                configuration.invalidate()
            }
    }
}

ポイントは、音声認識によって作られたテキストは動的に変わるので、`Binding<String>` で受け取り、 `onChange` で値が変わったら、 `configuration.invalidate()` を呼び出して再翻訳させることです。翻訳はオンデバイスで行われるので、テキストがどんなに頻繁に変わっても、結果がすぐ出ますし、使用制限もありません。

翻訳言語の設定UI

翻訳において、翻訳元言語と翻訳先言語を設定する必要があります。また、ディクショナリをダウンロードしていないと使えないので、その場合、ユーザーにダウンロードを促す必要があります。これらのUIは、設定画面で実装しました。

NEXT REALITY設定画面での翻訳言語設定

指定した言語ペアが翻訳が可能かどうかは LanguageAvailability で判定が可能です。状態が `.installed` ではない場合は翻訳できないので、TranslationSessionの prepareTranslation() を使って、言語のダウンロードダイアログを出し、ユーザーに言語をダウンロードしてもらう必要があります。

言語ダウンロードダイアログ

iOS 17以下でなぜかクラッシュが起きた

prepareTranslation() を呼ぶには、言語ペアを指定した `TranslationSession.Configuration` が必要なので、設定画面のViewModelでこのような処理を書き、`configuration` プロパティを保持して、Viewから参照しました。

// ViewModel
@available(iOS 18.0, *)
class TranslationSettingsViewModel: ObservableObject {
    @Published private(set) var configuration: TranslationSession.Configuration?

    func setTranslationLanguageCode(from: String, to: String) async {
        // LanguageAvailabilityでチェックしたあと、configurationを設定する
        ...
        configuration = .init(Locale(identifier: from), Locale(identifier: to))
    }
}
// View
Color.clear
    .translationTask(configuration) { session in
        do {
            try await session.prepareTranslation()
        } catch {
            // エラーハンドリング
        }
    }

上記の実装でiOS 18では特に何も問題なく動作しますし、iOS 17以下では設定を出していないので、今まで通り何も挙動が変わりません。これでリリースしようと思ったら、社内のTestFlightユーザーの方から「iOS 17でチャット画面を開くとなぜか必ずクラッシュするけど…😱」という連絡を受けました。チャット画面は今回の改修とは全く関係ない箇所で、とても戸惑って、早急に調査しました。

NSClassFromStringがクラッシュ原因

チャット画面では、Realmを使ってローカルデータベースからチャット履歴を読み書きしています。そこで調べたところ、Realmが呼んでいる NSClassFromString メソッドがクラッシュの原因だと分かりました。NSClassFromStringのクラッシュは下記のコードで再現が可能です。(Xcode 16.0, iOS 17.2 Simulator)

① iOS 18から使える `TranslationSession.Configuration?` のプロパティを `MyViewModel` のクラスに定義する

@available(iOS 18.0, *)
public class MyViewModel {
    var configuration: TranslationSession.Configuration?
}

NSClassFromString で、クラス名からクラスを取得するObjective-Cの関数を書く

#import "HogeClass.h"
#import <objc/runtime.h>

@implementation HogeClass

- (void)obtainClass:(NSString *)className {
    Class objectClass = NSClassFromString(className);
}
@end

③ SwiftでObjective-Cの関数に `MyViewModel` を渡してクラスを取得する

import SwiftUI
import Translation

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
        .onAppear {
            HogeClass().obtainClass("module.MyViewModel")}
    }
}

そうすると、iOS 18では何の問題もありませんが、iOS 17以下では、 `NSClassFromString` がクラッシュしてしまいます。

iOS 17以下でのクラッシュ
Thread 1: EXC_BAD_ACCESS (code=1, address=0x0)
A bad access to memory terminated the process.

奇妙なのは、 `TranslationSession.Configuration?` の場合は落ちますが、 同じiOS 18から使える `TranslationSession` だと落ちません。どうも非対応クラスのサブクラスをプロパティとして定義することがクラッシュの条件のようです。

クラッシュの対策

発生箇所は特定できました。しかし、Realmは外部ライブラリであり、REALITYで修正することはできず、いきなり廃止するわけにもいきません。
なので、別の簡単な対策を取りました。

NSClassFromStringはClassに対して動作するので、StructであるView側で `TranslationSession.Configuration?` を持つように修正すると回避できました🙌


@available(iOS 18.0, *)
struct TranslationSettingsView: View {
    // Viewで保持してクラッシュを回避
    @State private var configuration: TranslationSession.Configuration?
    ...
}

ちなみに、iOS 17から使える `@Observable` もなぜか iOS 16の端末でクラッシュしてしまいます。Objective-Cの `NSClassFromString` で何かを操作している方は要注意です。

// iOS 16でNG
@available(iOS 18.0, *)
@Observable
class TranslationSettingsViewModel: ObservableObject {
    var toLanguageCode: String?
    ...
}
// OK
@available(iOS 18.0, *)
class TranslationSettingsViewModel: ObservableObject {
    @Published var toLanguageCode: String?
    ...
}

まとめ

今回はiOS 18のTranslation APIをいち早くユーザーに届けたくて、字幕の自動翻訳機能を実装してリリースする過程で直面したクラッシュの修正について解説しました。教訓としては、特定のiOSバージョン以上のみで使えるように実装しても、思わずところでバグが出ることがあるので、古いOSバージョンでもしっかり動作確認することが大事です。あと、Objective-Cのコードはいろいろできて便利な分、やっぱり怖いですね…