🙈

AndroidのKotlin(Java)におけるUTF-16エンコーディングについて

2022/12/26に公開

こんにちは。株式会社SunAsteriskでモバイルエンジニアなど色々をやっているundoroidです。
AndroidでUTF-16エンコーディングを指定した場合の挙動にAndroidOSのバージョン差異があるのですが、意外と知られていないので書くことにしました。

はじめに

文中コードのプログラミング言語はKotlinとなります。
本題に入るまでの説明が長いので結論から読みたい方は本題まで飛んでください。

前提知識:UTF-16についての説明

文字コードについては非常に奥が深く、誰もが何らかの問題に当たりやすい領域なので一度は参考図書を読んでおくことをおすすめします。
参考図書:矢野啓介『改訂新版 プログラマのための文字コード技術入門』

UTF-16とは

UTF-16とは、Unicodeの符号化方式(エンコーディング)の一つです。
Unicodeとは世界中の文字を1つの表に収めることを目標とした符号化文字集合で、Unicodeのコードポイント(符号位置)はBMP(基本多言語面)の場合16ビットです。UTF-16はUnicodeのBMPのコードポイントを16ビットで符号化しています。BMP以降の領域についてはサロゲートペアという仕組みを使います。

UnicodeとかBMPやサロゲートペアについて説明すると長いので参考図書を読んでください。

UTF-8とUTF-16の比較(主にバイト効率での)

なんでUTF-16を使ったりするのUTF-8でいいじゃん、という人もいるとおもうので、UTF-8と比べてUTF-16を使うメリットについて、とりあえずわかりやすそうなバイト効率の面で説明しておきます。

UTF-8

  • 8ビット単位の可変長
  • ASCII互換のためASCII文字(abc123など)の場合1バイト
  • ASCII文字以外は2バイト以上。かな、漢字などは大抵3バイトになる
  • サロゲートペアは4バイト

UTF-16

  • 16ビット単位の可変長
  • ASCII非互換でASCII文字の場合も2バイト
  • かな、BMPに入る範囲の漢字なども2バイトになる
  • サロゲートペアは4バイト

このような特徴があるため
ASCII文字を多く使う場合、UTF-8でエンコーディングするとASCII文字が1バイトで済むためバイト効率が良いです。一方、UTF-16の場合は2バイト必要になります。

val asciiStr = "abcd"
asciiStr.toByteArray(StandardCharsets.UTF_8).size // => 4
asciiStr.toByteArray(StandardCharsets.UTF_16BE).size // => 8 (BE等については後述)

逆に、日本語などマルチバイト文字を多く含む場合はUTF-8でマルチバイト文字をエンコーディングすると1文字あたり3バイトを使いますがUTF-16は2バイトで済むのでUTF-16のほうがバイト効率が良いです。

val hiraganaStr = "あいうえ"
hiraganaStr.toByteArray(StandardCharsets.UTF_8).size // => 12
hiraganaStr.toByteArray(StandardCharsets.UTF_16BE).size // => 8

この理由で、なのかどうかはわかりませんが、UTF-16は時々ライブラリの内部で使われたり通信仕様に指定されていたりします。
ちなみにJavaでは内部の文字エンコーディングとして使われていたりします(歴史的経緯によるものだと思うけど)

UTF-16におけるエンディアンとBOM

UTF-16では16ビットを8ビット単位でバイト列にした際に、上位8ビットから先に並べるか、下位8ビットから先に並べるかというバイト順(byte-order)の問題が存在します。

上位8ビットから並べる方式をビッグエンディアン(big-endian、BE)、下位8ビットから並べる方式をリトルエンディアン(little-endian、LE)と呼び、UTF-16の場合はそれぞれUTF-16BE、UTF-16LEと略されいることが多いです。

例えば、「亜」の場合、UnicodeでのコードポイントはU+4E9Cです。
これをBEにすると「4E,9C」ですが、LEの場合は「9C,4E」とバイト順が逆転します。

// 16進数で見やすくするためにtoHex()を定義
fun ByteArray.toHex(): String = joinToString(separator = " ") {
    "%02X".format(it)
}

val aStr = "亜"
aStr.toByteArray(StandardCharsets.UTF_16BE).toHex() // => 4E 9C
aStr.toByteArray(StandardCharsets.UTF_16LE).toHex() // => 9C 4E

このどちらの方式になっているかを区別するための印として、先頭2バイトにBOM(byte-order mark)を付けることがあります。

  • BOM付きUTF-16BEの場合FE FF  「亜」の場合FE FF 4E 9C
  • BOM付きUTF-16LEの場合FF FE  「亜」の場合FF FE 9C 4E

AndroidのKotlin(Java)の場合、BOMがない場合はBEとして処理されます。

When decoding, the UTF-16 charset interprets the byte-order mark at the beginning of the input stream to indicate the byte-order of the stream but defaults to big-endian if there is no byte-order mark
https://developer.android.com/reference/java/nio/charset/Charset.html

ちなみにこれはUnicodeの仕様ですが、すべてが準拠しているわけではないので注意。Windows系、というかIntel系はLEが多いらしい
https://qiita.com/libraplanet/items/b477261d669c1fa108e2#リトル-エンディアン-非準拠陣営

UTF-16のパターン整理

つまりUTF-16エンコードにはバイト順×BOM有無で以下4パターン存在する、ということ。

  • BOM付きUTF-16BE
  • BOM付きUTF-16LE
  • BOMなしUTF-16BE
  • BOMなしUTF-16LE

本題:AndroidのKotlin(Java)でUTF-16を指定してエンコードした場合の挙動

