在Swift中对本地化字符串进行风格化设计的教程

378 阅读8分钟

将一个应用程序本地化为多种语言,通常可以大大增加其在App Store上的成功机会,因为许多用户倾向于使用支持自己母语的应用程序。

然而,虽然苹果公司确实提供了许多API和其他类型的基础设施来处理本地化字符串等资源,但如果我们想在我们的应用程序内渲染的字符串中加入某种形式的混合风格,事情往往会变得相当棘手。

例如,假设我们正在开发一个显示新电影列表的应用程序,我们想在其中一个用户界面的标题中强调 "新 "字。如果我们的应用程序没有被本地化,那么这样做将是非常直接的--我们可以简单地在标题字符串中搜索这个特定的词,然后在渲染其标签时对其进行不同的处理--但如果我们的应用程序确实支持多种语言呢?

如何处理这种情况的一种方法是,在我们的本地化字符串文件中标记我们希望强调的每个字符串的哪一部分--就像这样:

// English
"NewMovies" = "**New** movies";

// Swedish
"NewMovies" = "**Nya** filmer";

// Polish
"NewMovies" = "**Nowe** filmy";

从上面的例子来看,另一个选择似乎是简单地强调每个字符串中的第一个词。然而,这将是一个相当脆弱的解决方案,因为并不是所有的语言都使用相同的词序,而且如果我们在未来为我们的字符串添加某种形式的前缀呢?

接下来,我们要对上述字符串格式进行解析,以便将每段文本转化为NSAttributedString (用于基于UIKIt的UI),或SwiftUIText 实例。

为了开始,让我们定义一个专门的LocalizedString ,在这个类型中我们将能够实现所有需要的逻辑。最初,我们可以实现API,用一个本地化的字符串密钥初始化一个实例,以及使用内置的NSLocalizedString 函数解析一个原始String

struct LocalizedString {
    var key: String

    init(_ key: String) {
        self.key = key
    }

    func resolve() -> String {
        NSLocalizedString(key, comment: "")
    }
}

extension LocalizedString: ExpressibleByStringLiteral {
    init(stringLiteral value: StringLiteralType) {
        key = value
    }
}

我们还可以用一个字符串字面来表达一个LocalizedString ,一旦我们开始将我们的新类型与UIKit和SwiftUI整合,这将非常有用。

有了上述类型,现在让我们继续进行实际的解析和渲染,从NSAttributedString

归属的字符串

正如该类型的名称所暗示的那样,NSAttributedString ,使我们能够将渲染属性添加到一个普通的String ,在这种情况下,它使我们有可能编码一个特定的本地化字符串的某些部分应该被强调。

为了实现这一点,让我们用一个方法来扩展我们的LocalizedString 类型,该方法使用我们选择的标记(**, Markdown-style)将一个给定的原始本地化字符串分割成多个组件,然后根据给定组件的索引是偶数还是奇数来选择默认字体或粗体。

extension LocalizedString {
    typealias Fonts = (default: UIFont, bold: UIFont)

    static func defaultFonts() -> Fonts {
        let font = UIFont.preferredFont(forTextStyle: .body)
        return (font, .boldSystemFont(ofSize: font.pointSize))
    }

    func attributedString(
        withFonts fonts: Fonts = defaultFonts()
    ) -> NSAttributedString {
        let components = resolve().components(separatedBy: "**")
        let sequence = components.enumerated()
        let attributedString = NSMutableAttributedString()

        return sequence.reduce(into: attributedString) { string, pair in
            let isBold = !pair.offset.isMultiple(of: 2)
            let font = isBold ? fonts.bold : fonts.default

            string.append(NSAttributedString(
                string: pair.element,
                attributes: [.font: font]
            ))
        }
    }
}

使用上述新的API,我们现在就可以使用UIKit类(如UILabelUITextView ,它们都支持属性字符串)来渲染具有混合风格的本地化字符串。

现在,在我们继续实现上述功能的SwiftUI等价物之前,让我们花点时间把我们实际的字符串解析和渲染逻辑重构为一个可重用的工具,我们将能够从两个实现中调用,以避免代码重复。

一种方法是实现一个通用的、reduce 风格的渲染函数,它需要一个初始结果,以及一个执行实际字符串连接的处理程序--例如,像这样:

private extension LocalizedString {
    func render<T>(
        into initialResult: T,
        handler: (inout T, String, _ isBold: Bool) -> Void
    ) -> T {
        let components = resolve().components(separatedBy: "**")
        let sequence = components.enumerated()

        return sequence.reduce(into: initialResult) { result, pair in
            let isBold = !pair.offset.isMultiple(of: 2)
            handler(&result, pair.element, isBold)
        }
    }
}

有了上面的方法,我们可以大大简化之前基于NSAttributedString 的方法,因为它现在可以专注于注释和组合传入其handler 的字符串:

extension LocalizedString {
    ...

    func attributedString(
        withFonts fonts: Fonts = defaultFonts()
    ) -> NSAttributedString {
        render(
            into: NSMutableAttributedString(),
            handler: { fullString, string, isBold in
                let font = isBold ? fonts.bold : fonts.default

                fullString.append(NSAttributedString(
                    string: string,
                    attributes: [.font: font]
                ))
            }
        )
    }
}

完成了这个小小的重构任务后,我们现在开始实现我们基于SwiftUI的字符串渲染。

SwiftUI文本

SwiftUI的Text 类型的一个有点 "隐藏 "的特点是,多个文本值可以使用添加操作符直接串联起来,就像它们是原始的String 值一样--这仍然保留了每个单独实例的风格设计。

