原文:kodeco: Regular Expressions Tutorial: Getting Started
正则表达式基础
如果您以前没有听说过正则表达式(regular expression,简称为 regex),那么在继续本教程之前,可能值得您先了解一下基础知识。幸运的是,我们为您保驾护航!请在此处查看正则表达式简介。
在 iOS 中实现正则表达式
现在您已经了解了基础知识,是时候在应用程序中使用正则表达式了。
使用本教程顶部或底部的 "下载材料" 按钮下载入门项目。在 Xcode 中打开 iRegex 入门项目并运行它。
您将为您的老板(超级恶棍)构建一个日记应用程序!每个人都知道超级恶棍需要跟踪他们统治世界的所有邪恶计划,对吗?有很多计划要做,而你,作为小黄人,是这些计划的一部分 - 你的职责是为其计划构建应用程序!
该应用程序的 UI 已基本完成,但该应用程序的核心功能依赖于正则表达式,而它目前还没有!
在本教程中,您的工作是将所需的正则表达式添加到此应用程序中,以使其发光(并希望避免被扔进一桶炽热的岩浆中)。
以下是展示最终产品的一些示例屏幕截图:

最终的应用程序将涵盖正则表达式的两个常见用例:
- 执行文本搜索:高亮显示以及搜索和替换。
- 验证用户输入。
您将从实现正则表达式最直接的使用开始:文本搜索。
实现搜索和替换
以下是该应用程序的搜索和替换功能的基本概述:
- 搜索视图控制器
SearchViewController
有一个只读的UITextView
视图,其中包含老板私人日记的摘录。 - 导航栏包含一个搜索按钮,该按钮将以模态方式呈现
SearchOptionsViewController
。 - 这将允许你邪恶的老板在输入框中输入信息并点击 "搜索"。
- 然后,应用程序将关闭 Search 视图,并在 Text View 中高亮显示日记中的所有匹配项。
- 如果您的老板在
SearchOptionsViewController
中选择了 "替换" 选项,则应用程序将对文本中的所有匹配项执行搜索和替换功能,而不是高亮显示匹配的结果。
注意:您的应用程序使用
UITextView
的NSAttributedString
属性来高亮显示搜索结果。 您还可以使用 Text Kit 实现高亮显示功能。请务必查看 Swift 中的 Text Kit 教程以了解更多信息。
还有一个阅读模式按钮,可以高亮显示日记中每个条目之间的所有日期、时间和分隔符。为了简单起见,您不会涵盖文本中可能出现的日期和时间字符串的所有可能格式。您将在本教程的最后实现此高亮显示功能。
让搜索功能发挥作用的第一步是将表示正则表达式的标准字符串转换为 NSRegularExpression
对象。
打开 SearchOptionsViewController.swift
。 SearchViewController
以模态方式呈现此视图控制器,并允许用户输入他的搜索(和可选替换)术语,以及指定搜索是否应区分大小写或仅匹配整个单词。
查看文件顶部的 SearchOptions
结构。 SearchOptions
是一个简单的结构体,封装了用户的搜索选项。该代码将 SearchOptions
的实例传递回 SearchViewController
。如果能够直接使用它来构造适当的 NSRegularExpression
那就太好了。您可以通过向 NSRegularExpression
添加自定义初始值设定项和扩展来实现此目的。
选择文件 ▸ 新建 ▸ 文件...,然后选择 Swift 文件。将文件命名为 RegexHelpers.swift
。打开新文件并添加以下代码:
swift
extension NSRegularExpression {
convenience init?(options: SearchOptions) throws {
let searchString = options.searchString
let isCaseSensitive = options.matchCase
let isWholeWords = options.wholeWords
let regexOption: NSRegularExpression.Options = isCaseSensitive ? [] : .caseInsensitive
let pattern = isWholeWords ? "\\b\(searchString)\\b" : searchString
try self.init(pattern: pattern, options: regexOption)
}
}
此代码向 NSRegularExpression
添加了一个便捷初始化方法。它使用传入的 SearchOptions
实例中的各种属性来正确配置。
需要注意的是:
- 每当用户请求不区分大小写(case-insensitive)的搜索时,正则表达式都会使用
NSRegularExpressionOptions
枚举类型中的.caseInsensitive
。NSRegularExpression
的默认行为是执行区分大小写(case-sensitive)的搜索,但是在本例中,您使用的是更用户友好的默认不区分大小写的搜索。 - 如果用户请求全字段搜索,则应用程序会将正则表达式模式包装在
\b
字符中。回想一下,\b
是单词边界字符,因此在搜索模式之前和之后放置\b
会将其变成整个单词搜索(即,模式\bcat\b
将仅匹配单词 "cat",而不是 "catch")。
如果由于某种原因无法创建 NSRegularExpression
,那么初始化器将失败并返回 nil
。现在您已经有了 NSRegularExpression
对象,您可以使用它来匹配文本。
打开 searchViewController.swift
,查找 searchFortext(_:repleastwith:intextView :)
,并将以下实现添加到空白方法中:
swift
// 搜索并替换字符
func searchForText(_ searchText: String, replaceWith replacementText: String, inTextView textView: UITextView) {
if let beforeText = textView.text, let searchOptions = self.searchOptions {
let range = NSRange(beforeText.startIndex..., in: beforeText)
if let regex = try? NSRegularExpression(options: searchOptions) {
let afterText = regex?.stringByReplacingMatches(in: beforeText,
options: [],
range: range,
withTemplate: replacementText)
textView.text = afterText
}
}
}
首先,该方法捕获 UITextView
中的当前文本并计算整个字符串的范围。可以将正则表达式仅应用于文本的一部分,这就是您需要指定范围的原因。在本例中,您使用整个字符串,这将导致正则表达式应用于所有文本。
真正的魔力发生在对 stringByReplacingMatches(in:options:range:withTemplate:)
的调用中。此方法返回一个新字符串,而不改变旧字符串。然后该方法在 UITextView
上设置新字符串,以便用户可以看到结果。
仍然在 SearchViewController
中,找到 highlightText(_:inTextView:)
并添加以下内容:
swift
// 高亮搜索到的字符
func highlightText(_ searchText: String, inTextView textView: UITextView) {
// 1.获取 textView 的 attributedText 的可变副本
let attributedText = textView.attributedText.mutableCopy() as! NSMutableAttributedString
// 2.为整个文本长度创建一个 NSRange,并删除已经在属性文本上设置的背景颜色
let attributedTextRange = NSMakeRange(0, attributedText.length)
attributedText.removeAttribute(NSAttributedString.Key.backgroundColor, range: attributedTextRange)
// 3.使用便捷初始化方法创建正则表达式,并获取 textView.text 中与正则表达式所匹配的数组
if let searchOptions = self.searchOptions,
let regex = try? NSRegularExpression(options: searchOptions) {
let range = NSRange(textView.text.startIndex..., in: textView.text)
if let matches = regex?.matches(in: textView.text, options: [], range: range) {
// 4.循环遍历每一个匹配项,并为其添加黄色背景
for match in matches {
let matchRange = match.range
attributedText.addAttribute(
NSAttributedString.Key.backgroundColor,
value: UIColor.yellow,
range: matchRange
)
}
}
}
// 5.将更新后的结果显示在 UITextView 上
textView.attributedText = (attributedText.copy() as! NSAttributedString)
}
构建并运行您的应用程序。尝试搜索各种单词和单词组!您将看到整个文本中突出显示的搜索词,如下图所示:

