天天看点

charAt引发的血案

charAt() 方法用于返回指定索引处的字符。索引范围为从 0 到 length() - 1。

public char charAt(int index)

index – 字符的索引。

事情发生在昨天,今天整理出来。

线上客服爆出“C端APP上的某个促销活动的活动详情无法打开”,通过客户端同学小T查看,该BUG的现象是:同一个活动详情,Android没有报错能展示活动详情(后来发现有一个乱码),而IOS直接报错无法展示任何信息。两端的其他活动详情都正常。

出现这种只有其中一类客户端有异常的情况,我一般判断是这类客户端自身的问题,而不是接口的问题,所以开始我没有详细跟进。

一段时间后,搞IOS的小T挠头抓耳的发给我一堆报错信息:

“Unable to convert hex escape sequence (no high character) to UTF8-encoded character.” UserInfo={NSDebugDescription=Unable to convert hex escape sequence (no high character) to UTF8-encoded character.

然后跟我说,这不是他们IOS的问题,是接口返回的某个字符串有问题,他们没法解析。

接口没有给IOS做特殊返回啊,怎么会发送了没法解析的字符串给IOS呢?况且Android也没有报错呢。

辩论是谁的过错没有意义,解决问题才是王道。

仔细去看小T发给我的报错,初步判断是字符编码的问题。我用浏览器访问接口,观察返回的结果,一小段乱码吸引了我的注意力。看业务,知道这段乱码是已下单用户的昵称。结合小T发给我的异常信息,我感觉问题就是出在乱码这里。

我找到数据库里面这个用户的昵称,发现他的昵称最后带了一个表情“?”,有点与众不同。

再看接口的具体代码,发现代码有对用户的昵称做特殊处理,目的是隐藏用户的昵称信息,具体代码如下:

/*
获取业主昵称的模糊化数据
*/
private String getPreName(UsersDO usersDO) {
  String preName = DEFAULT_NAME;

  if (usersDO != null && StringUtils.isNotBlank(usersDO.getNick())) {
    char first = usersDO.getNick().charAt(0);

    if (usersDO.getNick().length() == 1) {
      preName = first + "**";
    } else {
      preName = first + "**" + usersDO.getNick().charAt(usersDO.getNick().length() - 1);
    }
  }

  return preName;
}
           

代码对昵称做了切割处理,取了第一个字符和最后一个字符,中间变成**,我意识到,应该是这个切割把表情“?”切出问题变成了乱码,这个表情很可能并不是一个单纯的Java的char。遂,我写了一段代码,去验证我的想法:

public static void main(String[] args) {
        //这个testStr实际上就是"特殊的昵称?",我把表情复制上去,就自动变成了橘黄色的Unicode数值
        String testStr = "特殊的昵称\uD83D\uDE0A";

        System.out.println("字符串的长度:" + testStr.length());

        System.out.println("所有字符串如下:");
        for (char c : testStr.toCharArray()) {
            System.out.println(c);
        }
    }

//打印的结果如下:
字符串的长度:7
所有字符串如下:
特
殊
的
昵
称
?
?
           

种种迹象表明这个表情“?”并不是一个单纯的Java char,而是两个Java char,所以usersDO.getNick().charAt(usersDO.getNick().length() - 1)之后,会得出乱码。

这是我的知识盲区啊,以前从来没有担心过这种Java基础API也有“坑”(是我用的不对,并不是真的Java坑)。

看了charAt的源码,理解的不是那么透彻。我祭出:

哪里不懂,Google哪里!

看到一篇来自Umer Mansoor的文章《The char Type in Java is Broken》(原文https://codeahoy.com/2016/05/08/the-char-type-in-java-is-broken/ ),标题就很炸人。说的是Java的char存储位数的问题。看到其中一段话:

char

uses 16 bits to store Unicode characters that fall in the 0 - 65,535 which isn’t enough to store all Unicode characters anymore. You might think: Gee, 65,535 is plenty already. I’ll never use that many. That’s true. But your users will. And when they send you a character that requires more than 16 bits, like these emojis ??, the char methods like someString.charAt(0) or someString.substring(0,1) will break and give you only half the code point. And the worst part is that the compiler won’t even complain. Recently, a fellow developer told me that their “North American users” started complaining that the chat nicknames and messages “aren’t displaying properly”. After a lot of grief, they found the issue and had to undo all

char

manipulation in their software to handle emojis and other cool characters. (Use

codePointAt(index)

instead which returns an int that will fit all Unicode characters in existence.)

加粗的那句话,大概意思是:类似charAt、substring会破坏emoji表情,只返回半个编码。

多方求证,得出个大概:

早期Java版本使用16位char数据类型表示Unicode字符,且能够表示当时所有的Unicode字符。随着时代发展,Unicode后来将最大值增加到1,114,111(0x10FFFF),16位根本存不下了。但与32位值相比,16位值的内存使用效率更高,因此Unicode引入了一种代理对的设计方法来允许继续使用16位值。它使用一个高代理加上一个低代理来表示16位无法表示下的字符。

Java保留了char类型的行为来表示UTF-16值(以便兼容现有程序),它实现了码位的概念来表示 UTF-32 值。这个扩展(根据 JSR 204:Unicode Supplementary Character Support 实现)不需要记住Unicode码位或转换算法的准确值。

如果通过Java API来正确处理char呢?

Java提供了如下API来操作码位(一个码位可以理解成一个肉眼可见的字符,emoji表情也是一个字符,而不是被charAt成两段):

public int codePointCount(int beginIndex, int endIndex);//返回范围内代码点的个数,即多少个Unicode字符

public int offsetByCodePoints(int index, int codePointOffset);//返回从index偏移codePointOffset代码点后的索引

public int codePointAt(int index);//返回字符串指定位置的Unicode代码点的Unicode值

public int codePointBefore(int index);//返回字符串所在索引-1的Unicode代码点

那我们的这个业务需求,其实可以简化为:

在昵称不为空的前提下,计算昵称的代码点总个数:如果代码点只有1个,那取第一个代码点的字符,再在后面补星星;如果代码点超过1个,那取第一个代码点字符,再在后面补星星,最后补上最后一个代码点的字符

结合subString方法,最终代码如下:

/*
该版本,考虑Emoji等超过2字节的字符
*/
private String getPreNameV2(UsersDO usersDO) {
  String preName = DEFAULT_NAME;

  if (usersDO != null && StringUtils.isNotBlank(usersDO.getNick())) {
    String nick = usersDO.getNick();
    String firstUnicodeStr = nick.substring(0, nick.offsetByCodePoints(0, 1));//第一个字符
    int codePointCount = nick.codePointCount(0, nick.length());//一共有多少个代码点

    if (codePointCount == 1) {
      preName = firstUnicodeStr + "**";
    } else {
      preName = firstUnicodeStr + "**" + nick.substring(nick.offsetByCodePoints(0, codePointCount - 1));
    }
  }

  return preName;
}
           

代码改完,已经测试通过上线了。

(Android没有报错,只是显示乱码,我觉得是因为Android同样也是Java体系的原因)

基础不牢地动山摇啊!keep moving

引用的文章

https://codeahoy.com/2016/05/08/the-char-type-in-java-is-broken/

https://www.ibm.com/developerworks/cn/java/j-unicode/index.html

https://codeday.me/bug/20180314/143840.html

http://pclevin.blogspot.com/2011/12/javastring_27.html

http://lugeek.com/2014/10/27/java-string/

https://www.javatpoint.com/post/java-character-codepointat-method

--------猫玛尼分割线--------

公众号:猫玛尼

博客:https://blog.moremoney.ink/

CSDN:https://blog.csdn.net/luoyanjiewade

知乎:https://www.zhihu.com/people/luo-yan-jie-70/activities