Android 9中utf8乱码问题

问题源于一个很奇怪的bug:Android 9.0机型上,直播间评论列表刷新后的emoji符号会乱码,在iphone以及Android 9.0以下机型并没有出现。起初怀疑是因为后台返回字段存在差异,定位后发现是Android 9.0关于utf8编码的规则修改导致。

先说结论

在Android 9中,系统认为变种形式的utf8是非法的(例如代理对形式),而这种形式在一些后台系统的储存时还在使用(例如mysql,是使用两个字节储存字符的);一般的字符不会出现问题,但是emoji的unicode编码是不在基本平面BMP上的(0x10000+),导致了系统使用代理模式储存,最后返回终端的byte数组无法成功转换导致的。

复现路径

有兴趣的同学可以根据以下代码,在Android 9.0以及其他机型上测试,前者是会乱码的

byte[] source = new byte[]{-19, -96, -68, -19, -68, -99, -27, -68, -96, -28, -72, -119, -27, -78, -127, -23, -128, -127, -25, -69, -103, 48, 55, -27, -65, -85, -28, -71, -112, -25, -108, -73, -27, -93, -80, 32, 52, -28, -72, -86, -26, -81, -108, -27, -65, -125, 32};
String printData = new String(source);
Log.d("TAG", "result = " + printData);

问题原因

以下图片来自Android 9.0版本修改说明

Android 9关于utf8问题的修改

主要是第二点,大意是替代形式的utf8被视为格式不正确

这个点我一开始相当迷糊,因为网上关于他的资料比较少,同时代理对不是utf16的概念么?后来找到维基百科-UTF-8,有这么一个说法:

变种UTF-8
第二个不同点是基本多文种平面之外字符的编码的方法。在标准UTF-8中,这些字符使用4字节形式编码,而在修正的UTF-8中,这些字符和UTF-16一样首先表示为代理对(surrogate pairs),然后再像CESU-8那样按照代理对分别编码。这样修正的原因更是微妙。Java中的字符为16位长,因此一些Unicode字符需要两个Java字符来表示。语言的这个性质盖过了Unicode的增补平面的要求。尽管如此,为了要保持良好的向后兼容、要改变也不容易了。这个修正的编码系统保证了一个已编码字符串可以一次编为一个UTF-16码,而不是一次一个Unicode码点。不幸的是,这也意味着UTF-8中需要4字节的字符在变种UTF-8中变成需要6字节。
因为变种UTF-8并不是UTF-8,所以用户在交换信息和使用互联网的时候需要特别注意不要误把变种UTF-8当成UTF-8数据。

在这里我的理解是:unicode标准中的utf8是没有代理对的说法的,但是一些系统中(例如java,想要储存0x10000,一个2byte的char是储存不下的,所以需要拆成两个)对他进行了修改,也即是上面维基百科提到的变种utf8。

事实上Unicode标准永久保留U+D800 ~ U+DFFF作为UTF-16编码的高低代理,它们不会被赋值,也不应该对它们编码。Unicode标准指出,没有UTF格式(包括UTF-16)编码它们。

关于utf16我们可以看图:


utf16 转换算法

简而言之,变种utf8是在原先的utf8基础上加上了utf16的代理对概念将字符一分为二,我们称之为变种utf8。但是在Android 9上这种编码规则被认为是非法的(ill-formed),所以显示出现了乱码。

玩味:举个栗子

上面举的复现路径,其实只有前面两个是emoji,表达为U+1F31D,用utf8表示出来就是

11110000 10011111 10001100 10011101

大家熟知utf8的编码规则,如果是超出一个字节的表示,则第一个字节前面n个1就是用了n个字节,后面的n-1个字节的开头都是10,所以解码单独拎出来就是
0001 1111 0011 0001 1101
也就是上面的1F31D

如同上面提到的,如果想用代理对来表示:

  1. 0x1F31D需要先减掉0x10000,得到0x0F31D
  2. 表示出来就是0000 1111 0011 0001 1101
  3. 我们得到高10位是00 0011 1100即0x003c,高位需要加上0xD800,合起来就是0xD83c
  4. 接下来是低10位 11 0001 1101即0x031D,低位需要加上0xDC00,所以是DF1D
  5. 最终得到这个变种utf8表示:0xD83c 0xDF1D,大家可以试试\uD83c\uDF1D,粘到TextView里就能显示了

总结:我们一个想要表达的emoji的unicode编码为U+1F31D,使用变种utf8表示则是\uD83c\uDF1D。

问题所在代码

我们与后台交互的时候传递的是byte字节流,终端在解析String字段的时候是这么写的:

public String readString(int tag, boolean isRequire) {
//忽略无关代码
    try {
            // 这里就是问题出现的代码,直接交给了String类解析byte[]
            s = new String(ss, this.sServerEncoding);
        } catch (UnsupportedEncodingException var9) {
            s = new String(ss);
        }
            
        return s;
    }

