AndroidでのStringのバイト数による切り捨てについて


Stringのバイト数による切り捨ての実装は、NIOを駆使した方法が推奨のようです。
しかしながら、日本語で紹介される巷の実装は、Android環境上では期待通りに動作しないようです。

## NIOによる巷の実装と問題点
Googleで検索すると以下のページが上位にヒットします。
http://qiita.com/ota-meshi/items/16972156c935b8b7feaa
http://d.hatena.ne.jp/kameid/20090314/1237025305

これらの実装は、大まかに以下の流れです。
* CharBuffer から ByteBufferへ、エンコードを行う。
* エンコードがOverflow(ByteBufferへの書き込み範囲超過)で打ち切られる。
* その時点の CharBufferの先頭からpositionまでの範囲でString化する。

この処理をテストしてみると以下の結果でした。
* 入力文字列: "あいうえお"
* 指定文字セット: UTF-8
* 切り取りサイズ: 14Byte (15Byte以上は切り捨て)
※ UTF-8での平仮名は1文字=3Byte。 

| テスト・タイプ | 実行環境 | 結果 |
|---|---|---|
| ローカル ユニット テスト| AndroidStudioのOpenJDK | "あいうえ"|
| インストルメント化されたテスト | Androidエミュレータ | ”あいうえお”|

上述の実装は、Overflowでエンコードが打ち切られた時点で、CharBufferのpositionが4であることを想定した作りになっています。しかしAndroidのエミュレータ又は実機では、Overflowでエンコードが打ち切られた時点で、CharBufferのpositionが5まで進むため、切り捨てが期待通りに動作しない結果でした。

念のためCharsetEncoderのpositionに関する記載を確認してみると、それほどの厳密な説明では無いようです。書き込み側のpositionはOverflow時に末尾に位置している事が期待できそうですが、読み込み側は、各EncoderのOverflow判定の実装次第で変化しそう。と思いました。

> https://docs.oracle.com/javase/jp/8/docs/api/java/nio/charset/CharsetEncoder.html
> バッファの位置は、読み取られた文字数または書き込まれたバイト数に従って増加しますが、

## 解決版の実装

Googleで検索を英語圏まで手を伸ばすと以下のページが紹介されていました。
https://theholyjava.wordpress.com/2007/11/02/truncating-utf-string-to-the-given/

ここでの実装は、大まかに以下の流れです。
エンコード/デコード完了時における読み込み側のpositionには依存しない処理となっています。
* 入力String から 指定サイズのByteBufferへ、エンコードを行う。(指定サイズでの切り捨てが行われる)
* ByteBuffer から CharBufferへ、デコードを行う。(末尾の不正バイトは切り捨てされる)
* CharBufferの内容をString化する。

これを参考に、早期の切り捨て不要判定も取り入れた実装を以下に紹介します。

```java

    public static String truncate(String text, int capacity) {
        if (text == null || capacity < 0) {
            throw new IllegalArgumentException("invalid parameter.");
        }

        Charset charset = StandardCharsets.UTF_8;
        CharsetEncoder encoder = charset.newEncoder()
                .onMalformedInput(CodingErrorAction.IGNORE)
                .onUnmappableCharacter(CodingErrorAction.IGNORE)
                .reset();
        // step 0.
        int estimate = text.length() * (int) Math.ceil(encoder.maxBytesPerChar());
        if (estimate <= capacity) {
            return text;
        }

        // step 1.
        ByteBuffer srcBuffer = ByteBuffer.allocate(capacity);
        CoderResult result = encoder.encode(CharBuffer.wrap(text), srcBuffer, true);
        encoder.flush(srcBuffer);
        srcBuffer.flip();
        if (result.isUnderflow()) {
            return text;
        }

        // step 2.
        CharBuffer dstBuffer = CharBuffer.allocate(text.length());
        CharsetDecoder decoder = charset.newDecoder()
                .onMalformedInput(CodingErrorAction.IGNORE)
                .onUnmappableCharacter(CodingErrorAction.IGNORE)
                .reset();
        decoder.decode(srcBuffer, dstBuffer, true);
        decoder.flush(dstBuffer);
        dstBuffer.flip();
        // step 3.
        return dstBuffer.toString();
    }
```


## テストコード