因此,我们要为我们的LocalizedString 类型添加一个基于SwiftUI的渲染API,所要做的就是调用我们新的render 方法,然后把它给我们的每个字符串组合起来--像这样。

extension LocalizedString {
    func styledText() -> Text {
        render(into: Text("")) { fullText, string, isBold in
            var text = Text(string)

            if isBold {
                text = text.bold()
            }

            fullText = fullText + text
        }
    }
}

是时候进行整合了

接下来,为了使我们的UIKit和基于SwiftUI的方法更容易使用,让我们也用方便的API来扩展UILabelText ,让我们直接用一个LocalizedString 的值来初始化一个标签。

extension UILabel {
    convenience init(styledLocalizedString string: LocalizedString) {
        self.init(frame: .zero)
        attributedText = string.attributedString()
    }
}

extension Text {
    init(styledLocalizedString string: LocalizedString) {
        self = string.styledText()
    }
}

有了上面的内容,我们现在可以利用LocalizedString 值可以用字符串字面表达的事实,使用SwiftUI或UIKit创建有风格的、本地化的标签,只需这样做。

// UIKit
UILabel(styledLocalizedString: "NewMovies")

// SwiftUI
Text(styledLocalizedString: "NewMovies")

非常好!然而,目前我们总是在每次请求时重新解析每个字符串,如果我们不经常更新我们的用户界面,这可能不是一个问题,但让我们也探讨一下如何在我们的实现中添加缓存。

缓存

由于我们解析的所有字符串都是从静态资源(用户当前语言的本地化字符串文件)加载的,所以我们应该能够非常积极地缓存它们。一种方法是使用我们在"Swift中的缓存 "中构建的Cache 类型然后修改我们的render 函数,使其支持从这种缓存中读写--像这样:

private extension LocalizedString {
    static let attributedStringCache = Cache<String, NSMutableAttributedString>()
    static let swiftUITextCache = Cache<String, Text>()

    func render<T>(
        into initialResult: @autoclosure () -> T,
        cache: Cache<String, T>,
        handler: (inout T, String, _ isBold: Bool) -> Void
    ) -> T {
        if let cached = cache.value(forKey: key) {
            return cached
        }

        let components = resolve().components(separatedBy: "**")
        let sequence = components.enumerated()

        let result = sequence.reduce(into: initialResult()) { result, pair in
            let isBold = !pair.offset.isMultiple(of: 2)
            handler(&result, pair.element, isBold)
        }

        cache.insert(result, forKey: key)
        return result
    }
}

我们现在用@autoclosure 属性标记我们的initialResult 参数,是为了防止在发现缓存值的情况下对其进行评估。

请注意,我们的attributedStringCache 存储了NSMutableAttributedString 实例,这是因为当我们从attributedString 方法中调用render 时,这就是我们要处理的类型。虽然在我们的LocalizedString 类型内部使用这种易变的实例并无大碍,但我们现在肯定应该在返回之前复制所有的属性字符串,以防止任何意外的易变状态的共享。

所以让我们来做这件事,同时更新我们的字符串渲染方法,以支持我们新的缓存功能。

extension LocalizedString {
    ...

    func attributedString(
        withFonts fonts: Fonts = defaultFonts()
    ) -> NSAttributedString {
        let string = render(
            into: NSMutableAttributedString(),
            cache: Self.attributedStringCache,
            handler: { fullString, string, isBold in
                ...
            }
        )

        return NSAttributedString(attributedString: string)
    }

    func styledText() -> Text {
        render(
            into: Text(""),
            cache: Self.swiftUITextCache,
            handler: { fullText, string, isBold in
                ...
            }
        )
    }
}

有了这最后一块,我们新的LocalizedString API就完成了,我们现在可以使用SwiftUI或UIKit,以一种高性能和可预测的方式渲染完全本地化的、有风格的字符串。

支持多种样式,并以 HTML 作为替代

当然,我们在本文中构建的系统目前只支持将字符串的部分内容变成黑体,但我们总是可以继续迭代它,以备我们想要添加对多种样式的支持,尽管这可能需要更复杂的字符串解析技术。

例如,我们可以使用开源的Sweep库来识别应该用一组给定的属性进行风格化的范围,或者使用像"Swift中的字符串解析 "中涉及的技术来实现这一点。

另一个选择是将某些字符串渲染成HTML,NSAttributedString ,这实际上是对HTML的完整支持,这也有其自身的权衡。这样,我们就可以在我们的本地化字符串中放置任何类型的HTML样式(比如<b><em> ),然后把它们变成像这样的完全可渲染的属性字符串。

extension LocalizedString {
    func attributedString() throws -> NSAttributedString {
        let data = Data(resolve().utf8)

        return try NSAttributedString(
            data: data,
            options: [
                .documentType: NSAttributedString.DocumentType.html,
                .characterEncoding: String.Encoding.utf8.rawValue
            ],
            documentAttributes: nil
        )
    }
}

然而,上述技术的一个很大的缺点是,它要求我们的本地化字符串文件包含HTML代码(随着时间的推移,包括外部译者在内的许多人可能会编辑这些文件,这些代码很容易变得不正常)。另外,由于我们要把这些字符串当作网络片段来渲染,因此我们还必须使用网络技术来设计每个这样的标签,这可能很快就会使我们的设置变得相当复杂和难以维护。

总结

将本地化与动态渲染的内容或样式结合起来有时是相当困难的--即使是相对简单的强调一个给定字符串的部分也需要相当多的代码来实现。希望这篇文章向你展示了一些关于如何做到这一点的提示和技巧,也许所涉及的技术可以为你建立自己的系统提供一个起点,以呈现风格化的、本地化的字符串。

谢谢你的阅读!