尝试使用各种选项搜索单词 "the" 并查看效果。例如,请注意,当开启整个单词搜索时,"then" 中的 "the" 不会高亮显示。
另外,测试搜索和替换功能以查看文本字符串是否按预期替换。还可以尝试 "匹配大小写" 和 "整个单词" 选项。
高亮显示和替换文本都很棒。但是,您还能如何在应用程序中有效地使用正则表达式呢?
数据校验
许多应用程序都会有用户输入,例如用户输入电子邮件地址或电话号码。您需要对此用户输入信息执行某种级别的数据校验,以确保数据完整性并响应用户输入数据时出现的任何错误。
正则表达式非常适合多种数据验证,因为它们在模式匹配方面非常出色。
您需要添加到应用程序中的两件事:验证模式本身以及使用这些模式验证用户输入的机制。
作为练习,尝试提出正则表达式来验证以下文本字符串(不要担心区分大小写):
- 名字:应由标准英文字母组成,长度在 1 到 10 个字符之间。
- 中间名首字母:应由一个英文字母组成。
- 名称:应由标准英文字母加上撇号(对于 O'Brian 等名字)、连字符(对于 Randell-Nash 等名字)组成,长度在 2 到 20 个字符之间。
- 超级反派名称:应由标准英文字母、撇号、句点、连字符、数字和空格组成,长度在 2 到 20 个字符之间。这允许使用 Ra's al Ghul、Two-Face 和 Mr. Freeze 等名字。
- 密码:至少8个字符,包括1个大写字符、1个小写字符、1个数字和1个非字母或数字字符。这个很棘手!
当然,您可以使用资料文件夹中的 iRegex Playground 来在开发表达式时尝试它们。
您是如何想出所需的正则表达式的?如果您遇到困难,只需返回本教程顶部的备忘录,查找在上述场景中对您有帮助的内容。
下面的剧透显示了您将使用的正则表达式。但在继续阅读之前,请先尝试自己弄清楚并检查结果!
swift
"^[a-z]{1,10}$", // First name
"^[a-z]$", // Middle Initial
"^[a-z'\\-]{2,20}$", // Last Name
"^[a-z0-9'.\\-\\s]{2,20}$" // Super Villain name
"^(?=\\P{Ll}*\\p{Ll})(?=\\P{Lu}*\\p{Lu})(?=\\P{N}*\\p{N})(?=[\\p{L}\\p{N}]*[^\\p{L}\\p{N}])[\\s\\S]{8,}$" // Password validator
打开 AccountViewController.Swift
并将以下代码添加到 viewDidLoad()
中:
swift
override func viewDidLoad() {
super.viewDidLoad()
textFields = [
firstNameField,
middleInitialField,
lastNameField,
superVillianNameField,
passwordField
]
let patterns = [
"^[a-z]{1,10}$",
"^[a-z]$",
"^[a-z'\\-]{2,20}$",
"^[a-z0-9'.\\-\\s]{2,20}$",
"^(?=\\P{Ll}*\\p{Ll})(?=\\P{Lu}*\\p{Lu})(?=\\P{N}*\\p{N})(?=[\\p{L}\\p{N}]*[^\\p{L}\\p{N}])[\\s\\S]{8,}$"
]
regexes = patterns.map {
do {
let regex = try NSRegularExpression(pattern: $0, options: .caseInsensitive)
return regex
} catch {
#if targetEnvironment(simulator)
fatalError("Error initializing regular expressions. Exiting.")
#else
return nil
#endif
}
}
}
这将在视图控制器中创建一个文本字段数组和一个字符串模式数组。然后,它使用 Swift 的 map
函数创建一个 NSRegularExpression
对象数组,每个模式对应一个对象。如果通过模式创建正则表达式失败,则会在模拟器中出现 fatalError
,以便您在开发应用程序时可以快速捕获它,但在生产中忽略它,因为您不希望应用程序为用户崩溃!
要创建正则表达式来验证名字,首先从字符串的开头进行匹配。然后,您匹配从 A 到 Z 的一系列字符,最后匹配字符串的末尾,确保其长度在 1 到 10 个字符之间。
接下来的两个模式------中间名首字母和姓氏------遵循相同的逻辑。如果是中间首字母,则无需指定长度 - {1}
- 因为 ^[a-z]$
默认匹配一个字符。超级恶棍的名称模式也类似,但随着添加对特殊字符的支持:撇号、连字符和句号,开始看起来有点复杂。
请注意,在这里您不必担心是否区分大小写 - 您将在实例化正则表达式时处理这一点。
现在,密码正则表达式是怎样的?需要强调的是,这只是一个展示如何使用正则表达式的练习,您真的不应该在现实世界的应用程序中使用它!
话虽如此,它实际上是如何运作的?首先,回顾一些正则表达式的理论:
- (括号)定义一个捕获组,将正则表达式的一部分组合在一起。
- 当捕获组以
?=
开头时,这表示该组将用作正向前瞻 (positive lookahead),仅当捕获组中的模式后跟前一个模式时才匹配它。例如,A(?=B)
会匹配字母 A,但仅当它后面跟着字母 B 时才会成功匹配。前瞻(lookahead)是一种断言,它类似^
或$
,它检查字符串中的特定模式,但本身不消耗任何字符。 \p{}
匹配某个类别内的 Unicode 字符,\P{}
匹配不属于某个类别的 Unicode 字符。例如,类别可以是所有字母(\p{L})
、所有小写字母(\p{Lu})
或数字(\p{N})
。
使用这些知识,分解正则表达式本身:
^
和$
和往常一样,匹配行的开头和结尾。(?=\P{Ll}*\p{Ll})
匹配(但不消耗)任意数量的非小写 Unicode 字符后跟一个小写 Unicode 字符,实际上匹配至少包含一个小写字符的字符串。(?=\P{Lu}*\p{Lu})
遵循与上面类似的模式,但确保至少有一个大写字符。(?=\P{N}*\p{N})
确保至少一位数字。(?=[\p{L}\p{N}]*[^\p{L}\p{N}])
使用 (^
) 确保至少一个字符不是字母或数字否定一个模式。- 最后,
[\s\S]{8,}
通过匹配空白或非空白字符来匹配任意字符八次或更多次。
做得好!
您可以利用正则表达式发挥创意。还有其他方法可以解决上述问题,例如使用 \d
代替 [0-9]
。然而,任何解决方案只要有效就完全没问题!
现在您已经有了模式,您需要验证每个文本字段中输入的文本。
仍然在 AccountViewController.swift
中,找到 validate(string:withRegex:)
并将虚拟实现替换为以下内容:
swift
func validate(string: String, withRegex regex: NSRegularExpression) -> Bool {
let range = NSRange(string.startIndex..., in: string)
let matchRange = regex.rangeOfFirstMatch(in: string, options: .reportProgress, range: range)
return matchRange.location != NSNotFound
}
随后,在 validateTextField(_:)
正下方,添加以下实现:
swift
func validateTextField(_ textField: UITextField) {
let index = textFields.index(of: textField)
if let regex = regexes[index!] {
if let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) {
let valid = validate(string: text, withRegex: regex)
textField.textColor = (valid) ? .trueColor : .falseColor
}
}
}
这与您在 SearchViewController.Swift
中所做的非常相似。从 validateTextField(_:)
开始,从正则表达式数组中获取相关的正则表达式,并修剪用户输入的文本字段中的任何空格。
然后,在 validate(string:withRegex:)
中为整个文本创建一个范围,并通过测试 rangeOfFirstMatch(in:options:range:)
的结果来检查匹配项。这可能是检查匹配的最有效方法,因为此调用在找到第一个匹配时会提前退出。但是,如果您需要知道匹配的总数,还有其他选择,例如 numberOfMatches(in:options:range:)
。
最后,在 allTextFieldsAreValid()
中将虚拟实现替换为:
swift
func allTextFieldsAreValid() -> Bool {
for (index, textField) in textFields.enumerated() {
if let regex = regexes[index] {
if let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) {
let valid = text.isEmpty || validate(string: text, withRegex: regex)
if !valid {
return false
}
}
}
}
return true
}
使用与上面相同的 validate(string:withRegex:)
方法,此方法只是测试每个非空文本字段是否有效。
运行项目,单击左上角的"帐户"图标按钮,然后尝试在注册表中输入一些信息。当您完成每个字段时,您应该看到其文本变成绿色或红色,具体取决于它是否有效,如下面的屏幕截图所示:

