问题源于一个很奇怪的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版本修改说明:
主要是第二点,大意是替代形式的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我们可以看图:
简而言之,变种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
如同上面提到的,如果想用代理对来表示:
- 0x1F31D需要先减掉0x10000,得到0x0F31D
- 表示出来就是
0000 1111 0011 0001 1101
- 我们得到高10位是
00 0011 1100
即0x003c,高位需要加上0xD800,合起来就是0xD83c - 接下来是低10位
11 0001 1101
即0x031D,低位需要加上0xDC00,所以是DF1D - 最终得到这个变种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)
这步转化中出现了错误,导致乱码。
在寻找答案的过程中,我没能找到对于编码规则很熟悉的朋友,对于文章中的错误希望大家帮忙指正!!谢谢
参考资料
- 维基百科 UTF-8
- Android 9 行为变更
- Unicode中UTF-8与UTF-16编码详解
- emoji 大全
- modified-utf-8
- Unicode字符集的编码方式 (UTF-8, UTF-16, UTF-32)