PhoneNumberKit 阅读笔记

1,285 阅读4分钟

这是我参与更文挑战的第1天,活动详情查看: 更文挑战

当 App 面向全球的朋友,在输入电话号码时,就不得不考虑如何将电话号码根据当地语言来格式化,如何展示各个国家的图标等问题了。在处理电话号码的问题时,可以使用PhoneNumberKit

下面是我在源码阅读过程中收货。

不换行空格 "\u{00a0}"

我们经常需要过滤空白字符,其他一个特殊的字符需要考虑:"\u{00a0}" 不换行空格

var spaceCharacterSet: CharacterSet = {
    let characterSet = NSMutableCharacterSet(charactersIn: "\u{00a0}")
    characterSet.formUnion(with: CharacterSet.whitespacesAndNewlines)
    return characterSet as CharacterSet
}()

不换行空格有两个用途:

  1. 禁止合并连续的空格。例如 HTML 中会将连续的空白字符合并成一个。
  2. 禁止自动换行。编辑器一般会把自动换行放在空格字符处。但是,有些文本内容在排版时不适合被放在连续的一行行尾与下一行行首,例如:“100 km”。

详细资料可以查看维基百科

NSRange 和 Range 相互转化

下面是 PhoneNumberKit 作者对 NSRange 和 Range 相互转化的方法:

extension String {
    func nsRange(from range: Range<String.Index>) -> NSRange {
        let utf16view = self.utf16
        let from = range.lowerBound.samePosition(in: utf16view) ?? self.startIndex
        let to = range.upperBound.samePosition(in: utf16view) ?? self.endIndex
        return NSRange(location: utf16view.distance(from: utf16view.startIndex, to: from),
                       length: utf16view.distance(from: from, to: to))
    }

    func range(from nsRange: NSRange) -> Range<String.Index>? {
        guard
            let from16 = utf16.index(utf16.startIndex, offsetBy: nsRange.location, limitedBy: utf16.endIndex),
            let to16 = utf16.index(from16, offsetBy: nsRange.length, limitedBy: utf16.endIndex),
            let from = String.Index(from16, within: self),
            let to = String.Index(to16, within: self)
        else { return nil }
        return from..<to
    }
}

平台的判断

由于平台的差异可能会导致 bug,或者要区别对待,所以需要在必要的情况下对平台进行判断。在 PhoneNumberKit 框架中,使用到了 canImport 来判断平台状况。

// 判断是有 ObjectiveC 运行环境
#if canImport(ObjectiveC) 
// ……
#else 
// ……
#endif

// 判断是否有 UIKit 
#if canImport(UIKit)
// ...
#endif

更多的平台信息可以通过查看苹果文档

Platform conditionValid arguments
os()macOS, iOS, watchOS, tvOS, Linux, Windows
arch()i386, x86_64, arm, arm64
swift()>= or < followed by a version number
compiler()>= or < followed by a version number
canImport()A module name
targetEnvironment()simulator, macCatalyst

在 PhoneNumberKit 中有两处为了避免 bug 而进行的平台判断:

// 用例一:
extension NSRegularExpression {
    #if canImport(ObjectiveC)
    func enumerateMatches(in string: String, options: NSRegularExpression.MatchingOptions = [], range: Range<String.Index>? = nil, using block: (NSTextCheckingResult?, NSRegularExpression.MatchingFlags, UnsafeMutablePointer<ObjCBool>) -> Swift.Void) {
        let range = range ?? string.startIndex..<string.endIndex
        let nsRange = string.nsRange(from: range)

        self.enumerateMatches(in: string, options: options, range: nsRange, using: block)
    }
    #else
    // FIX: block needs to be @escaping
    func enumerateMatches(in string: String, options: NSRegularExpression.MatchingOptions = [], range: Range<String.Index>? = nil, using block: @escaping (NSTextCheckingResult?, NSRegularExpression.MatchingFlags, UnsafeMutablePointer<ObjCBool>) -> Swift.Void) {
        let range = range ?? string.startIndex..<string.endIndex
        let nsRange = string.nsRange(from: range)

        self.enumerateMatches(in: string, options: options, range: nsRange, using: block)
    }
    #endif
}

// 用例二:
#if canImport(ObjectiveC)
let prefixPattern = String(format: "^(?:%@)", possibleNationalPrefix)
#else
// FIX: String format with %@ doesn't work without ObjectiveC (e.g. Linux)
let prefixPattern = "^(?:\(possibleNationalPrefix))"
#endif

Any way to test for the presence of the Objective-C runtime?

NSRegularExpression 的缓存

当我们的 App 需要经常用到正则 NSRegularExpression 时,就需要考虑对正则进行缓存来提高性能了。

PhoneNumberKit 中使用了 RegexManager 来缓存已创建的 NSRegularExpression,并使用自定义的队列进行保存和读取。

关键代码如下:

final class RegexManager {
    // MARK: Regular expression pool

    var regularExpresionPool = [String: NSRegularExpression]()

    // concurrent 并行队列
    private let regularExpressionPoolQueue = DispatchQueue(label: "com.phonenumberkit.regexpool",
                                                           attributes: .concurrent)

    // MARK: Regular expression

