为什么像 ‍‍‍ 这样的表情符号字符在 Swift 字符串中被如此奇怪地对待?

Why are emoji characters like ‍‍‍ treated so strangely in Swift strings?

字符‍‍‍(有两个女人,一个女孩,一个男孩的家庭)编码如下:

U+1F469 WOMAN,
‍U+200D ZWJ,
U+1F469 WOMAN,
U+200D ZWJ,
U+1F467 GIRL,
U+200D ZWJ,
U+1F466BOY

所以它的编码非常有趣;单元测试的完美目标。不过,Swift好像不知道怎么治疗。这就是我的意思:

"‍‍‍".contains("‍‍‍") // true
"‍‍‍".contains("") // false
"‍‍‍".contains("\u{200D}") // false
"‍‍‍".contains("") // false
"‍‍‍".contains("") // true

所以,Swift 说它包含自己(好)和一个男孩(好!)。但它接着说它不包含女人、女孩或零宽度木工。 这里发生了什么?为什么 Swift 知道它包含一个男孩而不是一个女人或女孩? 如果它把它当作一个单独的字符并且只识别它包含自己,我可以理解,但事实上它有一个子组件没有其他人让我感到困惑。

如果我使用 "".characters.first! 这样的东西,这不会改变。


更令人困惑的是:

let manual = "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
Array(manual.characters) // ["‍", "‍", "‍", ""]

即使我将 ZWJ 放在那里,它们也没有反映在字符数组中。接下来是一个小故事:

manual.contains("") // false
manual.contains("") // false
manual.contains("") // true

所以我得到了与字符数组相同的行为...这非常烦人,因为我知道数组的样子。

如果我使用 "".characters.first!.

这也不会改变

似乎 Swift 认为 ZWJ 是一个扩展的字素簇,其前面有一个字符。我们可以在将字符数组映射到它们的 unicodeScalars:

时看到这一点
Array(manual.characters).map { [=10=].description.unicodeScalars }

这会从 LLDB 打印以下内容:

▿ 4 elements
  ▿ 0 : StringUnicodeScalarView("‍")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"
  ▿ 1 : StringUnicodeScalarView("‍")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"
  ▿ 2 : StringUnicodeScalarView("‍")
    - 0 : "\u{0001F467}"
    - 1 : "\u{200D}"
  ▿ 3 : StringUnicodeScalarView("")
    - 0 : "\u{0001F466}"

此外,.contains 将扩展的字形簇组合成单个字符。例如,取韩文字符 (组合起来构成 "one" 的韩文单词:한):

"\u{1112}\u{1161}\u{11AB}".contains("\u{1112}") // false

这找不到 因为三个代码点被分组到一个集群中作为一个字符。同样,\u{1F469}\u{200D} (WOMAN ZWJ) 是一个簇,作为一个字符。

这与 String 类型在 Swift 中的工作方式以及 contains(_:) 方法的工作方式有关。

“‍‍‍”是所谓的表情符号序列,呈现为字符串中的一个可见字符。该序列由Character个对象组成,同时由UnicodeScalar个对象组成。

如果检查字符串的字符数,您会发现它由四个字符组成,而如果检查 unicode 标量数,则会显示不同的结果:

print("‍‍‍".characters.count)     // 4
print("‍‍‍".unicodeScalars.count) // 7

现在,如果您解析这些字符并将它们打印出来,您会看到看似正常的字符,但实际上前三个字符在 UnicodeScalarView:

for char in "‍‍‍".characters {
    print(char)

    let scalars = String(char).unicodeScalars.map({ String([=11=].value, radix: 16) })
    print(scalars)
}

// ‍
// ["1f469", "200d"]
// ‍
// ["1f469", "200d"]
// ‍
// ["1f467", "200d"]
// 
// ["1f466"]

如您所见,只有最后一个字符不包含零宽度连接符,因此当使用 contains(_:) 方法时,它会按您预期的那样工作。由于您不与包含零宽度连接符的表情符号进行比较,因此该方法不会找到除最后一个字符之外的任何匹配项。

对此进行扩展,如果您创建一个由以零宽度连接符结尾的表情符号字符组成的 String,并将其传递给 contains(_:) 方法,它也会计算至 false。这与 contains(_:)range(of:) != nil 完全相同有关,后者试图找到与给定参数的精确匹配。由于以零宽度连接符结尾的字符形成不完整的序列,因此该方法尝试在将以零宽度连接符结尾的字符组合成完整序列时为参数找到匹配项。这意味着如果出现以下情况,该方法将永远找不到匹配项:

  1. 参数以零宽度连接符结尾,并且
  2. 要解析的字符串不包含不完整的序列(即以零宽度连接符结尾且后面没有兼容字符)。

