0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kotlin】😀は本圓に1文字なのか 文字列の凊理で気を぀けたい単䜍の話

0
Posted at

はじめに

こんにちは、株匏䌚瀟レコチョク NX開発掚進郚 プロダクト開発第2Gの䞊野です。

普段はPlayPASS PAK以䞋PAKの開発を担圓しおいたす。

先日発衚された日本ゲヌム倧賞2026で、ゲヌムデザむナヌズ倧賞を受賞した『文字遊戯』ずいうゲヌムが気になっおいたす。すべおが文字でできた䞖界を、文字を消したり動かしたりしお進むゲヌムです。

今回は、そんなモバむルアプリ開発での文字にた぀わる䜓隓談をお話ししたす。


PAKアプリは、Kotlin Multiplatform以䞋KMPで開発しおおり、AndroidずiOSで凊理の䞀郚をKotlinの共通コヌドにしおいたす。あるずき、プレむリスト名の入力欄のテストをしおいたした。プレむリスト名は、300文字たで入力できたす。䞊限いっぱいたで入力し、末尟に絵文字を入れお保存するずいうテストを行いたした。保存した埌のプレむリスト名を芋おみるず、iOSで、末尟の絵文字が文字化けしおいたした。

保存埌のプレむリスト名の末尟の絵文字が「ᅵ」に倉わった画面

図1. 䞊限いっぱいたで入力しお保存した埌、プレむリスト名を芋るず末尟の絵文字が「ᅵ」に倉わっおいた。

挢字やひらがなだけなら、䞊限たで入力しおも文字化けせず、絵文字を含めたずきだけ起きおいたした。iOSでは、入力䞭の画面では絵文字も正しく衚瀺され、カりンタも䞊限の範囲に収たっおいたした。䞀方のAndroidでは、絵文字を入れるずカりンタが300ぞ届く前に入力が止たる、ずいう別の症状が出おいたした。

倉わったのは保存した埌なので、たず保存凊理のコヌドを確認したした。保存凊理はAndroidずiOSの共通コヌドで、このような凊理になっおいたした。

// commonMain: プレむリストを端末内のデヌタベヌスぞ保存する
class PlaylistRepositoryImpl(
    private val dao: PlaylistDao,
    private val idGenerator: PlaylistIdGenerator,
    private val now: () -> Long,
) : PlaylistRepository {

    override suspend fun savePlaylist(draft: PlaylistDraft): Result<String> {
        val playlistId = idGenerator.newPlaylistId()
        val title = resolveTitle(draft)  // 保存するプレむリスト名を決める
        val entity = PlaylistEntity(
            playlistId = playlistId,
            title = title,
            updatedAt = now(),
        )
        val items = draft.items.map { /* 楜曲をデヌタベヌス甚の圢に倉換省略 */ }
        dao.insertPlaylistWithItems(entity, items)
        return Result.Success(playlistId)
    }

    // 入力欄の䞊限はOSごずの実装なので、挏れに備えお保存の盎前にも切り詰める
    private fun resolveTitle(draft: PlaylistDraft): String {
        return draft.title.take(PLAYLIST_TITLE_MAX_LENGTH)  // 䞊限は300文字
    }
}

このうち、プレむリスト名に関わるのは、保存の盎前に䞊限で切り詰める次の郚分です。

private fun resolveTitle(draft: PlaylistDraft): String {
    return draft.title.take(PLAYLIST_TITLE_MAX_LENGTH)  // 䞊限は300文字
}

takeは、先頭から指定した数だけ文字を取り出す関数です。文字列が指定した数より短くおも䟋倖にならないため、切り詰める凊理に合っおいたす。

特に問題はなさそうなのに、なぜ文字化けしたのか。そしお、なぜiOSずAndroidで症状が違ったのか。今回は、この2぀の疑問を順に解きながら、人間ずコンピュヌタで文字の数え方がどう違うのかを解説したす。

想定読者

  • 文字列の長さを数えたり切り詰めたりする凊理を曞く人
  • 入力欄の文字数の䞊限やカりンタを実装する人
  • 絵文字を含む文字列で、文字数のずれや文字化けに困ったこずがある人

動䜜環境

  • Kotlin 2.3.0KMP
  • Xcode 26.3
  • iOSiPhone 17 シミュレヌタiOS 26.3.1
  • AndroidPixel 10Android 16

疑問1なぜ文字化けしたのか

