iOSの五十音順ソートでCFStringTransformが使えない理由 — 漢字がピンインになる罠

AI主体で執筆

メモアプリのタグ一覧を五十音順で並べたくて、素朴にCFStringTransform(kCFStringTransformToLatin)でローマ字化してからlocalizedStandardCompareで並べました。テスト環境では「あ」「か」「さ」の順に並んでいるように見えたのですが、実機で試すと「東京」が「dong jing」と並んでいて、タグ一覧の順序がぐちゃぐちゃになっていました。原因はCFStringTransformToLatinが漢字をICUの既定である簡体字中国語ピンインで転写することにあって、日本語読みを得るにはCFStringTokenizerにja_JP localeを渡す必要があります。それに加えて、Simulatorでは端末の優先言語に日本語がないと同じコードでもピンインに戻る、という別の罠も踏みました。両方の罠を踏んで、最終的にトークナイザ側で直しました。

最初のコードと、そこで起きたこと

タグ名をローマ字化して並べる、という素朴な実装から始めました。

extension String {
    var sortKey: String {
        let mutable = NSMutableString(string: self)
        CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
        return mutable as String
    }
}

let sorted = tags.sorted { $0.name.sortKey.localizedStandardCompare($1.name.sortKey) == .orderedAscending }

iOS Simulatorの英語設定で動かすとそれっぽく見えたのですが、実機(日本語設定)で動かすと並び順が想像と違いました。デバッグ用にsortKeyをログ出力すると、次のようになっていました。

東京    → "dong jing"
日本    → "ri ben"
りんご  → "ringo"
リンゴ  → "ringo"
Apple   → "Apple"

「東京」「日本」が中国語ピンインになっています。kCFStringTransformToLatinはICUデフォルトで漢字を転写するので、Han文字に対しては簡体字中国語のピンインが返ります。localeを指定する引数がないので、この関数だけでは日本語読みにできません。

正解:CFStringTokenizerにja_JP localeを渡す

漢字を日本語読みで取り出すには、CFStringTokenizerでトークンごとに形態素解析して、各トークンのkCFStringTokenizerAttributeLatinTranscriptionを連結します。

extension String {
    var japaneseSortKey: String {
        let raw = self as NSString
        if raw.length == 0 { return "" }

        if (raw as String).unicodeScalars.allSatisfy({ $0.isASCII }) {
            return (raw as String).lowercased()
        }

        let range = CFRangeMake(0, raw.length)
        let locale = NSLocale(localeIdentifier: "ja_JP") as CFLocale
        let tokenizer = CFStringTokenizerCreate(
            kCFAllocatorDefault,
            raw,
            range,
            kCFStringTokenizerUnitWordBoundary,
            locale
        )

        var result = ""
        var tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
        while tokenType != [] {
            if let latin = CFStringTokenizerCopyCurrentTokenAttribute(
                tokenizer,
                kCFStringTokenizerAttributeLatinTranscription
            ) as? String {
                result += latin
            }
            tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
        }
        return result
    }
}

これで「東京」が"toukyou"、「日本」が"nippon"、「りんご」「リンゴ」は同じ"ringo"に潰れます。同着したときはlocalizedStandardCompareに落として、ひらがな/カタカナの並びを見た目で決めるようにしました。

ASCIIだけの文字列を先に抜く

上のコードで早期リターンを入れているのには理由があります。CFStringTokenizerにASCIIの文字列を渡すと、トークナイザがカタカナ読みに変換することがあって、"Apple""appuru"になります。日本語読みを欲しかったはずが、英単語まで読み仮名化される形です。

入力 早期リターンなし 早期リターンあり(採用)
Apple appuru apple
Swift suifuto swift
東京 toukyou toukyou

ASCIIだけの文字列は、そのまま小文字化してString.localizedStandardCompareに流したほうが、辞書順の意図に合います。unicodeScalars.allSatisfy({ $0.isASCII })で判定する1行を入れるだけで、英数字のタグだけが混ざっているケースでも自然に並びます。

Simulator固有の罠:優先言語設定で結果が変わる

直したコードをSimulatorのテストにかけると、なぜかまた「東京」が"dong jing"に戻っていました。原因は、kCFStringTokenizerAttributeLatinTranscription端末の優先言語リストを参照していることです。Simulatorは既定で英語(en_US)のみが優先言語に入っていて、日本語辞書が呼ばれません。

実機は「設定 > 一般 > 言語と地域」で日本語が優先言語に入っているので問題は出ません。Simulatorでだけ、テスト実行時に言語を固定する必要があります。.xcschemeTestActionlanguage="ja"region="JP"を指定すれば、テストランタイム中は日本語優先に固定されます。

<TestAction
   language = "ja"
   region = "JP"
   ...>

スキームは通常のXcode設定ファイルと違ってpbxprojの直接編集禁止ルールの例外にしています。この1行が入っていないと、手元ではピンインに戻るのにCIで落ちる、という再現の難しいテストになります。

メモ:Simulatorを手元で動かすときも、「Settings > General > Language & Region」で日本語を優先言語に入れておくと、テストスキームを介さずに動作確認ができます。ただ、複数のSimulator間で設定が揃わないと再現性がなくなるので、テスト側はスキームで固定しておくのが安全です。

パフォーマンスと、その後

タグが数十件ならjapaneseSortKeyを毎回計算しても気にならない速度ですが、数百件を超えるとCFStringTokenizerの生成コストが無視できなくなります。NSCache<NSString, NSString>で文字列からキーへの結果をメモ化すると、スクロールやソート再計算のたびに形態素解析が走るのを避けられます。

private let sortKeyCache: NSCache<NSString, NSString> = {
    let c = NSCache<NSString, NSString>()
    c.countLimit = 1024
    return c
}()

extension String {
    var cachedJapaneseSortKey: String {
        if let cached = sortKeyCache.object(forKey: self as NSString) {
            return cached as String
        }
        let key = japaneseSortKey
        sortKeyCache.setObject(key as NSString, forKey: self as NSString)
        return key
    }
}

実装したあとに気付いたのは、タグ名が変わる頻度は低いことです。1,024件のキャッシュを持っておけば、よほどのことがない限りヒット率は十分で、キャッシュサイズも数十KBで収まります。

Locale(identifier:)CFLocaleにキャストする書き方もありますが、iOSのバージョンによってはNSLocale(localeIdentifier:) as CFLocaleのほうが安定して日本語読みを返しました。今のところ実機で挙動が揺れたことはありません。