演示:

let s = "\u{1f469}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}" // ‍‍‍

s.range(of: "\u{1f469}\u{200d}") != nil                            // false
s.range(of: "\u{1f469}\u{200d}\u{1f469}") != nil                   // false

但是,由于比较只是向前看,您可以通过向后工作在字符串中找到其他几个完整的序列:

s.range(of: "\u{1f466}") != nil                                    // true
s.range(of: "\u{1f467}\u{200d}\u{1f466}") != nil                   // true
s.range(of: "\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}") != nil  // true

// Same as the above:
s.contains("\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}")          // true

最简单的解决方案是为 range(of:options:range:locale:) 方法提供特定的比较选项。选项 String.CompareOptions.literal 精确逐个字符等价 进行比较。作为旁注,此处字符的意思是 而不是 Swift Character,而是实例和比较字符串的 UTF-16 表示形式——但是,因为 String 不允许格式错误的 UTF-16,这基本上等同于比较 Unicode 标量表示。

这里我已经重载了Foundation方法,所以如果你需要原来的方法,重命名这个或者别的什么:

extension String {
    func contains(_ string: String) -> Bool {
        return self.range(of: string, options: String.CompareOptions.literal) != nil
    }
}

现在,即使序列不完整,该方法也能像 "should" 每个字符一样工作:

s.contains("")          // true
s.contains("\u{200d}")  // true
s.contains("\u{200d}")    // true

第一个问题是您要使用 contains 桥接到 Foundation(Swift 的 String 不是 Collection),所以这是 NSString 行为,我不认为它能像 Swift 那样有效地处理组合的表情符号。也就是说,Swift 我相信现在正在实施 Unicode 8,这也需要围绕 Unicode 10 中的这种情况进行修改(所以当他们实施 Unicode 10 时这可能会全部改变;我还没有深入研究它是否会).

为了简化事情,让我们摆脱 Foundation,并使用 Swift,它提供更明确的视图。我们将从字符开始:

"‍‍‍".characters.forEach { print([=10=]) }
‍
‍
‍

好的。这就是我们所期望的。但这是一个谎言。让我们看看这些角色到底是什么。

"‍‍‍".characters.forEach { print(String([=11=]).unicodeScalars.map{[=11=]}) }
["\u{0001F469}", "\u{200D}"]
["\u{0001F469}", "\u{200D}"]
["\u{0001F467}", "\u{200D}"]
["\u{0001F466}"]

啊……原来是["ZWJ", "ZWJ", "ZWJ", ""]。这让一切都变得更加清晰了。不是此列表的成员(它是 "ZWJ"),但是是成员。

问题是 Character 是一个 "grapheme cluster,",它把东西组合在一起(比如附加 ZWJ)。您真正要搜索的是一个 unicode 标量。这完全符合您的预期:

"‍‍‍".unicodeScalars.contains("") // true
"‍‍‍".unicodeScalars.contains("\u{200D}") // true
"‍‍‍".unicodeScalars.contains("") // true
"‍‍‍".unicodeScalars.contains("") // true

当然我们也可以寻找其中的实际字符:

"‍‍‍".characters.contains("\u{200D}") // true

(这在很大程度上重复了 Ben Leggiero 的观点。我在注意到他已经回答之前发布了这个。离开以防任何人都清楚。)

其他答案讨论了 Swift 的作用,但没有详细说明原因。

你认为“Å”等于“Å”吗?我希望你会。

其中一个是带有组合符的字母,另一个是单个组合字符。您可以将许多不同的组合器添加到一个基本角色,而人类仍然会认为它是一个角色。为了处理这种差异,创建了字素的概念来表示人类对字符的看法,而不管使用的代码点如何。

多年来,短信服务一直在将字符组合成图形表情符号 :)</code>。所以各种表情符号被添加到 Unicode。<br> 这些服务还开始将表情符号组合成复合表情符号。<br> 当然,没有合理的方法将所有可能的组合编码成单独的代码点,因此 Unicode 联盟决定扩展字素的概念以涵盖这些复合字符。</p> <p>这归结为 <code>"‍‍‍" 如果您尝试在字素级别使用它,则应将其视为单个 "grapheme cluster",默认情况下 Swift 会这样做。

如果你想检查它是否包含 "" 作为其中的一部分,那么你应该下降到较低的级别。


我不知道 Swift 语法,所以这里有一些 Perl 6,它对 Unicode 的支持水平相似。
(Perl 6 支持 Unicode 版本 9,因此可能存在差异)