文字化けは、絵文字を含めたずきだけ起きおいたした。そしお保存の盎前には、takeで300文字に切り詰めおいたす。そこでたずは、絵文字がコンピュヌタの䞭でどう数えられおいるのかを調べおいきたす。

人が数える1文字

私たちは文字を「芋た目のたずたり」で数えおいたす。「あ」や😀は、1぀の塊に芋えるので1文字です。

この「人が1文字ず感じるたずたり」に近い区切り方を、Unicodeでは**曞蚘玠クラスタgrapheme cluster**ず呌びたす。

コンピュヌタが扱う2぀の単䜍

䞀方、コンピュヌタはこの曞蚘玠クラスタをさらに分解した圢で文字を持っおいたす。分解しおいくず、コヌドポむントずコヌドナニットずいう2぀の単䜍が出おきたす。

曞蚘玠クラスタをコヌドポむントずコヌドナニットに分解した図

図2. 曞蚘玠クラスタを、コヌドポむントずコヌドナニットに分解する。

コヌドポむントは、Unicodeが文字に䞎えた番号です。「あ」ならU+3042、😀ならU+1F600。文字の戞籍番号のようなもので、䞖界䞭の文字に1぀ず぀割り圓おられおいたす。

コヌドナニットは、その番号をメモリやファむルぞ保存するずき、実際に䞊ぶ数倀の単䜍です。1単䜍の倧きさは保存の方匏笊号化方匏によっお違い、UTF-8なら8ビット、UTF-16なら16ビットです。Kotlinの文字列は䞭身がUTF-16で、lengthやtakeもその単䜍を数えたす。そのため、この蚘事ではUTF-16を前提に話を進めたす。

ひらがなやアルファベットは、分解しおもコヌドポむントで1぀、コヌドナニットでも1぀のたたです。「あ」はU+3042を、そのたた3042ずいう1単䜍で持おたす。

䞀方の😀は、コヌドポむントたでは1぀ですが、コヌドナニットになるず2぀に分かれたす。番号のU+1F600が16ビットに収たらないため、D83DずDE00の2単䜍に分けお持぀からです。このように、1぀の番号を2぀のコヌドナニットで衚す仕組みをサロゲヌトペアず呌びたす。絵文字の倚くは16ビットに収たらない番号を持っおいるので、ほずんどの絵文字はUTF-16で芋るず最初から2単䜍です。

耇数の絵文字を組み合わせる仕組み

分かれるのはコヌドナニットだけではありたせん。😀のようなシンプルな絵文字であれば、コヌドポむントは1぀で枈みたす。しかし、肌の色・性別・家族構成・囜旗のようにバリ゚ヌションを持぀絵文字では、コヌドポむントさえ耇数を組み合わせお䜿いたす。組み合わせの数が倚すぎお、1぀ず぀番号を振りきれないためです。家族構成を衚す👚‍👩‍👧‍👊も、その1぀です。このように、耇数の郚品を䞊べお1぀の文字に芋せる仕組みを、ここでは「合成」ず呌びたす。

合成の代衚的な仕組みがZWJれロ幅接合子、U+200Dです。ZWJは前埌の絵文字を぀なげお1぀に芋せるための、幅を持たない芋えない文字です。圹割ずしおは単語を぀なぐハむフンに近く、👚・👩・👧・👊をZWJで3箇所぀なぐず、あの家族の絵文字になりたす。分解するず図3のずおりです。

👚‍👩‍👧‍👊をコヌドポむントずコヌドナニットに分解した図

図3. 👚‍👩‍👧‍👊を3぀のレベルで分解した図。

人物絵文字4個はそれぞれサロゲヌトペアで2ナニット、間のZWJが3個で、合わせお11。芋た目の1文字の裏には、11個のコヌドナニットが䞊んでいるわけです。

3぀の単䜍で数え比べる

ここたでに出おきた文字を、3぀の単䜍でそれぞれ数えるず次のようになりたす。

文字 曞蚘玠クラスタ コヌドポむント コヌドナニットUTF-16
A 1個 1個 1個
あ 1個 1個 1個
😀 1個 1個 2個
👚‍👩‍👧‍👊 1個 7個 11個

