ML Kitを用いたAndroidでのオンデバイス翻訳

こんにちは。CL Android開発チームの吉兼 (@blackbracken) です。

私たちのプロダクトでは、2025年1月ごろにコミュニティ翻訳機能をリリースしました。CLにはコミュニティという、アーティストとユーザーが繋がる投稿やタイムラインなどの機能があります。今まで、数量やパターンが多いコンテンツに対して、利用コストなどが原因となり、翻訳機能の提供が困難になっていました。しかしML Kitの利用によりそちらの問題を超え解決しました。
そこで、実際にAndroidのアプリケーションにおいてJetpack Compose上で翻訳を実現したアプローチについて解説します。

ML Kitとは

ML Kitは、Googleが提供している機械学習系の機能を簡単に組み込むためのSDKです。学習済みモデルをオンデバイスで利用するため、依存が少なくレスポンスが高速、コストが小さいなどのメリットがあります。CLとしても実装と運用、双方のコストを抑えながらコミュニティの翻訳機能を実現できる良い選択となりました。

依存

  • com.google.mlkit:translate: 17.0.3
  • com.google.mlkit:language-id: 17.0.6

要件

投稿が表示され、ユーザーの言語と翻訳可能な言語を識別し、翻訳可能であれば翻訳ボタンを表示します。その後、翻訳ボタンの押下によって翻訳を行います。

そのため、投稿には直和な状態と、それに対応するようなKotlinのコードが下記のように考えられます。

翻訳されるテキストの状態遷移モデル

@Parcelize
sealed interface Translation : Parcelable {
  @Parcelize
  data class Translated(val text: String) : Translation

  @Parcelize
  data object Translating : Translation

  @Parcelize
  data object NotTranslatedYet : Translation

  @Parcelize
  data object NotRequired : Translation
}

実装

上記のようにモデリングできるということを踏まえて、実装を行います。

翻訳側

Tranlsation のようなモデルを返す単純な @Composable な関数を定義できれば、再利用性高くシンプルな実装に落とし込めると考え、それを元に関数を定義します。

また、今回は実装の簡単のためにモデルのダウンロード周りを簡略化していますが、実際にはユーザーのWifi環境やユーザーへのプロンプト通知の結果に応じて翻訳するかは判断すべきです。

cf. https://firebase.google.com/docs/ml-kit/android/translate-text?hl=ja#manage_models

@Composable
fun rememberTranslation(
  localeList: LocaleList,
  text: String,
  shouldTranslate: Boolean, // UI上でのトグルボタンなどの状態
): Translation {
  val usingLanguage = remember(localeList) { 
    localeList
      .getFirstMatch(TranslateLanguage.languageCodeArray)
      ?.let { TranslateLanguage.fromLocale(it) }
   } ?: return Translation.NotRequired
  var isIdentifiedLanguageOfText by rememberSaveable(localeList) { mutableStateOf(false) }
  var translation by rememberSaveable(localeList) { mutableStateOf<Translation>(Translation.NotRequired) }

  // localeListに応じて言語の識別(Configuration Change)
  LaunchedEffect(localeList) {
    if (translation is Translation.Translated) {
      return@LaunchedEffect
    }

    isIdentifiedLanguageOfText = false

    val textLanguage = identifyTranslatableLanguage(usingLanguage, text)
    translation = if (textLanguage != null) {
      Translation.NotTranslatedYet
    } else {
      Translation.NotRequired
    }

    isIdentifiedLanguageOfText = true
  }

  // 外部の状態に合わせて実際の翻訳や状態の切り替え
  LaunchedEffect(localeList, isIdentifiedLanguageOfText, shouldTranslate) {
    if (!isIdentifiedLanguageOfText) {
      return@LaunchedEffect
    }
    val textLanguage = identifyTranslatableLanguage(usingLanguage, text) ?: return@LaunchedEffect

    when {
      shouldTranslate && translation is Translation.NotTranslatedYet -> {
        translation = Translation.Translating

        val translatedText = translate(textLanguage, usingLanguage, text)
        translation = if (translatedText != null) {
          Translation.Translated(translatedText)
        } else {
          Translation.NotTranslatedYet
        }
      }

      !shouldTranslate && translation is Translation.Translated -> {
        translation = Translation.NotTranslatedYet
      }
    }
  }

  return translation
}

/**
  * 言語を識別する
  */
private suspend fun identifyTranslatableLanguage(currentLanguage: TranslateLanguage, text: String): TranslateLanguage? {
  val identifiedLanguageCode = try {
    LanguageIdentification.getClient()
      .identifyLanguage(text)
      .await()
  } catch (_: Exception) {
    null
  } ?: return null
  val identifiedLanguage = TranslateLanguage.fromMlkitValue(identifiedLanguageCode) ?: return null

  return identifiedLanguage.takeIf { it != currentLanguage }
}

/**
 * 翻訳する
 */
private suspend fun translate(from: TranslateLanguage, to: TranslateLanguage, text: String): String? {
  if (from == to) {
    return null
  }

  return try {
    val options = TranslatorOptions.Builder()
      .setSourceLanguage(from.mlkitValue)
      .setTargetLanguage(to.mlkitValue)
      .build()

    val translator = MlkitTranslation.getClient(options)
    // 必要に応じてモデルをダウンロードする旨などをユーザーに通知
    translator.downloadModelIfNeeded().await()

    translator.translate(text).await()
  } catch (ex: Exception) {
    // 必要に応じてエラーハンドル
    null
  }
}

UI側

先ほど定義した rememberTranslation を用い、返ってきた Translation を使って表示を出し分けます。Translation に一方的に依存しているだけなので、必要に応じて(画面による要件やユーザーのログイン状態など)出し分けることもできます。

/**
 * サンプル画面による一例
 */
@Composable
fun ExampleScreen() {
  val text = "" // 翻訳されるテキスト 
  val locales = LocalConfiguration.current.locales
  var shouldTranslate by rememberSaveable(locales) { mutableStateOf(false) }
  val translation = rememberTranslation(locales, text, shouldTranslate)

  // 翻訳ボタンを表示するか
  val showTranslationButton = translation !is Translation.NotRequired

  val content = if (translation is Translation.Translated) {
    translation.text
  } else {
    text
  }

  Column {
    // 翻訳ボタン
    if (showTranslationButton) {
      Button(
        // ...
        onClick = { shouldTranslate = !shouldTranslate },
      )
    }

    // 翻訳内容
    if (translation is Translation.Translating) {
      // 読み込み中; スケルトンローディングなど
    } else {
      Text(
        text = content,
      )
    }

  }  
}

さいごに

ML Kitを使ってAndroidでオンデバイス翻訳を行う方法をご紹介しました。Jetpack Composeとの噛み合わせも良く、簡単に導入できるのでぜひお試しください。