say "\c[family: woman woman girl boy]" eq "‍‍‍"; # True

# .contains is a Str method only, in Perl 6
say "‍‍‍".contains("‍‍‍")    # True
say "‍‍‍".contains("");        # False
say "‍‍‍".contains("\x[200D]");  # False

# comb with no arguments splits a Str into graphemes
my @graphemes = "‍‍‍".comb;
say @graphemes.elems;                # 1

让我们再往下一层

# look at it as a list of NFC codepoints
my @components := "‍‍‍".NFC;
say @components.elems;                     # 7

say @components.grep("".ord).Bool;       # True
say @components.grep("\x[200D]".ord).Bool; # True
say @components.grep(0x200D).Bool;         # True

虽然下降到这个级别可能会使一些事情变得更难。

my @match = "‍‍‍".ords;
my $l = @match.elems;
say @components.rotor( $l => 1-$l ).grep(@match).Bool; # True

我假设 Swift 中的 .contains 使这更容易,但这并不意味着没有其他事情变得更困难。

在这个级别工作可以更容易地在复合字符的中间不小心拆分字符串。


您无意中问的是,为什么这种高级表示不像低级表示那样工作。答案当然是,不应该。

如果你问自己“为什么要这么复杂”,答案当然是“人类”。

Swift 4.0更新

String 在 Swift 4 更新中进行了大量修改,如 SE-0163 中所述。 此演示使用两个表情符号代表两种不同的结构。两者都与一系列表情符号相结合。

</code> 是两个表情符号的组合,<code></code></p> <p><code>‍‍‍是四个emoji的组合,零宽连接。格式为 ‍joiner‍joiner‍joiner

1.计数

在 Swift 4.0 中,表情符号被算作字素簇。每个表情符号都算作1个。 count 属性 也可以直接用于字符串。所以你可以直接这样调用。

"".count  // 1. Not available on swift 3
"‍‍‍".count  // 1. Not available on swift 3

字符串的字符数组在Swift 4.0中也被算作字素簇,所以下面的两个代码都打印1。这两个表情符号是表情符号序列的例子,其中几个表情符号组合在一起 or它们之间没有零宽度连接符 \u{200d}。在 swift 3.0 中,此类字符串的字符数组将每个表情符号分开,并生成一个包含多个元素(表情符号)的数组。在此过程中忽略连接器。然而,在 Swift 4.0 中,字符数组将所有表情符号视为一个整体。所以任何表情符号的那个永远是1.

"".characters.count  // 1. In swift 3, this prints 2
"‍‍‍".characters.count  // 1. In swift 3, this prints 4

unicodeScalars在Swift中保持不变 4.它提供了给定字符串中唯一的Unicode字符。

"".unicodeScalars.count  // 2. Combination of two emoji
"‍‍‍".unicodeScalars.count  // 7. Combination of four emoji with joiner between them

2。包含

在 Swift 4.0 中,contains 方法忽略表情符号中的零宽度连接符。因此,对于 "‍‍‍" 的四个表情符号组件中的任何一个,它 return 为真,如果您检查连接器,则 return 为假。但是,在Swift 3.0中,joiner并没有被忽略,而是与前面的emoji组合在一起。因此,当您检查 "‍‍‍" 是否包含前三个组成部分的表情符号时,结果将为 false

"".contains("")       // true
"".contains("")        // true
"‍‍‍".contains("‍‍‍")       // true
"‍‍‍".contains("")       // true. In swift 3, this prints false
"‍‍‍".contains("\u{200D}") // false
"‍‍‍".contains("")       // true. In swift 3, this prints false
"‍‍‍".contains("")       // true

表情符号与 unicode 标准非常相似,看似复杂。肤色、性别、工作、人群、零宽度连接符序列、标志(2 个字符的 unicode)和其他复杂情况会使表情符号解析变得混乱。一棵圣诞树、一片披萨或一堆便便都可以用单个 Unicode 代码点表示。更不用说在引入新表情符号时,iOS 支持和表情符号发布之间存在延迟。那以及 iOS 的不同版本支持不同版本的 unicode 标准这一事实。

TL;DR. 我已经研究了这些功能并开源了一个库,我是 JKEmoji 的作者,以帮助解析带有表情符号的字符串。它使解析变得如此简单:

print("I love these emojis ‍‍‍".emojiCount)

5

它通过定期刷新所有识别的表情符号的本地数据库到最新的 unicode 版本(12.0 最近)并将它们与 运行 OS 版本通过查看无法识别的表情符号字符的位图表示。

注意

之前的一个回答因在没有明确说明我是作者的情况下为我的图书馆做广告而被删除。我再次确认这一点。