やっと本題です。
では、単にUTF-16とだけ指定して
String#toByteArray(StandardCharsets.UTF_16)
String#toByteArray(Charset.forName("UTF-16"))
を使い、エンコーディングした場合のバイト順、BOM有無はどうなるのでしょうか?

まずはドキュメントを見ましょう。

when encoding, it uses big-endian byte order and writes a big-endian byte-order mark.
https://developer.android.com/reference/java/nio/charset/Charset.html

BOM付きのBEになると書いてあります。
しかしこれは正確ではありません。

正確には

  • Android8.0以下の場合はBOM付きUTF-16LE
  • Android8.1以上の場合はBOM付きUTF-16BE

となります。

昔からドキュメントにこの「BOM付きのBEになる」という記載はあったのですが、実装が間違っており
Android8.1のリリース時にドキュメントに合わせてしれっと実装が直されました。
Androidのドキュメントはたまに間違ったことが書かれているので注意です。

https://issuetracker.google.com/issues/37074504
https://android-review.googlesource.com/c/platform/libcore/+/437155

サンプル:Android8.0(エミュレータ)

小文字の「a」で試してみます。unicodeのコードポイントはU+0061です。
ちなみにUTF_16BE、UTF_16LEを明示的に指定するとBOM無しのBE、LEになります。

val aStr = "a"
val utf16ByteArray = aStr.toByteArray(StandardCharsets.UTF_16)
val utf16BeByteArray = aStr.toByteArray(StandardCharsets.UTF_16BE) // 比較用 BOM無しBE
val utf16LeByteArray = aStr.toByteArray(StandardCharsets.UTF_16LE) // 比較用 BOM無しLE

Log.i("UTF-16", utf16ByteArray.toHex()) //       => I/UTF-16:    FF FE 61 00
Log.i("UTF-16BE", utf16BeByteArray.toHex()) //   => I/UTF-16BE:  00 61
Log.i("UTF-16LE", utf16LeByteArray.toHex()) //   => I/UTF-16LE:  61 00

バイト順:61が先に来ているのでLE
先頭2バイト:FF FE => LEのBOM

ということで、BOM付きUTF-16LEになります。

サンプル:Android8.1(エミュレータ)

同様のソースコードをAndroid8.1で実行します。

val aStr = "a"
val utf16ByteArray = aStr.toByteArray(StandardCharsets.UTF_16)
val utf16BeByteArray = aStr.toByteArray(StandardCharsets.UTF_16BE) // 比較用 BOM無しBE
val utf16LeByteArray = aStr.toByteArray(StandardCharsets.UTF_16LE) // 比較用 BOM無しLE
Log.i("UTF-16", utf16ByteArray.toHex()) //       => I/UTF-16:    FE FF 00 61
Log.i("UTF-16BE", utf16BeByteArray.toHex()) //   => I/UTF-16BE:  00 61
Log.i("UTF-16LE", utf16LeByteArray.toHex()) //   => I/UTF-16LE:  61 00

バイト順:00が先に来ているのでBE
先頭2バイト:FE FF => BEのBOM

こちらはBOM付きUTF-16BEになります。

変更による影響

指定なしのUTF-16でエンコーディングした場合の挙動変更に伴い、どんな問題が発生するのでしょうか。
基本的にはどちらもBOMがついているので、BOM付きUTF-16BEに対応していればデコードする場合は問題が起きません。
(ちなみにExcelはBOM付きUTF16LEにしか対応してないらしい)

しかし、バイト配列をそのまま扱う場合には問題が発生することがあります。
例えば、ハッシュ化する際の処理でこのバイト列を使っている場合、同じ文字列・同じソースコードにもかかわらずAndroid8.0と8.1でハッシュ値が変わります。
同様に暗号化のための鍵としていた場合も変わるので、他システム間やE2Eで復号できなくなるパターンが存在します。

サンプル:UTF-16エンコード->SHA-256

val aStr = "a"
// UTF-16でエンコーディング
val utf16ByteArray = aStr.toByteArray(StandardCharsets.UTF_16)
// ハッシュ化アルゴリズムを選択
val md = MessageDigest.getInstance("SHA-256")
// エンコーディングしたバイト配列でSHA-256の値を計算
md.update(utf16ByteArray)
val digest = md.digest()
// 出力
Log.i("UTF-16toSHA-256", digest.toHex())

Android8.0の場合
EE 49 9F 13 8A F4 E8 A0 FB 31 17 11 5B 45 36 C3 2A 3B EF 4D 0A D1 5B 99 22 A7 1E B9 0D 3E 74 93

Android8.1の場合
CC B7 5E C7 67 98 AE 70 32 B1 79 58 E8 54 11 BB 5E F8 1C D3 89 49 BB CD FE D5 0A 57 6E 59 BE CB

ぜんぜん違いますね。

回避策

Android8.1以上でBOM付きUTF-16LEにエンコーディングするためにはx-UTF-16LE-BOMを指定すればOKです。これでBOMが付きます。

val utf16ByteArray = aStr.toByteArray(Charset.forName("x-UTF-16LE-BOM"))

Android8.0以下でBOM付きUTF-16BEにする方法はざっと探したところCharset.forName()で指定できる中にはなさそうでした。Unknown encoding: 'x-UTF-16BE-BOM'と怒られる。
UTF-16BEを指定してBOM無しのバイト列を作って先頭にBOMを付与する、とかですかね・・・?
いい方法があれば教えて下さい。

おわりに

文字コードは本当に奥が深くて楽しいです。ぜひ年末はこの壮大な歴史と文化に触れてみてはいかがでしょうか。

Sun* Advent Calendar 2022、25日目は弊社Yusukeによる「最大8プロジェクトを同時にプロジェクト推進してみて見えてきた世界」 をお届けします。

Discussion