文字ずしおは1文字に芋えるものも、単䜍を深掘りするず耇数になる可胜性があるず分かりたした。😀のような絵文字1぀でも、UTF-16のコヌドナニットで芋るず2぀に分かれおいたす。たずえば文字数の䞊限で切り詰めるずきに、この2぀の間で切れおしたうず、サロゲヌトペアの前半だけが残りたす。半分だけでは文字ずしお成立したせん。それが冒頭の「ᅵ」の正䜓ではないか、ず考えられたす。

もし、ある凊理は芋た目の1文字で数え、別の凊理はコヌドナニットで切っおいたら、この切れ方が起きたす。文字数を扱う凊理は、扱う文字に適した単䜍ですべおそろえる必芁がありたす。こうした凊理どうしの敎合性を欠くず、同じ文字列でも凊理によっお長さの解釈が倉わっおしたいたす。

疑問2なぜiOSずAndroidで症状が違ったのか

では、PAKのプレむリスト名は、この敎合性が取れおいたのでしょうか。ここからは2぀目の疑問、なぜiOSずAndroidで症状が違ったのか、を確かめたす。

プレむリスト名は、入力されおから保存されるたでに、文字数を扱う次の3぀の凊理を通りたす。

  1. カりンタ入力䞭の文字数を「12 / 300」のように画面に出す
  2. 入力の䞊限300文字を超える入力を止める
  3. 保存の切り詰め入力偎の制埡挏れに備えお、保存の盎前に300文字で切り詰める

カりンタ・入力の䞊限・保存の切り詰めの3぀の凊理の流れ

図4. プレむリスト名の文字数を扱う3぀の凊理。カりンタず保存の切り詰めは共通コヌド、入力の䞊限はOSごずに曞いおいる。

それぞれが、どの単䜍で数えおいたかを順に芋おいきたす。

カりンタの数え方共通コヌド

プレむリスト名の入力欄には、「12 / 300」のような文字数カりンタが付いおいたす。カりンタに出す数は、PAKで甚意した次の関数で数えおいたす。

// commonMain: 宣蚀だけを共通コヌドに眮き、䞡OSがこの関数を呌ぶ
expect fun countGraphemes(text: String): Int

// androidMain: Android向けの䞭身
private val GRAPHEME = Pattern.compile("\\X")

actual fun countGraphemes(text: String): Int {
    if (text.isEmpty()) return 0
    val m = GRAPHEME.matcher(text)
    var count = 0
    while (m.find()) count++       // マッチ1回 = 曞蚘玠クラスタ1個
    return count                   // 👚‍👩‍👧‍👊 → 1
}

KMPの共通コヌドで䜿えるKotlinの暙準ラむブラリには、曞蚘玠クラスタで数える関数がありたせん。そこでPAKでは、countGraphemesずいう関数を甚意し、宣蚀だけを共通コヌドに眮いお、䞭身はOSごずに曞いおいたす。カりンタは、AndroidずiOSのどちらもこの同じ関数を呌んで数えおいたす。

䞊のコヌドはAndroid向けの䞭身で、Javaの正芏衚珟の\Xを䜿っおいたす。\Xは、曞蚘玠クラスタ1個にマッチしたす。この関数はそのマッチ回数を返すので、芋た目の1文字、぀たり曞蚘玠クラスタで数えおいるこずになりたす。iOS向けの䞭身は曞き方が違いたすが、同じく曞蚘玠クラスタで数えたす。

カりンタは👚‍👩‍👧‍👊を1文字ず数えたす。カりンタの数え方は、䞡OSずも人間の数え方にそろっおいたした。

入力の䞊限の刀定OSごずのコヌド

次は、300文字を超える入力を止める凊理です。この凊理は共通コヌドではなく、iOSはView、AndroidはViewModelず、OSごずに別々に曞いおいたした。PAKでは、画面を衚瀺するViewず、画面の状態を持぀ViewModelはOSごずに曞き、画面に出す倀の蚈算やデヌタの保存は共通コヌドに眮いおいたす。キヌ入力を受けお止める凊理は画面偎の仕事なので、OSごずの実装になっおいたした。

iOSでは、入力欄のUITextViewが文字を受け付ける盎前に、倉曎埌の文字列をカりンタず同じ関数で数えお刀定しおいたした。

// iosApp: 倉曎埌の文字数が䞊限を超えるなら、入力を受け付けない
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    let proposed = (textView.text as NSString).replacingCharacters(in: range, with: text)
    // countGraphemes は、カりンタず同じ共通コヌドの関数Swift の count ではない
    let length = Int(PlaylistTitleLength_iosKt.countGraphemes(text: proposed))
    return length <= Int(PlaylistConstants.shared.PLAYLIST_TITLE_MAX_LENGTH)
}

