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でだけ、テスト実行時に言語を固定する必要があります。.xcschemeのTestActionにlanguage="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のほうが安定して日本語読みを返しました。今のところ実機で挙動が揺れたことはありません。