尝试保存您的帐户。请注意,只有当所有文本字段都正确验证时才能执行此操作。重新启动应用程序。这次,当应用程序启动时,您会看到一个注册表单,然后您才能看到日记中的秘密计划。输入您刚刚创建的密码,然后单击"登录"。
注意:这是正则表达式教程,而不是身份验证!不要使用本教程中的代码作为身份验证最佳实践的示例。为了强调这一点,密码以纯文本形式存储在设备上。
LoginViewController
中的loginAction
仅检查存储在设备上的密码,而不是安全存储在服务器上的密码。这无论如何都不安全。

处理多个搜索结果
您尚未使用导航栏上的阅读模式按钮。当用户点击它时,应用程序应该进入"聚焦"模式,高亮显示文本中的任何日期或时间字符串,并高亮显示每个日记条目的结尾。
在 Xcode 中打开 SearchViewController.Swift
,找到阅读模式按钮项的以下实现:
swift
@IBAction func toggleReadingMode(_ sender: AnyObject) {
if !self.readingModeEnabled {
readingModeEnabled = true
decorateAllDatesWith(.underlining)
decorateAllTimesWith(.underlining)
decorateAllSplittersWith(.underlining)
} else {
readingModeEnabled = false
decorateAllDatesWith(.noDecoration)
decorateAllTimesWith(.noDecoration)
decorateAllSplittersWith(.noDecoration)
}
}
上面的方法调用其他三个辅助方法来装饰文本中的日期、时间和日记条目分隔符。每种方法都采用装饰(decoration)选项,用于为文本添加下划线或不设置装饰(删除下划线)。如果您查看上面每个辅助方法的实现,您会发现它们都是空的!
在担心实现装饰方法之前,您应该定义并创建 NSRegularExpressions
本身。一个方便的方法是在 NSRegularExpression
上创建静态变量。切换到 RegexHelpers.swift
并在 NSRegularExpression
扩展中添加以下占位符:
swift
static var regularExpressionForDates: NSRegularExpression? {
let pattern = ""
return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}
static var regularExpressionForTimes: NSRegularExpression? {
let pattern = ""
return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}
static var regularExpressionForSplitter: NSRegularExpression? {
let pattern = ""
return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}
现在,你的工作就是完成这些 pattern!以下是要求:
Date 要求:
xx/xx/xx
或xx.xx.xx
或xx-xx-xx
格式。日、月和年的位置并不重要,因为代码只会高亮显示它们。示例:12 年 5 月 10 日。- 完整或缩写的月份名称(例如 Jan 或 January、Feb 或 February 等),后跟 1 或 2 位数字(例如 x 或 xx)。月份中的日期可以是序数(例如,1 日、2 日、10 日、21 日等),后跟逗号作为分隔符,然后是四位数字(例如 xxxx)。月、日和年的名称之间可以有零个或多个空格。示例:March 13th, 2001
Time 要求:
- 查找简单时间,例如"9am"或"11pm":一位或两位数字后跟零个或多个空格,后跟小写"am"或"pm"。
分割线要求:
- 波浪号 (~) 字符序列,长度至少为 10 个。
您可以使用 Playground 来尝试这些。看看你是否能找出所需的正则表达式!
您可以尝试以下三种示例模式。将 RegularExpressionForDates
的空模式替换为以下内容:
swift
(\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{1,2})|((Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)((r)?uary|(tem|o|em)?ber|ch|il|e|y|)?)\\s*(\\d{1,2}(st|nd|rd|th)?+)?[,]\\s*\\d{4}
该模式有两个部分,由 |
分隔。 (或)字符。这意味着第一部分或第二部分将匹配。
第一部分为:(\d{1,2}[-/.]\d{1,2}[-/.]\d{1,2})
。这意味着两位数字后跟 -
或 /
或 .
之一。后跟两位数字,再后跟 -
或 /
或 .
,再后跟最后两位数字。
第二部分以 ((Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)((r)?uary|(tem|o|em)?ber|ch|il|e|y|)?)
开头,它将匹配完整或缩写的月份名称。
接下来是 \\s*\\d{1,2}(st|nd|rd|th)?
,它将匹配零个或多个空格,后跟一位或两位数字,后跟可选的序数后缀。例如,这将匹配"1"和"1st"。
最后,[,]\\s*\\d{4}
将匹配逗号,后跟零个或多个空格,后跟四位数的年份。
这真是一个令人生畏的正则表达式!然而,您可以看到正则表达式是如何简洁并包含大量信息 - 而且功能强大! ------变成一个看似神秘的字符串。
接下来是 regularExpressionForTimes
和 regularExpressionForSplitters
的模式。用以下内容填充空白图案:
swift
// Times
\\d{1,2}\\s*(pm|am)
// Splitters
~{10,}
作为练习,看看您是否可以根据上述规范解释正则表达式模式。
最后,打开 SearchViewController.swift
,填写 SearchViewController
中各个装饰方法的实现,如下:
swift
func decorateAllDatesWith(_ decoration: Decoration) {
if let regex = NSRegularExpression.regularExpressionForDates {
let matches = matchesForRegularExpression(regex, inTextView: textView)
switch decoration {
case .underlining:
highlightMatches(matches)
case .noDecoration:
removeHighlightedMatches(matches)
}
}
}
func decorateAllTimesWith(_ decoration: Decoration) {
if let regex = NSRegularExpression.regularExpressionForTimes {
let matches = matchesForRegularExpression(regex, inTextView: textView)
switch decoration {
case .underlining:
highlightMatches(matches)
case .noDecoration:
removeHighlightedMatches(matches)
}
}
}
func decorateAllSplittersWith(_ decoration: Decoration) {
if let regex = NSRegularExpression.regularExpressionForSplitter {
let matches = matchesForRegularExpression(regex, inTextView: textView)
switch decoration {
case .underlining:
highlightMatches(matches)
case .noDecoration:
removeHighlightedMatches(matches)
}
}
}
这些方法中的每一种都使用 NSRegularExpression
上的静态变量之一来创建适当的正则表达式。然后,他们找到匹配项并调用 highlightMatches(_:)
为文本中的每个字符串着色并添加下划线,或调用 removeHighlightedMatches(_:)
来恢复样式更改。如果您有兴趣了解它们的工作原理,请查看它们的实现。
构建并运行应用程序。现在,点击阅读模式图标。您应该看到日期、时间和分隔符的链接样式突出显示,如下所示:

再次点击该按钮可禁用阅读模式,并将文本恢复为正常样式。
虽然这个例子很好,但您能明白为什么时间的正则表达式可能不适合更一般的搜索吗?按照目前的情况,它不会匹配 3:15pm,而是匹配 28pm。
这是一个具有挑战性的问题!了解如何重写时间正则表达式,使其匹配更通用的时间格式。
具体来说,您的答案应与标准 12 小时制的 ab:cd am/pm
格式的时间匹配。因此它应该匹配: 11:45 am、10:33 pm、04:12 am,但不匹配 2pm、0:00am、18:44am、9:63pm 或 7:4 am。 am/pm 之前最多应有一个空格。顺便说一句,如果 14:33am 匹配 4:33am 就可以了。
下面显示了一个可能的答案,但请先自己尝试一下。检查随附 Playground 的末尾以查看其运行情况。
swift
"(1[0-2]|0?[1-9]):([0-5][0-9]\\s?(am|pm))"
何去何从
恭喜!您现在已经有了一些使用正则表达式的实践经验。
您可以使用本教程顶部或底部的"下载材料"按钮下载该项目的完整版本。
正则表达式功能强大且使用起来很有趣------它们很像解决数学问题。正则表达式的灵活性为您提供了多种方法来创建满足您需求的模式,例如过滤输入字符串中的空格、在解析之前去除 HTML 或 XML 标签,或者查找特定的 XML 或 HTML 标签 - 等等!
更多练习
有很多实际的字符串示例,您可以使用正则表达式进行验证。作为最后一个练习,尝试理清以下验证电子邮件地址的正则表达式:
swift
[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?
乍一看,它看起来像是一堆混乱的字符,但凭借您新发现的知识(以及下面的有用链接),您离理解它并成为正则表达式大师又近了一步!
更多资源
以下是有关正则表达式的一些有用资源的简短列表:
- <www.regular-expressions.info> 是 Jan Goyvaerts 的信息网站。他还出版了一些关于正则表达式的非常全面的书籍。
- NSRegularExpression 参考始终是你使用 NSRegularExpression API 的最佳参考。
- 对于快速测试正则表达式,<www.regexpal.com> 是非常方便的资源。
我希望您喜欢这个 NSRegularExpression
教程,如果您有任何意见或问题,请加入下面的论坛讨论!