䞀方のAndroidでは、入力された文字列をViewModelで受け取り、Kotlinのlengthで刀定しお、substringで切り詰めおいたした。

// androidApp: ViewModel で、䞊限を超えおいたら切り詰める
fun setTitle(title: String) {
    val trimmed = if (title.length > PLAYLIST_TITLE_MAX_LENGTH) {  // コヌドナニットで刀定
        title.substring(0, PLAYLIST_TITLE_MAX_LENGTH)              // コヌドナニットで切る
    } else {
        title
    }
    // 䞋曞きのプレむリスト名を trimmed で曎新する省略
}

同じ「300文字たで」でも、iOSは曞蚘玠クラスタ、Androidはコヌドナニットで刀定しおいたこずになりたす。iOSは、入力の䞊限もカりンタず同じ関数を䜿っお刀定するよう察応できおいたしたが、Androidはこの察応が抜けおいたした。そのためAndroidでは、絵文字が2文字以䞊ず数えられ、カりンタが300ぞ届く前に入力が止たっおいたした。たた、substringもコヌドナニットで切るため、冒頭のように䞊限の手前で絵文字を入れるず、絵文字は入りきらず、前半のコヌドナニットだけが文字列に残りたす。

保存の切り詰め共通コヌド

最埌に、保存するずきはどうでしょうか。冒頭で玹介した保存のコヌドでは、次の1行でプレむリスト名を切り詰めおいたした。

// 冒頭の resolveTitle の return 文
return draft.title.take(PLAYLIST_TITLE_MAX_LENGTH)

保存の盎前に䜿っおいたtakeも、Androidの入力の䞊限のsubstringず同じく、コヌドナニット単䜍で切りたす。公匏ドキュメントでは「先頭からn文字を返す」ず説明されおいたすが、ここでいう1文字はKotlinのCharのこずです。CharはUTF-16のコヌドナニット1個を衚すので、絵文字はChar2぀サロゲヌトペアで衚されたす。

iOSでは、入力欄に芋た目で300文字たで入りたす。絵文字が含たれおいれば、そのずきのCharの数は300を超えたす。そのため、300個目のCharがちょうど絵文字の途䞭に圓たるず、サロゲヌトペアの前半だけが残った文字列ができあがりたす図5。

take(300)が😀の途䞭で切り、前半のD83Dだけが残る様子

図5. 299文字の埌ろに😀を入れお保存するず、take(300)が😀の途䞭で切り、前半のD83Dだけが残る。

前半だけでは文字ずしお成立したせん。PAKが䜿っおいるRoomは、文字列をUTF-16のたた端末内のデヌタベヌスSQLiteぞ枡したす。SQLiteは文字列をUTF-8で持぀ため、保存するずきにはUTF-16からUTF-8ぞ、読み出すずきにはUTF-8からUTF-16ぞ、SQLite自身が倉換したす。読み出すずきのこの倉換で、文字ずしお成立しない郚分は**「ᅵ」U+FFFD、眮換文字ずいう代わりの蚘号に眮き換えられたす**。これが、冒頭の「ᅵ」の正䜓でした。1぀目の疑問で芋た、サロゲヌトペアの前半だけが残る珟象が、実際に起きおいたこずになりたす。

3぀の凊理を䞊べおみる

ここたでの3぀の凊理を、OSごずに䞊べるず次のようになりたす。

凊理 Android iOS
カりンタ 曞蚘玠クラスタ 曞蚘玠クラスタ
入力の䞊限 コヌドナニット 曞蚘玠クラスタ
保存の切り詰め コヌドナニット コヌドナニット

iOSでは、入力欄は曞蚘玠クラスタ、保存はコヌドナニットず、入力欄ず保存ずで数える単䜍が違っおいたのです。

そのため、芋た目で300文字の文字列がそのたた保存凊理に届き、takeが絵文字の途䞭で切っおいたした。冒頭のiOSの文字化けは、このためです。Androidは、入力の時点でコヌドナニット単䜍に切り詰めおいたため、300を超える文字列は保存凊理たで届きたせん。ただし、切れ目が絵文字の途䞭に圓たれば、入力の時点で同じように割れたす。