    func regexWithPattern(_ pattern: String) throws -> NSRegularExpression {
        var cached: NSRegularExpression?

        // 将数组的操作都放到了自己的 并行队列中,
        // 读取使用的是 .sync
        // 设置使用    .async(flags: .barrier)
        self.regularExpressionPoolQueue.sync {
            cached = self.regularExpresionPool[pattern]
        }

        if let cached = cached {
            return cached
        }

        do {
            // caseInsensitive 忽略大小写
            let regex = try NSRegularExpression(pattern: pattern,
                                                options: .caseInsensitive)

            regularExpressionPoolQueue.async(flags: .barrier) {
                self.regularExpresionPool[pattern] = regex
            }

            return regex
        } catch {
            throw PhoneNumberError.generalError
        }
    }
}

NSLocale

获取国家或地区代码的本地化字符串。

let name = (Locale.current as NSLocale).localizedString(forCountryCode: countryCode)

例如,在美国英语 (en_US) 语言环境中调用此方法,当 countryCode 为 “GB” 时,返回字符串“United Kingdom”。

苹果官网 localizedString(forCountryCode:)

数字转字符串

平时我将数字转字符串都是用的 "\(number)" 的方法,作者采用的是 public var description: String { get } 的方法。

代码如下:

var number: Int? = nil
debugPrint(number?.description ?? "empty")
number = 1
debugPrint(number?.description ?? "empty")

// 输出
"empty"
"1"

国家编码转 emoji

在选择国家的时候,通常都会在国家前面加上对应的图标,增加辨识度,方便用户选择。

如果使用图片来展示各个国家的图标,则会增加 App 包的大小。PhoneNumberKit 的作者采用了 emogi 的方式,通过国家的编码(county code)来找出对应的 emoji,这样即避免了图片造成的 App 安装变大,又提高的加载速度。

通过国家的编码(county code)找出对应的 emoji 的方法为:

static func getFlag(countryCode: String) -> String? {
    var flag = ""
    let flagBase = UnicodeScalar("🇦").value - UnicodeScalar("A").value
    countryCode.uppercased().unicodeScalars.forEach {
        if let scaler = UnicodeScalar(flagBase + $0.value) {
            flag.append(String(describing: scaler))
        }
    }
    guard flag.count == 1 else {
        return nil
    }
    return flag
}

另外可以参考下面的文章: stackoverflow 上的讨论 维基百科

字符串的比较

不区分大小写比较两个字符串

不区分大小写比较两个字符串时,可以采用的先转换成全部大写或者小写后再比较。作者在通过搜索过滤国家列表的时候,就采用了这种方式:

public func updateSearchResults(for searchController: UISearchController) {
    let searchText = searchController.searchBar.text ?? ""
    filteredCountries = allCountries.filter { country in
                                             country.name.lowercased().contains(searchText.lowercased()) ||
                                             country.code.lowercased().contains(searchText.lowercased()) ||
                                             country.prefix.lowercased().contains(searchText.lowercased())
                                            }
    tableView.reloadData()
}

另外,可以采用字符串的 caseInsensitiveCompare(_:) (Apple 文档)方法,这个方法其实是以 NSCaseInsensitiveSearch 作为唯一 option 调用 compare(_:options:) 的结果。作者在排序国家列表的时候采用了这种方式:

lazy var allCountries = phoneNumberKit
        .allCountries()
        .compactMap({ Country(for: $0, with: self.phoneNumberKit) })
        .sorted(by: { $0.name.caseInsensitiveCompare($1.name) == .orderedAscending })

另外:当操作的字符串需要向用户展示时,需要调用 localizedCaseInsensitiveCompare(_:) 方法。

很奇怪作者为什么会采用两种方式,个人感觉全部使用 caseInsensitiveCompare 方法即可。

忽略宽度或变音符号差异的方式比较字符串

处理文本时,尤其是拉丁文文本,以忽略大小写(大写或小写)、宽度(全角或半角)和变音符号(重音和半角)等差异的方式比较字符串时,可以使用 folding(options:locale:)  (Apple 文档)从字符串中删除指定的字符区别来创建适合比较的字符串。

作者在对国家进行分组展示的时候使用到了 folding 方法(代码有改动):

let lhs = lName.folding(options: .diacriticInsensitive, locale: nil)
let rhs = rName.folding(options: .diacriticInsensitive, locale: nil)
if lhs?.first == rhs.first {
    // 首字符一致
} else {
    // 首字母不一致
}

根据用户设定获取 UIFont

在设置 Cell 的字体是,作者用到了 preferredFont(forTextStyle:) 。

// 根据用户设定的字体大小及粗细设置字体
@available(iOS 7.0, *)
open class func preferredFont(forTextStyle style: UIFontTextStyle) -> UIFont

设置字体代码如下:

cell.textLabel?.font = .preferredFont(forTextStyle: .callout)
cell.detailTextLabel?.font = .preferredFont(forTextStyle: .body)

NSCharacterSet

如何获取一个字符集的非集(不存在指定字符集的字符集)呢? 原来 NSCharacterSet 也有 inverted 方法(Apple 文档)。

用例:

let nonNumericSet: NSCharacterSet = {
    var mutableSet = NSMutableCharacterSet.decimalDigit().inverted
    mutableSet.remove(charactersIn: PhoneNumberConstants.plusChars)
    mutableSet.remove(charactersIn: PhoneNumberConstants.pausesAndWaitsChars)
    mutableSet.remove(charactersIn: PhoneNumberConstants.operatorChars)
    return mutableSet as NSCharacterSet
}()

集合、字符串 allSatisfy

当判断一个集合、字符串中所有的元素是否都满足某个条件时,可以使用 allSatisfy(_:)Apple 文档)。

extension String {
  var isBlank: Bool {
    return allSatisfy({ $0.isWhitespace })
  }
}

每次阅读优秀的框架时,都有醍醐灌顶的感觉,谢谢那些优秀的开源作者。