这是一个很古老且不为人知的Bug,而且这个Bug的的work-around非常有趣。
通常我们可以在 res/string.xml
中配置部分文字的颜色,但是从Android4.3开始到6.0之前这种方式存在Bug,
font标签的颜色无法正常工作,标签内容会变成透明或者白色,例如下面例子中的 “Hello” 在6.0之前无法显示。
<string name="sample"><font color="#0000ff">Hello</font>, World!</string>
此 Bug 的历史
Bug 出现前
Android的font标签在一开始是没有color
属性的,只有fgcolor
和bgcolor
。
此时读取颜色属性的方式是XmlUtils.convertValueToUnsignedInt(),字面意思是将颜色属性转换成无符号的int类型(Android中的颜色使用AARRGGBB
的4字节表示)。本质是用long接收然后强转int截断:
(int) Long.parseLong(value.substring(index), base)`
所以在使用fgcolor
和bgcolor
时,必须使用长度为8的AARRGGBB的颜色格式,因为使用长度为6的RRGGBB格式在截断后高位补0,最终得到的是透明色 0x000000ff
。
Bug 诞生
在Android4.3以后font标签新增了color
属性,它是fgcolor
的另一种写法,bug就是在这时候诞生的。
Android 4.3 修改了font标签颜色属性的读取方式,使用 Color.getHtmlColor() 读取。它的核心代码是 XmlUtils.convertValueToInt(),也就是将颜色直接转成int了。
Integer.parseInt(nm.substring(index), base)
上面这行代码用来解析颜色会出现个问题,Integer的取值范围是 (-0x7fffffff, 0x7fffffff]
,如果使用超出这个范围的值去解析会抛出 NumberFormatException
,所以解析AARRGGBB的颜色 #ff0000ff
时就抛异常了,内部捕获异常后返回了个 -1,也就是白色 0xffffffff
。但也无法使用RRGGBB的颜色格式来解决这个问题,因为高位会补0变成透明。
这个Bug在6.0以下看起来好像无解,无论是AARRGGBB还是RRGGBB的颜色格式都无法正确解析。但还是有人提出了一个天才般的work-around,容我在这里先卖个关子。
Bug 修复后
这个Bug从Android 4.3开始一经出现就收到反馈,但直到Android 6.0 才解决,将 Color.getHtmlColor()
换成了 Color.parseColor()
,后者正是使用long截断的方式转换颜色的。
所以如果我们的App最低支持版本小于6.0且使用了font标签的颜色属性,那么我们需要做一个适配。最初我只能想到一些工作量比较大的方式,例如使用Html.fromHtml,直到看到了下面的方案。
美丽而“丑陋”的应急方案
美丽在于此方案实施起来简单有效,且具有非一般的想象力。 丑陋在于此方案的提供者主观上说它丑,客观上来说使用颜色补码确实不直观,至少在看到
#-ffff01
想不到这是蓝色。
有人想出了一个天才般的应急方案,利用计算机的二进制补码机制。
Android中的颜色使用AARRGGBB的32位二进制表示,也就是说它还是在 int 的范围内的,只是受限于 Integer.parseInt()
数值范围检测,
它只允许 (-0x7fffffff, 0x7fffffff]
范围内的值。
但值得一提的是,Integer.parseInt()
是支持负数解析的,例如前面提到的蓝色 #ff0000ff
就可以用负数的 -0x00ffff01
表示,
利用到了计算机二进制补码的机制,第一次看到此方案的时候就感叹作者的天才思路。
另外在这个方案中可以使用-RRGGBB的颜色格式 #-ffff01
,因为高位是0可以忽略。
-0xffff01
的补码就是 0xff0000ff
,由前者取反后加1
得到。而取反加1
和减1取反
是等价的,所以0xff0000ff
和0xffff01
可以使用相同的算法互相转换。
Android 6.0 及以后版本的适配
上面方案解决了6.0以下的颜色属性渲染问题,但在6.0及以后的版本还是存在问题,原因在于Color.parseColor()
方法有判断字符串长度必须为 7 或 9。
public static int parseColor(String colorString) {
if (colorString.charAt(0) == '#') {
// Use a long to avoid rollovers on #ffXXXXXX
long color = Long.parseLong(colorString.substring(1), 16);
if (colorString.length() == 7) {
// Set the alpha value
color |= 0x00000000ff000000;
} else if (colorString.length() != 9) {
throw new IllegalArgumentException("Unknown color");
}
return (int)color;
} else {
Integer color = sColorNameMap.get(colorString.toLowerCase(Locale.US));
if (color != null) {
return color;
}
}
throw new IllegalArgumentException("Unknown color");
}
所以蓝色 #0000ff
我们只能用 #-0ffff01
表示,在最高位补一个0凑够9位。
方案规则总结
- RRGGBB 格式的颜色需要利用补码转换,如前面的
#ff0000ff
->#-0ffff01
- AARRGGBB 格式的颜色,如果透明通道在
[0x00 ~ 0x7f]
范围内不需要转换,例如30%透明度的蓝色#4d0000ff
。 - AARRGGBB 格式的颜色透明通道如果不在
[0x00 ~ 0x7f]
范围内无解,因为Color.parseColor()
有长度限制,无法对包含透明通道的颜色做补码处理。
最后我们可以用一行简单的代码来辅助我们进行RRGGBB的补码计算:
System.out.println("#-0" + Integer.toHexString(~0x0000ff + 1).substring(2));