はじめに
こんにちは、Techouseでモバイルアプリ/バックエンド開発を担当しているnozomemeinです。
前回の投稿ではRuby on Rails の Active Storageを使ったCORS対応についてブログを書かせていただきました。
今回は、Flutterアプリケーションにおいてユーザーが漢字で姓名を入力した際に、自動的にふりがなを補完する機能を実装した際の技術的な取り組みについてご紹介します。
特に、FlutterのMethodChannelを活用してiOSネイティブAPIと連携し、オフラインで動作する漢字→ひらがな変換機能を実現した過程を詳しく解説いたします。
姓名ふりがな自動入力機能とは?
機能の概要
姓名ふりがな自動入力機能とは、ユーザーが姓名欄に漢字で名前を入力した際に、対応するふりがな欄に自動的にひらがなを補完する機能です。
例えば、「山田太郎」と入力すると、ふりがな欄に「やまだたろう」が自動的に入力されるといった具合です。
普段色々なアプリを使っていても、よく見かける機能ですね。
なぜこの機能が必要なのか
モバイルアプリにおいて、姓名とふりがなの両方を入力させるフォームをしばしば見かけますが、ユーザーにとってふりがなの入力をするのは幾らかのハードルがあります。
- 入力の手間: 漢字を入力した後に、わざわざひらがなで同じ内容を入力する必要がある
- ユーザビリティの低下: 特にモバイル端末では、入力切り替えが煩雑
今回のフォームは アプリの主要コンバージョン(CV)ポイントでした。ユーザー体験を損なうと離脱率が上がり、CVへ直結する数字にも影響します。
これらの課題を解消し、コンバージョン率(CVR)の向上を図るため、ユーザー体験を高める自動入力機能を実装しました。
技術選択と方針
依存を極力排除して、ローカル(端末内)で完結させる
今回の実装では、可能な限り外部依存を抑えて、ローカルで完結させるという方針を採用しました。
この判断をした理由は下記です:
1. 不要な通信の回避
外部APIを利用した場合、以下のような問題が発生する可能性があります:
- ネットワーク接続が不安定な環境での動作不良
- API呼び出しによるレスポンス遅延
- 通信コストの増加
ユーザーが常に良好な通信環境でアプリを使えるとは限らないため、頻繁な通信は避ける方針です。
2. 外部APIへの依存回避
- サービス終了やAPI仕様変更のリスク
- レート制限による機能制約
- 外部サービスの障害による機能停止
外部のAPIを利用してしまうとサービスの機能がそちらに依存するので、極力利用しない方針を取りました。
3. 外部パッケージ依存の最小化
不必要に外部パッケージに依存するとメンテナンスが大変だろうと考え、可能な限り標準機能やネイティブ機能を活用する方針としました。
iOSから優先的に進める
POC(Proof of Concept)として、まずiOS実装を優先することにしました。
これは、iOSにCFStringTokenizer
という強力な形態素解析APIが標準で提供されており、同じく標準の文字列変換APIであるCFStringTransform
と組み合わせることで、高精度な漢字→ひらがな変換が実現できるためです。
iOS実装
CFStringTokenizerの活用
iOSでは、Core FoundationフレームワークのCFStringTokenizer
を使用することで、高精度な形態素解析が可能です。
この機能は以下の特徴があります:
- 標準搭載: iOSに標準で組み込まれているため、追加のライブラリが不要
- 高精度: Appleが提供するAPIに基づく高精度な解析
- 多言語対応: 日本語を含む複数の言語に対応
Swift スクリプトでの検証
では実際に適当なSwiftスクリプトを用意して、期待の動作が実現できるかを確認してみることにしました。
以下が、漢字をひらがなに変換するSwiftコードのスニペットです:
import Foundation /// 漢字フルネーム → ひらがな func kanjiToHiragana(_ text: String) -> String? { // ① Tokenizer を準備 let cfText = text as CFString let fullRange = CFRange(location: 0, length: CFStringGetLength(cfText)) guard let tokenizer = CFStringTokenizerCreate( nil, cfText, fullRange, kCFStringTokenizerUnitWord, Locale(identifier: "ja") as CFLocale) else { return nil } // ② 各トークンのローマ字を取得 var latinPieces: [String] = [] var tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer) while tokenType.rawValue != 0 { if let latinObj = CFStringTokenizerCopyCurrentTokenAttribute( tokenizer, kCFStringTokenizerAttributeLatinTranscription) { let latin = latinObj as! String latinPieces.append(latin) } tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer) } let latinAll = latinPieces.joined() // ③ ローマ字 → ひらがな変換 let mutable = NSMutableString(string: latinAll) as CFMutableString CFStringTransform(mutable, nil, kCFStringTransformLatinHiragana, false) return mutable as String }
処理の流れ
- Tokenizerの準備: 日本語ロケールでCFStringTokenizerを初期化
- 形態素解析: 入力テキストを単語単位に分割し、各単語のローマ字表記を取得
- 文字変換: ローマ字をひらがなに変換(CFStringTransform)を利用)
この実装により、以下のような変換が可能になりました。思ったよりも簡単でしたね。
山田太郎 → やまだたろう 佐々木志乃 → ささきしの 一二三太郎 → いちにさんたろう 竹中平蔵 → たけなかへいぞう タナカタロウ → たなかたろう
AppDelegateへの統合
ここまでの内容で、CFStringTokenizer
を使って漢字→ひらがな変換が可能なことがわかったので、
いよいよMethodChannelを利用するための土台を作っていきます。
MethodChannelについて知りたい方はこちらの資料がわかりやすいと思います。 docs.flutter.dev
まず、Flutter側から呼び出されるNativeのハンドラーを定義するために、
app/ios/Runner/AppDelegate.swift
にMethodChannelのハンドラーを登録します。
@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { setupMethodChannels() // ここでMethodChannelのハンドラーを登録 GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } /// すべてのMethodChannelを設定する private func setupMethodChannels() { guard let controller = window?.rootViewController as? FlutterViewController else { return } // 漢字変換チャンネルの設定 KanaConverter.shared.setup(controller: controller) } }
ここでチャンネルの処理の実態となるKanaConveter
Classを下記のように定義していきます。
class KanaConverter { /// チャンネル名 static let channelName = "kana_converter" /// シングルトンClassにする static let shared = KanaConverter() private var channel: FlutterMethodChannel? private init() {} /// Method Channelを設定する /// - Parameter controller: FlutterViewController func setup(controller: FlutterViewController) { channel = FlutterMethodChannel( name: KanaConverter.channelName, binaryMessenger: controller as! FlutterBinaryMessenger ) channel?.setMethodCallHandler { [weak self] call, result in self?.handleMethodCall(call, result: result) } } /// Method Callを処理する /// - Parameters: /// - call: FlutterMethodCall /// - result: FlutterResult private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "convertKanjiToKana": handleConvertKanjiToKana(call, result: result) default: result(FlutterMethodNotImplemented) } } /// 漢字をひらがなに変換するメソッドを処理する /// - Parameters: /// - call: FlutterMethodCall /// - result: FlutterResult private func handleConvertKanjiToKana(_ call: FlutterMethodCall, result: @escaping FlutterResult) { guard let args = call.arguments as? [String: Any], let text = args["text"] as? String else { result(FlutterError( code: "BAD_ARGS", message: "Expected {text: String}", details: nil )) return } if let hiragana = KanaConverterUtils.kanjiToHiragana(text) { result(hiragana) } else { result(FlutterError( code: "CONVERT_FAIL", message: "Conversion failed", details: nil )) } } } /// 漢字をひらがなに変換するユーティリティクラス class KanaConverterUtils { /// 漢字をひらがなに変換する /// - Parameter text: 変換する漢字テキスト /// - Returns: 変換されたひらがなテキスト(変換に失敗した場合はnil) static func kanjiToHiragana(_ text: String) -> String? { let cfText = text as CFString let fullRange = CFRange(location: 0, length: CFStringGetLength(cfText)) guard let tokenizer = CFStringTokenizerCreate( nil, cfText, fullRange, kCFStringTokenizerUnitWord, Locale(identifier: "ja") as CFLocale) else { return nil } var latinPieces: [String] = [] var tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer) while tokenType.rawValue != 0 { if let latinObj = CFStringTokenizerCopyCurrentTokenAttribute( tokenizer, kCFStringTokenizerAttributeLatinTranscription) { let latin = latinObj as! String latinPieces.append(latin) } tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer) } let latinAll = latinPieces.joined() let mutable = NSMutableString(string: latinAll) as CFMutableString CFStringTransform(mutable, nil, kCFStringTransformLatinHiragana, false) return mutable as String } }
色々書いていますが大切なこととしては、下記です。
- MethodChannelそのものに名前をつける
- e.g.
kana_converter
- e.g.
- MethodChannel経由で呼び出されるメソッドに名前をつける
- e.g.
convertKanjiToKana
- e.g.
ここで登録した名前を利用して、Flutter側からinvokeMethod
で呼び出せるようになります
Flutter側の実装
MethodChannelの設定
Native側の実装が準備できたので、Flutter側からMethodChannelを呼び出せるようにしていきます。
弊社モバイルアプリでは、グローバルな状態管理ライブラリであるRiverpodを使えるようにしているので、MethodChannel自体の管理をRiverpodで行うようにしました。
MethodChannelとそのメソッドをアプリケーション内でアクセスできる形で整備していきます。
※ ここでは、riverpod_generator/riverpod_annotationを利用したコード生成機能を利用しています。
import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; // 利用可能なMethodChannelを示す enum AvailableMethodChannel { kanaConverter('kana_converter'); // 登録したMethodChannel final String key; const AvailableMethodChannel(this.key); } // 各MethodChannelで使えるメソッドを登録 enum ChannelMethod { convertKanjiToKana(AvailableMethodChannel.kanaConverter, 'convertKanjiToKana'); // MethodChannelに紐づくメソッド final AvailableMethodChannel channel; final String methodName; const ChannelMethod(this.channel, this.methodName); } // Riverpod経由でGlobalにMethodChannelにアクセスできるようにする @Riverpod(keepAlive: true) MethodChannel methodChannel(Ref ref, AvailableMethodChannel channel) { return MethodChannel(channel.key); }
登録したMethodChannelの呼び出しは、下記のように行います。
/// 特定のチャンネルで特定のメソッドを呼び出す汎用関数 Future<T?> _invokeChannelMethod<T>({ required Ref ref, required ChannelMethod method, Map<String, dynamic> arguments = const {}, }) async { final methodChannel = ref.read(methodChannelProvider(method.channel)); return methodChannel.invokeMethod<T>(method.methodName, arguments); } // アプリケーション側から利用するプロバイダーのエントリーポイントはここ @riverpod FutureOr<String?> convertKanjiToKana(Ref ref, String kanji) async { try { if(defaultTargetPlatform != TargetPlatform.iOS) return null; // Androidは未実装なので何もしない return await _invokeChannelMethod<String>( ref: ref, method: ChannelMethod.convertKanjiToKana, arguments: {'text': kanji}, ); } catch (e) { // エラーがあったとしても、どうしようもないのでユーザーには通知しない // 実際には開発者向けにログを吐いたりしています return null; } }
フォームでの活用
弊社アプリでは、Riverpodに合わせて、ReactのHookのような機能をFlutterに導入できるflutter_hooksも導入しています。
それを最大限活用する形で下記のようなカスタムHookを導入し、このHookを利用してフォームでの変換処理を行います。
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:flutter/widgets.dart'; // Riverpodで管理するProverContainerにアクセスするための汎用Hook ProviderContainer useProviderContainer() { final context = useContext(); return ProviderScope.containerOf(context); } typedef UseKanaConverterResult = ({ Future<void> Function(String text) convert, FocusNode? focusNode, }); // methodChannelにアクセスするAPIを提供するHook UseKanaConverterResult useKanaConverter({ required TextEditingController textController, required TextEditingController kanaController, required String initialValue, }) { final previousValue = useState<String>(initialValue); final container = useProviderContainer(); final focusNode = useFocusNode(); // 初期値はLazyに評価されるので、useEffectで初期化してあげる useEffect(() { previousValue.value = initialValue; return null; }, [initialValue]); final convert = useCallback((String value) async { // In MemoryのStateと比較して差分があったときのみ漢字→かな変換を行う final hasChanged = previousValue.value != value; if (hasChanged) { final hiragana = await container.read(convertKanjiToKanaProvider(value).future); // ここでRiverpod経由でMethodChannelを呼び出してあげる if (hiragana != null) { kanaController.text = hiragana; // うまく変換できれば、上書き更新 } } previousValue.value = value; // In Memoryでフォームの変更を記録 }, [kanaController, previousValue, initialValue]); // TextFieldのfocusが外れたときにも変換を行う useEffect(() { void listener() { if (!focusNode.hasFocus) { convert(textController.text); } } focusNode.addListener(listener); return () => focusNode.removeListener(listener); }, [focusNode, convert, textController]); return (convert: convert, focusNode: focusNode); }
ここでは、useState
、useEffect
やFocusNode
を活用する形で、変換処理の発火タイミングを制御するようにしています。(細かい仕様の話は後述します)
あとはこれを利用する場所で呼び出してあげれば、完成というわけです。
今回の場合だと、onEditingComplete
にuseKanaConverter
が返すconvert
関数を渡して、編集完了時に変換処理が発火するようにしてあげる形です。
import 'package:driver_app/hook/use_kana_converter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; class SampleForm extends HookWidget { const SampleForm({super.key}); @override Widget build(BuildContext context) { final nameCtrl = useTextEditingController(); final kanaCtrl = useTextEditingController(); final nameConverter = useKanaConverter( textController: nameCtrl, kanaController: kanaCtrl, initialValue: '', ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: nameCtrl, focusNode: nameConverter.focusNode, onEditingComplete: () => nameConverter.convert(nameCtrl.text), ), const SizedBox(height: 12), TextField( controller: kanaCtrl, ), ], ); } }
その他のこだわりポイント
自動入力が発火する条件
ふりがなは 「前回入力した姓名と違うときだけ」 更新するようにしています。
final convert = useCallback((String value) async { // In MemoryのStateと比較して差分があったときのみ漢字→かな変換を行う final hasChanged = previousValue.value != value; if (hasChanged) { final hiragana = await container.read(convertKanjiToKanaProvider(value).future); // ここでRiverpod経由でMethodChannelを呼び出してあげる if (hiragana != null) { kanaController.text = hiragana; // うまく変換できれば、上書き更新 } } previousValue.value = value; // In Memoryでフォームの変更を記録 }, [kanaController, previousValue, initialValue]);
これだけで、次のような細かな挙動をカバーできます。
ふりがな欄の状態 | ユーザー操作 | 条件 | 動作 |
---|---|---|---|
空 | 姓名を入力 | 前回と同じ | 変更なし |
前回と異なる | ふりがなを補完 | ||
入力済み | ふりがなを手動編集 | ― | 変更なし(上書き防止) |
姓名を編集 | 前回と同じ | 変更なし | |
前回と異なる | ふりがなを再補完 |
また、変換処理は、Tabキーや「次へ」ボタンでフィールドを移動したときだけでなく、ユーザーが別のフォーム要素にフォーカスを切り替えた瞬間にも実行されるようになっています。
// TextFieldのfocusが外れたときにも変換を行う useEffect(() { void listener() { if (!focusNode.hasFocus) { convert(textController.text); } } focusNode.addListener(listener); return () => focusNode.removeListener(listener); }, [focusNode, convert, textController]);
参考にした挙動
iOS標準の連絡先アプリのふりがな入力体験をベースにデザインしました。
「直前の入力だけを見る」というシンプルなロジック で実装負荷を抑えつつ、期待どおりのUXを得られたと感じています。
Android版について
無事にiOS版は実装ができたので、Android版の実装として、Google Gboard APIの活用を検討しました。
Gboard APIを使用することで、StandAloneのAndroidアプリで同様の実装が可能です。
以下に、StandAloneのAndroidアプリでのサンプルコードを実装しています。
しかし、MethodChannelを利用したAndroid実装では以下のような課題があったので、
- IME依存: Gboardがインストールされている環境でのみ動作
- UI密結合: IMEの入力データがUIコンポーネントと密結合しており、単体での利用が困難
Android版の実装は一旦行っていません。
感想・まとめ
今回、FlutterのMethodChannelを活用してiOSネイティブAPIと連携することで、オフラインで動作する姓名自動入力機能を実現することができました。構想から設計/実装まで2日程度だったのですが、思ったよりもサクッとできたので良かったです。
また自分自身、Swiftのコードを書いたことがなく、色々言語仕様を調べながら進めていくことはとても刺激的でした。
CI/CDの整備までは、更新頻度と実行時間を天秤にかけて今回は見送っているんですが、XCTest
を実装したりと、ワクワクしながら進めることができました。(※ Androidの方が実装できなかったのは少し悔しいですが、、、)
MethodChannelを使ったネイティブ機能との連携は、クロスプラットフォーム開発ながら要所要所で必要に応じてNativeの機能を呼び出せるという、Flutterの大きな魅力の一つだとも再認識しました。
今回の実装が、同様の課題に取り組む開発者の皆様の参考になれば幸いです。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。