如代码中标注,在9.0之前可以直接这么写,但是9.0上将会解析失败,认为是非法utf8(is treated as ill-formed

代码上的解决方法

  • 第一种,我喜欢自己动手
    外网网友提供的经验,参照之前版本String解析的方法,首先将byte[]转化为char,由我们自己来解析代理模式的utf8,也就是处理代理模式。代码如下:
static final char REPLACEMENT_CHAR = (char) 0xfffd;
    public static char[] byteArrayToCharArray(byte[] data) {

        char[] value;
        final int offset = 0;
        final int byteCount = data.length;

        char[] v = new char[byteCount];
        int idx = offset;
        int last = offset + byteCount;
        int s = 0;
        outer:
        while (idx < last) {
            byte b0 = data[idx++];
            if ((b0 & 0x80) == 0) {
                // 0xxxxxxx
                // Range:  U-00000000 - U-0000007F
                int val = b0 & 0xff;
                v[s++] = (char) val;
            } else if (((b0 & 0xe0) == 0xc0) || ((b0 & 0xf0) == 0xe0) ||
                    ((b0 & 0xf8) == 0xf0) || ((b0 & 0xfc) == 0xf8) || ((b0 & 0xfe) == 0xfc)) {
                // 看下使用几个字节
                int utfCount = 1;
                if ((b0 & 0xf0) == 0xe0) utfCount = 2;
                else if ((b0 & 0xf8) == 0xf0) utfCount = 3;
                else if ((b0 & 0xfc) == 0xf8) utfCount = 4;
                else if ((b0 & 0xfe) == 0xfc) utfCount = 5;
                // 110xxxxx (10xxxxxx)+
                // Range:  U-00000080 - U-000007FF (count == 1)
                // Range:  U-00000800 - U-0000FFFF (count == 2)
                // Range:  U-00010000 - U-001FFFFF (count == 3)
                // Range:  U-00200000 - U-03FFFFFF (count == 4)
                // Range:  U-04000000 - U-7FFFFFFF (count == 5)

                // 如果加上字节数,已经超过流的结尾,那么说明最后这个解码有误,我们加上不可识别的标志
                if (idx + utfCount > last) {
                    v[s++] = REPLACEMENT_CHAR;
                    continue;
                }
                // Extract usable bits from b0
                int val = b0 & (0x1f >> (utfCount - 1));
                for (int i = 0; i < utfCount; ++i) {
                    byte b = data[idx++];
                    if ((b & 0xc0) != 0x80) {
                        v[s++] = REPLACEMENT_CHAR;
                        idx--; // Put the input char back
                        continue outer;
                    }
                    // Push new bits in from the right side
                    val <<= 6;
                    val |= b & 0x3f;
                }
                // Note: Java allows overlong char
                // specifications To disallow, check that val
                // is greater than or equal to the minimum
                // value for each count:
                //
                // count    min value
                // -----   ----------
                //   1           0x80
                //   2          0x800
                //   3        0x10000
                //   4       0x200000
                //   5      0x4000000
                // Allow surrogate values (0xD800 - 0xDFFF) to
                // be specified using 3-byte UTF values only
                if ((utfCount != 2) && (val >= 0xD800) && (val <= 0xDFFF)) {
                    v[s++] = REPLACEMENT_CHAR;
                    continue;
                }
                // Reject chars greater than the Unicode maximum of U+10FFFF.
                if (val > 0x10FFFF) {
                    v[s++] = REPLACEMENT_CHAR;
                    continue;
                }
                // Encode chars from U+10000 up as surrogate pairs
                if (val < 0x10000) {
                    v[s++] = (char) val;
                } else {
                    int x = val & 0xffff;
                    int u = (val >> 16) & 0x1f;
                    int w = (u - 1) & 0xffff;
                    int hi = 0xd800 | (w << 6) | (x >> 10);
                    int lo = 0xdc00 | (x & 0x3ff);
                    v[s++] = (char) hi;
                    v[s++] = (char) lo;
                }
            } else {
                // Illegal values 0x8*, 0x9*, 0xa*, 0xb*, 0xfd-0xff
                v[s++] = REPLACEMENT_CHAR;
            }
        }
        if (s == byteCount) {
            // We guessed right, so we can use our temporary array as-is.
            value = v;
        } else {
            // Our temporary array was too big, so reallocate and copy.
            value = new char[s];
            System.arraycopy(v, 0, value, 0, s);
        }
        return value;
    }
  • 第二种,java支持变种的utf8,需要使用DataInput进行解析
    modified-utf-8

总结

在文本传输中,emoji在后台储存的格式为变种utf8,但是Android 9.0不允许此类格式的解码,因此在new String(byte[] data)这步转化中出现了错误,导致乱码。

在寻找答案的过程中,我没能找到对于编码规则很熟悉的朋友,对于文章中的错误希望大家帮忙指正!!谢谢

参考资料

  1. 维基百科 UTF-8
  2. Android 9 行为变更
  3. Unicode中UTF-8与UTF-16编码详解
  4. emoji 大全
  5. modified-utf-8
  6. Unicode字符集的编码方式 (UTF-8, UTF-16, UTF-32)
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容

  • 在当前这个时代(比如说公元2016年),如果你并不是在维护历史遗留的文本处理代码,没有在每个地方都使用Unicod...
    纵横而乐阅读 2,750评论 3 16
  • 编码问题一直困扰着开发人员,尤其在 Java 中更加明显,因为 Java 是跨平台语言,不同平台之间编码之间的切换...
    x360阅读 2,464评论 1 20
  • 之前突然发现自己对字符编码还是一知半解,基本上只是听说过各种编码的名字,对它们之间的特点和区别还是不甚了解。所以这...
    L_Zephyr阅读 1,850评论 0 4
  • 慢慢的都会远,渐渐的都会淡,拥有时,好好珍惜,离开了,默默祝福。
    酒分阅读 42评论 0 0
  • 会很想念高中… 也最怀恋某年 那时春夏秋冬 空气自由清新 窗外和铃声 他人和笑脸 我沉睡了一个夏天 …
    烟抄阅读 168评论 0 4