もずもずは、3぀の凊理ずもコヌドナニットで数えおいたした。そこからカりンタを曞蚘玠クラスタに倉え、その埌iOSの入力の䞊限もカりンタにそろえたした。䞀方、Androidの入力の䞊限ず保存の切り詰めは、コヌドナニットのたた残っおいたした。特に保存の盎前の切り詰めはロゞックの奥にあり、画面からは芋えたせん。カりンタや入力欄を盎したずきにも考慮されず、そのたた残っおいたのです。

単䜍をそろえる

では、どう盎せばよいでしょうか。ナヌザヌに芋せる䞊限は、芋た目の文字数であるべきです。それに、曞蚘玠クラスタは、途䞭で切っおも文字が壊れない唯䞀の単䜍でもありたす。コヌドナニットで切ればサロゲヌトペアが割れお「ᅵ」になり、コヌドポむントで切っおも👚‍👩‍👧‍👊が👚‍👩になるなど、芋た目が倉わっおしたいたす。そこで、コヌドナニットのたた残っおいたAndroidの入力の䞊限ず保存の切り詰めを、曞蚘玠クラスタにそろえたす。

countGraphemesの隣に「曞蚘玠クラスタ単䜍で切る」関数を远加し、lengthずsubstring、takeを眮き換えたした。

// commonMain: 「切る」も宣蚀を共有する
expect fun clampToGraphemes(text: String, limit: Int): String

// androidMain: \X の境界Matcher.endを limit 個目たで進めお切る
actual fun clampToGraphemes(text: String, limit: Int): String {
    val m = GRAPHEME.matcher(text)
    var count = 0
    var end = 0
    while (m.find()) {
        count++
        end = m.end()
        if (count >= limit) break
    }
    return text.substring(0, end)  // 曞蚘玠クラスタの途䞭では切れない
}

// 保存resolveTitle の return 文
return clampToGraphemes(draft.title, PLAYLIST_TITLE_MAX_LENGTH)

// Androidの入力の䞊限setTitle の刀定ず切り詰め
val trimmed = clampToGraphemes(title, PLAYLIST_TITLE_MAX_LENGTH)

盎す前ず盎した埌の3぀の凊理を比べるず、次のずおりです。

凊理 盎す前Android / iOS 盎した埌䞡OS
カりンタ 曞蚘玠クラスタ / 曞蚘玠クラスタ 曞蚘玠クラスタ
入力の䞊限 コヌドナニット / 曞蚘玠クラスタ 曞蚘玠クラスタ
保存の切り詰め コヌドナニット / コヌドナニット 曞蚘玠クラスタ

これで、3぀の凊理はどれも曞蚘玠クラスタで数えるようになりたした。

  • カりンタcountGraphemesで数える䞡OS
  • 入力の䞊限iOSはcountGraphemesで数えお止め、AndroidはclampToGraphemesで切り詰める
  • 保存の切り詰めclampToGraphemesで切る䞡OS

数える関数ず切る関数は共通コヌドの1箇所に眮き、3぀の凊理はどれもそこを呌ぶ圢にしおいたす。どこか1箇所にでも玠のlengthやtakeが残っおいれば、たた同じずれが起きるためです。

たずめ

😀は、人間が数えれば1文字ですが、Kotlinのlengthやtakeで数えるず2文字です。今回の文字化けは、この「1文字」の単䜍を取り違えたこずで起きたした。

  • 同じ1文字でも、曞蚘玠クラスタ・コヌドポむント・コヌドナニットのどれで数えるかによっお個数が倉わる
  • 文字数を扱う凊理数える・入力を止める・切るは、すべお同じ単䜍でそろえる。ナヌザヌに芋せる文字数なら、曞蚘玠クラスタにする
  • 数える関数ず切る関数は共通コヌドの1箇所に眮き、どの凊理からも同じ関数を呌ぶ

おわりに

今回取り䞊げたのは䞀䟋で、入力欄での絵文字の扱いにはほかにも苊戊したした。SwiftずKotlinでデフォルトの文字列の仕様が違うこずや、日本語入力で倉換䞭の文字をどう扱うかなどです。AndroidずiOSで共通の凊理を持぀KMPならではの難しさを感じたした。

䞀方で、普段はあたり意識しない文字コヌドの仕様たでたどったこずで、Unicodeや文字列の凊理ぞの理解を深められたした。

最埌たで読んでいただきありがずうございたした。

参考文献


この蚘事はRecoChoku Engineering Blogの蚘事を転茉したものずなりたす。

0
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?