```java
    @Test
    public void truncate() throws Exception {
        // 1Byte文字
        String testA = "abcde";
        String testA_len0 = "";
        String testA_len1 = "a";
        String testA_len4 = "abcd";
        String testA_len5 = "abcde";
        assertThat(StringUtil.truncate(testA, 0), is(testA_len0));
        assertThat(StringUtil.truncate(testA, 1), is(testA_len1));
        assertThat(StringUtil.truncate(testA, 4), is(testA_len4));
        assertThat(StringUtil.truncate(testA, 5), is(testA_len5));

        // 3Byte文字
        String testB = "あいうえお";
        String testB_len0 = "";
        String testB_len1 = "あ";
        String testB_len4 = "あいうえ";
        String testB_len5 = "あいうえお";
        assertThat(StringUtil.truncate(testB, 0), is(testB_len0));
        assertThat(StringUtil.truncate(testB, 2), is(testB_len0));
        assertThat(StringUtil.truncate(testB, 3), is(testB_len1));
        assertThat(StringUtil.truncate(testB, 14), is(testB_len4));
        assertThat(StringUtil.truncate(testB, 15), is(testB_len5));

        // 4Byte文字
        // 5文字分
        // https://www.softel.co.jp/blogs/tech/archives/596
        String testC = "\uD840\uDC0B\uD844\uDE3D\uD844\uDF1B\uD845\uDC6E\uD846\uDCBD";
        String testC_len0 = "";
        String testC_len1 = "\uD840\uDC0B";
        String testC_len4 = "\uD840\uDC0B\uD844\uDE3D\uD844\uDF1B\uD845\uDC6E";
        String testC_len5 = "\uD840\uDC0B\uD844\uDE3D\uD844\uDF1B\uD845\uDC6E\uD846\uDCBD";
        assertThat(StringUtil.truncate(testC, 3), is(testC_len0));
        assertThat(StringUtil.truncate(testC, 4), is(testC_len1));
        assertThat(StringUtil.truncate(testC, 19), is(testC_len4));
        assertThat(StringUtil.truncate(testC, 20), is(testC_len5));

        // 1Byte文字 と 3Byte文字 の組み合わせ
        String testD = "AあBいCうDえEお";
        String testD_len1 = "A";
        String testD_len2 = "Aあ";
        String testD_len9 = "AあBいCうDえE";
        String testD_len10 = "AあBいCうDえEお";
        assertThat(StringUtil.truncate(testD, 1), is(testD_len1));
        assertThat(StringUtil.truncate(testD, 3), is(testD_len1));
        assertThat(StringUtil.truncate(testD, 4), is(testD_len2));
        assertThat(StringUtil.truncate(testD, 19), is(testD_len9));
        assertThat(StringUtil.truncate(testD, 20), is(testD_len10));

        // 絵文字
        // 日本国旗, BATH
        // U+1F1EF U+1F1F5, U+1F6C0
        // 4+4Byte + 4Byte
        // http://qiita.com/_sobataro/items/47989ee4b573e0c2adfc
        String testE = "\uD83C\uDDEF\uD83C\uDDF5\uD83D\uDEC0";
        String testE_len0 = "";
        String testE_len1 = "\uD83C\uDDEF";
        String testE_len2 = "\uD83C\uDDEF\uD83C\uDDF5";
        String testE_len3 = "\uD83C\uDDEF\uD83C\uDDF5\uD83D\uDEC0";
        assertThat(StringUtil.truncate(testE, 3), is(testE_len0));
        assertThat(StringUtil.truncate(testE, 4), is(testE_len1));
        assertThat(StringUtil.truncate(testE, 7), is(testE_len1));
        assertThat(StringUtil.truncate(testE, 8), is(testE_len2));
        assertThat(StringUtil.truncate(testE, 11), is(testE_len2));
        assertThat(StringUtil.truncate(testE, 12), is(testE_len3));

        // Stringの文字列長チェック
        assertEquals(1 + 1 + 1 + 1 + 1, testA.length());
        assertEquals(1 + 1 + 1 + 1 + 1, testB.length());
        assertEquals(2 + 2 + 2 + 2 + 2, testC.length());
        assertEquals(2 + 2 + 2, testE.length());
    }
```