[Flutter笔记]正确的Dart字符串操作

3,534 阅读9分钟

原文地址:medium.com/dartlang/da…

原文作者:medium.com/@taodong

发布时间:2020年7月1日 - 6分钟阅读

像许多其他编程语言一样,在emojis开始主导我们的日常交流和商业应用中多语言支持兴起之前设计的,Dart将一个字符串表示为UTF-16编码单元的序列。这种编码在大多数情况下都能正常工作,直到国际化程度的提高和与任何语言相匹配的表情符号的引入,使得编码的固有问题变成了大家的问题。

考虑这个例子:

这张图显示的是字符串 "你好",最后是一个挥手的表情符号,是UTF -16编码单位。这个表情符号需要两个单位。 在字符串 "Hello👋"中,除了挥手表情符号👋外,每个用户可感知的字符都被映射到一个代码单位。这种映射的一个直接后果就是混淆了这个字符串的长度。下面这行代码的输出是6还是7?

print('Hello👋'.length)。

对于用户来说,这个字符串中显然有6个字符,除非你搞哲学。但是Dart String API会告诉你,长度是7,准确的说是7个UTF-16代码单位。这种差异有各种各样的影响,因为很多文本操作任务都涉及到使用String API的字符索引。例如,"Hello👋"[5]不会返回👋表情符号。相反,它将返回一个代表表情符号第一个代码单位的畸形字符。

好消息是,Dart有一个新的包,叫做Characters,可以操作用户可感知的字符,而不是UTF-16代码单位。然而,作为一个Dart程序员,你需要知道何时使用字符包。我们的研究表明,即使是有经验的Dart程序员,在读取文本操作代码时也很容易错过这样的问题。在这篇文章中,我将介绍一些常见的情况,在这些情况下,你需要特别注意并考虑使用characters包而不是Dart String

需要注意的场景

在本节中,我将介绍一些常见的文本操作场景,解释为什么使用Dart的String API会在这些场景中出现问题,并展示如何使用characters 包来获得更可靠的结果。下面的用例一般假设我们处理的是人类用户输入的字符串,其中可能包括表情符号或应用开发者不期望的语言字符。

场景1:计算字符串中的字符。

假设你正在编写一个函数,检查用户输入的文本是否超过了特定的字符数。如果没有超过限制,函数将返回正数的剩余字符,如果超过限制,则返回负数的额外字符。

这在使用String API时是非常直接的。

// Implementation using the String API, 
// which counts the number of UTF-16 code units
// instead of user-perceivable characters.
int remainingCapacity(String input, int limit) {
  var length = input.length;
  return limit - length;
}

然而,下面的测试揭示了这段代码的问题。

test('remainingCapacity', (){
  var limit = 140;
  input = 'Laughter 😀 is the sensation of feeling good all over and showing it principally in one place.';
  expect(remainingCapacity(input, limit), equals(47));
});

以下是测试结果

Expected: <47>
  Actual: <46>

我们可以使用characters包重写这个函数,它提供了一个方便的String扩展方法,以产生正确的字符数,如下所示。

int checkMaxLength(String input, int limit) {
  var length = input.characters.length;
  return limit - length;
}

场景2:提取一个子串

在这个场景中,我们想实现一个函数,从一个字符串中删除最后一个字符,并将结果作为一个新的字符串返回。我们假设这个字符串来自于用户输入。

这个函数很容易使用String上的substring方法实现,如下所示。

String skipLastChar(String text) {
  return text.substring(0, max(0, text.length - 1));
}

然而,一个好的表情测试可以迅速破坏代码。

test('skipLastChar(text)', () {
  var string = 'Hi 🇩🇰';
  expect(skipLastChar(string), equals('Hi '));
});

以下是测试结果:

Expected: ‘Hi 
  Actual: ‘Hi 🇩???’
    Which: is different. Both strings start the same, but the actual value also has the following trailing characters: 🇩???

characters包可以轻松处理这种情况,因为它提供了 skipLast(int count) 等高级方法。我们可以将这个片段改写成下面的代码。

String skipLastChar(String text) {
  return text.characters.skipLast(1).toString();
}

场景3:在表情符号上拆分一个字符串。

在第三种情况下,我们想在给定的表情符号上拆分一个字符串。下面是一个使用String上的split方法来实现的函数。

List splitEmojiSeparatedWords(String text, String separator) {
  return text.split(separator);
}

它能行吗?可能99%的时间都能正常工作,但下面的测试说明了一个例子,上面的代码会产生相当令人惊讶的结果。

test('splitEmojiSeparatedWords(String text, String separator)', () {
   var text = 'abc👨‍👩‍👧‍👦👧abc👧abc👧abc';
   var separator = '👧';
   List<String> expected = ['abc👨‍👩‍👧‍👦', 'abc', 'abc', 'abc'];
   expect(td.splitEmojiSeparatedWords(text, separator), equals(expected));
});

以下是测试结果。

Expected: ['abc👨‍👩‍👧‍👦', 'abc', 'abc', 'abc']
  Actual: ['abc👨‍👩‍','‍👦', 'abc', 'abc', 'abc']
    Which: was 'abc👨‍👩‍' instead of 'abc👨‍👩‍👧‍👦' at location [0]

那么,为什么👨‍👩‍👧‍👦当字符串被拆分后,会变成两个表情符号👨‍👩 ?因为👨‍👩‍👧‍👦其实是由四个不同的表情组成的。👨👩👧👦. 当从👧的位置进行字符串拆分时,"abc👨‍👩‍👧‍👦"被拆成了两部分:"abc👨"和"👦"。

