这是一个很古老且不为人知的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属性的,只有fgcolorbgcolor
此时读取颜色属性的方式是XmlUtils.convertValueToUnsignedInt(),字面意思是将颜色属性转换成无符号的int类型(Android中的颜色使用AARRGGBB的4字节表示)。本质是用long接收然后强转int截断:

(int) Long.parseLong(value.substring(index), base)`

所以在使用fgcolorbgcolor时,必须使用长度为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取反是等价的,所以0xff0000ff0xffff01可以使用相同的算法互相转换。

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));

References