Tips. 超長い string リソースによる強制終了について

# 要約
* **Android 8.1 以上** で **10923 文字以上の string リソース** を使用すると強制終了が発生する場合があります
* 解決策としては、超長い文字列を使用する場合はテキストファイル (.txt) などにして res/raw フォルダや assets フォルダから読み込む、もしくは Web ページを作成して表示する、などがあります

# 環境
Nexus 6P
Android Studio 3.1.3

# 経緯
超長い文字列を表示する画面で強制終了する、という現象が発生し調査した結果です。
今回確認したアプリでは約 40000 文字を表示する画面で問題が発生していました。

# 強制終了の内容
レイアウトxmlで下記のように文字列を参照し、画面を表示した際に以下の Exception が発生しアプリが強制終了します。

``` layout.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/tooLongString"/>

</LinearLayout>
```

``` string.xml
<resource>
    <string name="tooLongString">#40000字の文字列#</string>
</resource>
```

``` Logcat.(ΦωΦ)
8427-8427/com.sample.docrash E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.sample.docrash, PID: 8427
    java.lang.RuntimeException: Unable to start activity ComponentInfo{com.sample.docrash /com.sample.docrash.MainActivity}: android.view.InflateException: Binary XML file line #7: Binary XML file line #7: Error inflating class TextView
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2778)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2856)
        at android.app.ActivityThread.-wrap11(Unknown Source:0)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1589)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:164)
        at android.app.ActivityThread.main(ActivityThread.java:6494)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:807)
     Caused by: android.view.InflateException: Binary XML file line #7: Binary XML file line #7: Error inflating class TextView
     Caused by: android.view.InflateException: Binary XML file line #7: Error inflating class TextView
     Caused by: java.lang.IndexOutOfBoundsException
        at android.content.res.StringBlock.nativeGetString(Native Method)
        at android.content.res.StringBlock.get(StringBlock.java:82)
        at android.content.res.AssetManager.getPooledStringForCookie(AssetManager.java:332)
        at android.content.res.TypedArray.loadStringValueAt(TypedArray.java:1272)
        at android.content.res.TypedArray.getText(TypedArray.java:175)
        at android.widget.TextView.<init>(TextView.java:1144)
        at android.widget.TextView.<init>(TextView.java:820)
        at android.support.v7.widget.AppCompatTextView.<init>(AppCompatTextView.java:76)
        at android.support.v7.widget.AppCompatTextView.<init>(AppCompatTextView.java:72)
        at android.support.v7.app.AppCompatViewInflater.createTextView(AppCompatViewInflater.java:176)
        at android.support.v7.app.AppCompatViewInflater.createView(AppCompatViewInflater.java:101)
        at android.support.v7.app.AppCompatDelegateImplV9.createView(AppCompatDelegateImplV9.java:1035)
        at android.support.v7.app.AppCompatDelegateImplV9.onCreateView(AppCompatDelegateImplV9.java:1092)
        at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:772)
        at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:730)
        at android.view.LayoutInflater.rInflate(LayoutInflater.java:863)
        at android.view.LayoutInflater.rInflateChildren(LayoutInflater.java:824)
        at android.view.LayoutInflater.inflate(LayoutInflater.java:515)
        at android.view.LayoutInflater.inflate(LayoutInflater.java:423)
        at android.view.LayoutInflater.inflate(LayoutInflater.java:374)
        at android.support.v7.app.AppCompatDelegateImplV9.setContentView(AppCompatDelegateImplV9.java:287)
        at android.support.v7.app.AppCompatActivity.setContentView(AppCompatActivity.java:139)
        at com.sample.docrash.MainActivity.onCreate(MainActivity.java:11)
        at android.app.Activity.performCreate(Activity.java:6999)
        at android.app.Activity.performCreate(Activity.java:6990)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1214)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2731)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2856)
        at android.app.ActivityThread.-wrap11(Unknown Source:0)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1589)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:164)
        at android.app.ActivityThread.main(ActivityThread.java:6494)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:807)
```

レイアウトxmlの `android:text` による参照以外にも下記で参照すると問題が発生します。

* `Context.getString(int)`  
* `TextView.setText(int)`

# 問題の発生条件を確認
何文字の文字列リソースを使用すると強制終了問題が発生するのか確認してみました。
android は [UTF\-8](https://ja.wikipedia.org/wiki/UTF-8) を使用しているので、半角英数字記号は 1文字が 1byte 文字で、日本語などは 1文字が 3byte です。
半角英数字記号のみの文字列と、日本語のみの文字列で問題が発生する文字数は以下のようになりました。

||問題が発生する文字数|
|:--:|:--:|
|1byte 文字のみ|**32768** 文字以上|
|3byte 文字のみ|**10923** 文字以上|

# 解決方法
3byte 文字を考慮して **10923** 文字以上の string リソースを作成しないようにします。

## res/raw, assets で管理
テキストファイル (.txt) などを作成してファイルから文字列を取得します。
どんなに長い文字列でも1ファイルで済むため管理が楽です。

多言語化する場合には assets ではなく raw を使用して raw-ja のようにロケール設定を行います。assets では assets-ja などのロケール設定ができないためです。

``` 簡素に作ったres/rawファイルの文字列取得.java
public static String getTextRaw(Context context, @RawRes int rawId) {
    Resources res = context.getResources();
    StringBuilder strBuild = new StringBuilder();
    try {
        InputStream is = res.openRawResource(rawId);
        BufferedReader br = new BufferedReader(new InputStreamReader(is));
        String line;
        while ((line = br.readLine()) != null) {
            strBuild.append(line).append("\n");
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return strBuild.toString();
}
```

## アプリ内ブラウザで表示
表示する文字列が WEB ページとして存在している場合には、[WebView](https://developer.android.com/reference/android/webkit/WebView) や [Chrome Custom Tab](https://qiita.com/droibit/items/66704f96a602adec5a35) を使用して WEB ページそのものをアプリ内で表示してしまうのも一つの手です。
(例. 利用規約やプライバシポリシーなど)

表示する文字列はアプリ側にリソースとして含める必要がなく、Web ページ側を変更すればアプリ内の文字列を更新できるメリットもあります。

# 備考
Android 8.1 でこの問題が発生するかどうかは適用されているセキュリティパッチによるようです。
何もセキュリティパッチを適用していない素の Android 8.1 の場合は上記の問題が発生しないことを確認しています。
Android 8.1 用の最新のセキュリティパッチをあてると上記の問題が発生しますが、いつ頃のセキュリティパッチからこの問題が発生するのかは未調査です。