你可以通过在Characters类上使用split方法来避免这个问题,如下代码所示。

List<String> splitEmojiSeparatedWords(String text, String separator) {
  // Split returns an iterable, which we need to convert to a list.
  return [...text.characters.split(separator.characters)]; 
}

场景4:通过索引访问一个特定的字符。

在文本操作中,通常通过字符串中的索引(即位置)来访问特定字符。例如,这段代码显示了一个函数,该函数从用户在两个独立的文本字段中输入的名字和姓氏中返回首字母。

String createInitials(String firstName, String lastName) {
    return firstName[0].toUpperCase() + lastName[0].toUpperCase();
}

但正如我们在文章开头所演示的那样,在基于UTF-16的字符串中使用索引可能是有风险的。让我们用下面的测试用例来验证上述代码的正确性。

test("createInitials(firstName, lastname)", () {
    var firstName = 'étienne';
    var lastname = 'bézout';
    expect(td.createInitials(firstName, lastname), equals('ÉB'));
});

以下是测试结果

Expected: ‘ÉB’
  Actual: ‘EB’
    Which: is different.

为什么考试会失败呢?是因为字母 "É "可能是 "E "和重音符号的组合。你可以使用字符包来轻松避免这个问题。

String createInitials(String firstName, String lastName) {
  return '${firstName.characters.first}${lastName.characters.first}';
}

练习: 省略文本溢出

现在,给你一个挑战。在这个场景中,应用程序需要显示一个消息列表,每行一个。您需要审查实现一个函数的代码,该函数在消息长度超过给定字符限制时,将文本溢出显示为省略号。

String textOverflowEllipsis(String text, int limit) {
  if (text.length > limit) {
    return text.substring(0, limit - 3) + '…';
  } else {
    return text;
  }
}

你能想出一个测试来揭示这个代码段的潜在问题吗?你将如何使用characters包重写它?答案就在本文的最后。

缓解措施和可能的长期解决方案

指望Dart用户对上述各种陷阱保持高度警惕是不合理的。例如,在我们进行的一项实验中,53.7%的Dart用户无法检测到第一个场景中所说明的问题(计算字符),即使他们在几分钟前收到了两页关于characters包和该包所要解决的问题的信息。因此,我们正在采取一种两阶段的方法来帮助开发者选择最适合他们文本操作需求的API。

在短期内,我们正在Flutter框架和Dart分析器中引入一套缓解措施,以使characters包更容易在Dart UI编程中发现和调用。这包括几个步骤:

  1. TextField widget的内部实现中使用characters包。更多的细节请看这个PR这个设计文档

  2. 通过Flutter框架暴露characters包的API。一旦完成这项工作,Flutter用户将有更大的机会通过扩展方法String.characters发现API,当对String进行自动完成时,该方法将显示出来。这项工作的状态在本期追踪:github.com/flutter/flu…

  3. 更新Flutter框架的API文档和示例代码,建议在适用的时候使用Characters类,比如在TextField.onChanged的回调中。这项工作在github.com/flutter/flu… 中有跟踪,相关细节在本文档中

  4. 当自动完成处理用户输入文本的回调模板时,让Dart分析器建议将String对象转换为Characters对象。例如,IDE可以在用户在onChanged上自动完成后填写下面片段中的所有内容。这项工作在github.com/dart-lang/s… 中进行了跟踪。

TextField(
  onChanged: (String value) {
  // Converting String to Characters to handle emojis 
  // and non-English characters more robustly.
  var myText = value.characters;      
  }
)

这些缓解措施可以提供帮助,但它们仅限于在Flutter项目的上下文中执行的字符串操作。我们需要在它们可用后仔细衡量它们的有效性。Dart语言层面更完整的解决方案很可能需要迁移至少一些现有的代码,尽管一些选项(例如,静态扩展类型)可能会使中断变化变得可控。需要更多的技术调查来充分理解其中的权衡。

您能提供的帮助

请帮助我们提高对如何使用characters包修复字符串问题的认识:

  • 在你的代码中寻找使用String.lengthString.substring的例子。如果字符串可能来源于用户输入,请尝试使用characters包重写代码。
  • 与Dart社区的其他人分享这篇文章。
  • 尝试更新StackOverflow上现有的关于Dart文本操作的答案。如果被接受的答案遗漏了String API的这一限制,提醒人们注意风险。
  • 在上面列出的GitHub问题上发表评论,让我们知道你的想法和意见。

现在,祝大家编码愉快😉!

鸣谢

感谢Kathy Walrath、]Lasse Nielsen](medium.com/@lrhn)和[Mic… Thomson](medium.com/@mit.mit)对本… String API的这一限制的挑战。


PS:这里是习题的解题思路。

// Prerequisite: add the characters package as a dependency in your pubspec.yaml.
import 'package:characters/characters.dart';

void main(List<String> arguments) {
  print(textOverflowEllipsis('😸cats', 10));
  print(textOverflowEllipsis('🦏rhinoceroses', 10));
}

// This function converts text overflow to an ellipsis
// when the text's length exceeds the given character limit.
String textOverflowEllipsis(String text, int limit) {
  var myChars = text.characters;
  if (myChars.length > limit) {
    return '${myChars.take(limit - 1)}…';
  } else {
    return text;
  }
}

感谢Kathy Walrath。


通过( www.DeepL.com/Translator )(免费版)翻译