普通视图

发现新文章,点击刷新页面。
昨天以前掘金专栏-舒迟丶

基于 WordPress 开源的 AztecEditor-iOS库实现原生富文本编辑器

作者 舒迟丶
2024年6月28日 14:24

AztecEditor-iOS 基于 UITextView 提供了一个性能良好的用户界面组件。Android 端有对应的 SDK。

它由两个主要部分组成:

  • TextView:用户界面组件,用于呈现和编辑内容,可预览文本展示样式和对应HTML。
  • 转换器:实现HTML 和 NSAttributedString 的相互转换。

Aztec提供了丰富的富文本编辑功能,包括字体格式化(粗体、斜体、下划线、删除线、颜色等),引用块(可自定义样式),媒体文件(图片、视频)的展示和编辑,自定义标签(包括但不限于@、#等特殊字符可触发的自定义功能)等等。

鉴于 Aztec 的官方文档相当的简洁,开发者使用起来并没有那么方便,编者在实现编辑器的过程当中主要是参考了官方Demo

这里不针对 Aztec 源码做过多解析,只对如何使用以及如何进行定制化功能做一些说明,都是笔者在实际开发当中使用到的。

插件和短码系统

Aztec 提供的插件和短码系统,可以让我们通过自定义插件和短码来实现定制功能。

插件的实现主要是基于Plugin类和Processor协议,搭配短码解析系统来进行功能定制。短码方案直接采用了 WordPress-Editor 的方案,这个方案主要由ShortcodeAttributeSerializerShortcodeProcessorHTMLProcessor组成,ShortcodeProcessor内部通过正则匹配,可以识别形如[tag id="" type="" content=""]的自定义短码,搭配ShortcodeAttributeSerializerHTMLProcessor来进行正反方向的解析。

基于 Aztec 的插件和短码系统,我们可以扩展出用于自己项目的HTML与富文本相互转换的完全定制化系统,实际上 WordPress-Editor 的 Demo 就是这么做的。

image

open class Plugin {

    // MARK: - Customizers

    public let inputCustomizer: PluginInputCustomizer?
    public let outputCustomizer: PluginOutputCustomizer?

    // MARK: - Initializers

    public init(inputCustomizer: PluginInputCustomizer? = nil, outputCustomizer: PluginOutputCustomizer? = nil) {
        self.inputCustomizer = inputCustomizer
        self.outputCustomizer = outputCustomizer
    }

    /// Method plugins can use to execute extra code when loaded.

    ///
    open func loaded(textView: TextView) {}

  

    // MARK: - Equatable

    public static func ==(lhs: Plugin, rhs: Plugin) -> Bool {
        return type(of: lhs) == type(of: rhs)
    }
}

插件的功能主要依靠PluginInputCustomizerPluginOutputCustomizer来实现。我们在初始化编辑器时进行加载即可。

let plugins = [RichEditorPlugin(), PasteboardPlugin()]
for plugin in plugins {
    textView.load(plugin)
}
/// PluginInputCustomizer定义
public protocol PluginInputCustomizer {
    func process(html: String) -> String
    func process(htmlTree: RootNode)
    func converter(for elementNode: ElementNode) -> ElementConverter?
}

/// PluginOutputCustomizer定义
public protocol PluginOutputCustomizer {
    func process(html: String) -> String
    func process(htmlTree: RootNode)
    func convert(_ paragraphProperty: ParagraphProperty) -> ElementNode?
    func convert(_ attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any]) -> [Node]?
    func converter(for elementNode: ElementNode) -> ElementToTagConverter?
}

PluginInputCustomizer 可以和短码系统组合来为输入过程提供操作 HTML 的函数。 PluginOutputCustomizer为输出过程提供操作 HTML 的函数。比如发布时,自定义输出 HTML 内容。

在获取 HTML 时,Aztec 会调用HTMLConverterhtml(from attributedString: NSAttributedString, prettify: Bool = false) -> String函数来进行解析,将富文本转换成 HTML。

其内部借助 AttributedStringParser 和 ElementConverter(如ImageAttachmentToElementConverterVideoAttachmentToElementConverter)生成节点树,再通过HTMLSerializerserialize(_ node: Node, prettify: Bool = false) -> String函数,将节点树处理成 HTML,以上过程的每个节点都可以通过遵循PluginOutputCustomizer来进行干预。

class RichEditorPlugin: Plugin {
    init() {
        super.init(inputCustomizer: EditorInputCustomizer(),
                   outputCustomizer: EditorOutputCustomizer())
    }
}


class EditorInputCustomizer: PluginInputCustomizer {

    private var processor: PipelineProcessor = {

        var processors = [VideoProcessor.previewProcessor, ImageProcessor.previewProcessor]

        processors.append(contentsOf: CustomTagType.previewProcessors)
        return PipelineProcessor(processors)
    }()

  

    func process(html: String) -> String {
        return processor.process(html)
    }

    func process(htmlTree: Aztec.RootNode) {}
  
    func converter(for elementNode: Aztec.ElementNode) -> (any Aztec.ElementConverter)? {
        return nil
    }
}
class EditorOutputCustomizer: PluginOutputCustomizer {

    private var processor: PipelineProcessor = {
        var processors = [VideoProcessor.postProcessor, ImageProcessor.postProcessor]
        processors.append(contentsOf: CustomTagType.postProcessors)
        return PipelineProcessor(processors)
    }()

    func process(html: String) -> String {
        return processor.process(html)
    }

    func process(htmlTree: Aztec.RootNode) {}
  
    func convert(_ paragraphProperty: Aztec.ParagraphProperty) -> Aztec.ElementNode? {
        return nil
    }

    func convert(_ attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any]) -> [Aztec.Node]? {
        return nil
    }

    func converter(for elementNode: Aztec.ElementNode) -> (any Aztec.ElementToTagConverter)? {
        return nil
    }
}

文本格式化

工具条

Aztec 提供了工具条FormatBarFormatBarItem,在其基础上可做一些有限的自定义。

image

在实际开发中,开发者可以使用完全自定义的 UI + Aztec 提供的功能。

文本格式化

实现字体样式改变,比如粗体、斜体、下划线、删除线、引用块、标题可以直接调用 Aztec 提供的函数。插入自定义高亮标签,如 @someone 将在下一节进行说明。

private func handleFormat(_ style: EditorToolType.FormatFontStyle) {
        switch style {
        case .bold: /// 粗体文本
            richTextView.toggleBold(range: richTextView.selectedRange)
        case .italic: /// 斜体文本
            richTextView.toggleItalic(range: richTextView.selectedRange)
        case .underline: /// 下划线
            richTextView.toggleUnderline(range: richTextView.selectedRange)
        case .strikethrough: /// 删除线
            richTextView.toggleStrikethrough(range: richTextView.selectedRange)
        case .blockQuote: /// 引用块
            richTextView.toggleBlockquote(range: richTextView.selectedRange)
        case .headline: /// 标题
            richTextView.toggleHeader(.h1, range: richTextView.selectedRange)
        case .link: /// 添加链接
            delegate?.shouldInsertLink(self)
        case .horizontalRule: /// 分割线
            richTextView.replaceWithHorizontalRuler(at: richTextView.selectedRange)
        default:
            break
        }
    }

实现的样式如下:

image

插入自定义标签

在我们项目当中,自定义标签各端约定了固定格式,比如固定格式可以是这样的:[[id, type, content]]。通过插件和短码系统,编写自定义的短码处理器,可以完美实现需求。

短码的构造和解析

ShortcodeAttributeSerializerHTMLProcessor组合可以将短码解析为 HTML,我们可以在解析过程中自定义 HTML 的内容,保证在编辑器中展示正确的样式。

    static func parsedCustomTag(_ entity: CustomTagEntity) -> String {
      /// 构造符合ShortcodeProcessor要求格式的短码, e.g. [mention type=\"1\" text=\"@iOS test\" id=\"123\"]
        let shortCode = "[\(entity.type.identifier) type=\"\(entity.type.rawValue)\" text=\"\(entity.text)\" id=\"\(entity.id)\"]"
      /// 构造处理器
        let process = CustomTagsProcessor(tagType: entity.type)
      /// 将短码解析成 HTML
        return process.previewProcessor.process(shortCode)
    }

var previewProcessor: Processor {
        let serializer = ShortcodeAttributeSerializer()
        let processor = ShortcodeProcessor(tag: tagType.identifier) { shortCode in
            var html = "<mark style=\"color: #007AF5;\""

            guard let type = shortCode.attributes["type"],
                  let text = shortCode.attributes["text"],
                  let id = shortCode.attributes["id"]
            else {
                return nil
            }

            html += serializer.serialize(key: "type", value: type.value) + " "
            html += serializer.serialize(key: "text", value: text.value) + " "
            html += serializer.serialize(key: "id", value: id.value) + " "
            html += ">\(serializer.serialize(text.value))</mark>;
            return html
        }
        return processor
    }

HTML转自定义标签

ShortcodeAttributeSerializerShortcodeProcessor组合可以将 HTML 转为 自定义标签

在编辑器中展示时,为了方便previewProcessor使用了 mark 来标记自定义标签,所以在postProcessor中要使用HTMLProcessor找到 mark 标签进行解析。 Aztec 支持未知标签,底层将未知标签转换成 NSTextAttachment 的子类,实现该功能需要自定义 Attachment 和 Render,下面会讲到。

    var postProcessor: Processor {
        let serializer = ShortcodeAttributeSerializer()
        let processor = HTMLProcessor(for: Element.mark.rawValue) { element in
            guard let type = element.attributes["type"],
                  let text = element.attributes["text"],
                  let id = element.attributes["id"]
            else { return nil }
  
            let html = String(format: "[[%@,%@,%@]]", serializer.serialize(type.value), serializer.serialize(text.value), serializer.serialize(id.value))
            return html
        }
        return processor
    }

使用方法

基于上面的实现,统一了插入自定义标签的方法

enum CustomTagType: Int, CaseIterable {
    case mention = 1
    case link = 2
    case other = 3
    
    var identifier: String {
        switch self {
        case .mention: return "mention"
        case .link: return "link"
        case .other: return "other"
        }

    }

    static var previewProcessors: [Processor] {
        let processors = CustomTagType.allCases.map { CustomTagsProcessor(tagType: $0).previewProcessor }
        return processors
    }

    static var postProcessors: [Processor] {
        let processors = CustomTagType.allCases.map { CustomTagsProcessor(tagType: $0).postProcessor }
        return processors
    }
}

插入时按照上面提供的方案来进行即可。比如插入@someone:我们按照给定的格式提供一个短码 [mention type=\"1\" text=\"@iOS test\" id=\"123\"],经过previewProcessor的处理后会得到如下 HTML,这样可以保证在编辑器中的正确展示。 

<mark style=\"color: #007AF5;\" type=\"1\" text=\"@iOS test\" id=\"123\" >@iOS test</mark>;

上面的 HTML 经过postProcessor的处理后会得到约定好的自定义标签的固定格式[[1,@iOS test,123]],这样可以保证发布时得到正确的内容。

自定义未知HTML标签

Aztec 支持未知HTML标签,其原理是将未知标签转换成 NSTextAttachment 的子类,所以要实现该功能就需要自定义 Attachment 和 Render。

Attachment用来表示未知标签,Render 用来保证正确渲染。Aztec 为我们提供了 HTMLAttachment 和HTMLAttachmentRenderer,但它们可能无法满足我们的需求,所以在使用时需要做定制。

下面是我们的自定义渲染器:

class CustomTagsRender: TextViewAttachmentImageProvider {
    func textView(_ textView: TextView, shouldRender attachment: NSTextAttachment) -> Bool {
        return attachment is HTMLAttachment
    }

    func textView(_ textView: TextView, imageFor attachment: NSTextAttachment, with size: CGSize) -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(size, false, 0)

        guard let attachment = attachment as? HTMLAttachment else {
            return nil
        }

        let message = messageAttributedString(with: attachment)
        let targetRect = boundingRect(for: message, size: size)

        message.draw(in: targetRect)

        let result = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        return result
    }

    func textView(_ textView: TextView, boundsFor attachment: NSTextAttachment, with lineFragment: CGRect) -> CGRect {
        guard let attachment = attachment as? HTMLAttachment else {
            return .zero
        }

        let message = messageAttributedString(with: attachment)

        let size = CGSize(width: lineFragment.size.width, height: lineFragment.size.height)
        var rect = boundingRect(for: message, size: size)
        rect.origin.y = UIFont.mainRegular(size: 16).descender

        return rect.integral
    }
}

private extension CustomTagsRender {
    private func boundingRect(for message: NSAttributedString, size: CGSize) -> CGRect {
        let targetBounds = message.boundingRect(with: size, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
        let targetPosition = CGPoint(x: (size.width - targetBounds.width) * 0.5, y: (size.height - targetBounds.height) * 0.5)

        return CGRect(origin: targetPosition, size: targetBounds.size)
    }

    private func messageAttributedString(with attachment: HTMLAttachment) -> NSAttributedString {
        let attributes: [NSAttributedString.Key: Any] = [
            .foregroundColor: UIColor.textLink,
            .font: UIFont.mainRegular(size: 16)
        ]
/// 此处的 tagName 必须与 previewProcessor 和 postProcessor 中使用的标签名一致!!!
        guard let text = extractContent(from: attachment.rawHTML, tagName: "test")
        else {
            return NSAttributedString(string: "[Unknown]", attributes: attributes)
        }
        return NSAttributedString(string: text, attributes: attributes)
    }

    private func extractContent(from html: String, tagName: String) -> String? {
        do {
            let document = try SwiftSoup.parse(html)
            let elements = try document.getElementsByTag(tagName)

            if let element = elements.first() {
                let content = try element.text()
                return content
            }
        } catch {
            print("HTML 解析错误: \(error)")
        }

        return nil
    }
}

效果展示:

image

使用该方案可以一键删除 attachment,不支持光标移入,而且 attachment 支持点击,可以做一些扩展操作。缺点就是存在换行展示问题。

插入图片、视频

对于图像、视频、分隔符和表格等嵌入元素,使用的是NSTextAttachment的子类:

插入图片和视频时采用下面的方法,等待上传完成后,通过 identifier 获取对应 attachment,替换必要数据即可。

replaceWithImage(at range: NSRange, sourceURL url: URL, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> ImageAttachment

replaceWithVideo(at range: NSRange, sourceURL: URL, posterURL: URL?, placeHolderImage: UIImage?, identifier: String = UUID().uuidString) -> VideoAttachment

图片、视频的自定义属性

Aztec 提供的 Attachment 的属性可能无法满足我们的需求,比如项目需要保留标志视频长度的data-duration属性,这个 Aztec 并没有提供。

幸运的是 Aztec 为我们提供了extraAttributes字段,并且支持通过对extraAttributes进行解析来实现添加任意属性的功能。

    func convert(_ attachment: VideoAttachment, attributes: [NSAttributedString.Key : Any]) -> [Node] {
        let element: ElementNode
        
        ...
        /// 这里将extraAttributes中的自定义属性附加到了 element 上
        for attribute in attachment.extraAttributes {
            element.updateAttribute(named: attribute.name, value: attribute.value)
        }

        element.children = element.children + videoSourceElements(from: attachment)

        return [element]
    }

我们为 VideoAttachment 扩展了自定义属性,然后利用extraAttributes来实现我们的需求,使用时为自定义属性赋值,并在 processor 中进行解析。

extension VideoAttachment {

    @objc var videoId: String? {
        get {
            return extraAttributes[MediaHTMLAttribute.videoAttribute]?.toString()
        }
        set {
            if let nonNilValue = newValue {
                extraAttributes[MediaHTMLAttribute.videoAttribute] = .string(nonNilValue)
            } else {
                extraAttributes.remove(named: MediaHTMLAttribute.videoAttribute)
            }
        }
    }

    var duration: String? {
        get {
            return extraAttributes[MediaHTMLAttribute.videoDuration]?.toString()
        }
        set {
            if let nonNilValue = newValue {
                extraAttributes[MediaHTMLAttribute.videoDuration] = .string(nonNilValue)
            } else {
                extraAttributes.remove(named: MediaHTMLAttribute.videoDuration)
            }
        }
    }
}

图片和视频 processor 输出 HTML 的代码如下:

    static var postProcessor: Processor {
        let serializer = ShortcodeAttributeSerializer()
        let processor = HTMLProcessor(for: "img", replacer: { element in
            var html = "<img "
            if let imgId = element.attributes[MediaAttachment.uploadKey] {
                html += "\(serializer.serialize(key: "id", value: imgId.value))" + " "
            }
            if let src = element.attributes["src"] {
                html += "\(serializer.serialize(key: "src", value: src.value))" + " "
            }

            html += ">"
            return html
        })
        return processor
    }

    static var postProcessor: Processor {
        let serializer = ShortcodeAttributeSerializer()
        let processor = HTMLProcessor(for: "video", replacer: { element in
            var html = "<video "
            if let videoId = element.attributes[MediaAttachment.uploadKey] {
                html += "\(serializer.serialize(key: "id", value: videoId.value))" + " "
            }

            if let src = element.attributes["src"] {
                html += "\(serializer.serialize(key: "src", value: src.value))" + " "
            }

            if let width = element.attributes["width"] {
                html += "\(serializer.serialize(key: "w", value: width.value))" + " "
            }

            if let height = element.attributes["height"] {
                html += "\(serializer.serialize(key: "h", value: height.value))" + " "
            }

            if let posterAttribute = element.attributes["poster"],
               case let .string(posterValue) = posterAttribute.value,
               let posterURL = URL(string: posterValue),
               !posterURL.isFileURL
            {
                html += serializer.serialize(posterAttribute) + " "
            }

            if let duration = element.attributes["duration"] {
                html += "\(serializer.serialize(key: "data-duration", value: duration.value))" + " "
            }

            html += ">"
            return html
        })
        return processor
    }

总结

Aztec能够实现的功能远不止上面介绍到的,它还可以实现列表、自定义引用块、自定义媒体文件展示样式等等,这些功能需要开发者在自行探究中进行持续的发掘。

Codable编解码数据时的一些边界问题

作者 舒迟丶
2023年5月9日 17:58

我正在参加「掘金·启航计划」

键名与属性名不匹配

Apple提倡开发者在编码时使用驼峰命名,如果后端返回的数据中字段采用的是蛇形命名,比如:

{
  "nick_name": "张三",
  "formal_name": "张老三"
}

那么我们要如何统一编码风格呢?

Apple贴心的为我们提供了不同的解码策略,其中就包含了处理这种字段使用蛇形命名方案的策略,只要在解码前置处理即可 JSONDecoder().keyDecodingStrategy = .convertFromSnakeCase 然而要使用这个方法,需要保证所有的 JSON 响应都遵守蛇形命名规则,然而问题在于,在不断地迭代当中,我们无法保证亲爱的后端同事会不会在某一个版本心血来潮地对命名规则做出调整。所以尽管这种方法很方便,但是并不可取。

更具有可持续性的策略是使用 CodingKeys 指定明确的映射。

extension Person {
    private enum CodingKeys: String, CodingKey {
        case nickName = "nick_name"
        case formalName = "formal_name"
    }
}

拓展: JSONDecoder为日期解析提供了一个方便的方法dateDecodingStrategy,我们可以根据后端返回的时间类型来指定合适的策略,直接在数据解析的过程当中,悄悄地将时间转为我们需要的。有兴趣的读者可以自行查阅。

空值处理

在JSON解析的过程中,遇到null是常事,Swift中可以将模型属性设为可选类型,比如var name: String? ,或者给定默认值var name: String = ""

随机类型处理

相比JSONSerialization,Codable十分不灵活,几乎所有和 JSONSerialization 有关的字典都是 [String:Any] 类型的,把它变成 Decodable 类型有点困难。

假设我们需要一个这样的数据模型

struct Person: Decodable {
  var name: String
  var age: Int
  var others: [String: Any]
}

当你建好了一个这样的数据模型,编译器不出意外的报错了,原因是[String: Any] 类型不遵循 Codable 协议,因为它的值类型 Any 不遵从 Codable。不幸的是, [String: Codable] 也不行,因为需要指定确切的类型。

如果我们明确所有数据的值都是一种类型的,比如说都是 String,那么把类型直接定义成 [String: String]就可以了。然而如果想要处理混合类型的值,甚至包括嵌套的数组和字典,那就只能寻找另外的解决方案了。

使用类型无关的AnyCodable 是一种解决方案,它的接口和 AnyHashable 差不多。

示例:

struct Person: Decodable {
  var name: String
  var age: Int
  var others: [String: AnyDecodable]
}

参考

使用Swift Codable进行高效的数据编解码

Combine:订阅、绑定和内存管理

作者 舒迟丶
2023年4月28日 17:46

我正在参加「掘金·启航计划」

sink

sink负责订阅Publisher,并返回一个AnyCancellabel

完整签名:

 public func sink(receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void), receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

它接受两个闭包回调做为参数,receiveCompletion 在发布结束事件时被调用,receiveValue 在发布值时被调用。

来自喵神《SwiftUI和Combine编程》中的发布-订阅流程图:

iShot_2023-04-28_17.35.40.png

在使用 sink 完成订阅时,会创建一个特殊的 Subscriber 类型 Subscribers.Sink,并被纳入上面的流程中。它会自动声明想要接收无限多个新值,并订阅相应的 Publisher 对象。接下来,Publisher 会将新的值和结束事件作为参数传递给 sink 传入的两个闭包,从而将响应式的事件流转化为普通的指令操作。

Backpressure(背压)

Subscriber 可以在订阅初期通过 Subscription.request 或者通过 Subscriber.receive 返回特定的 Subscribers.Demand 值来指定能够处理的值的个数。这种背压机制 (Backpressure),可以让我们指定合适的背压策略,来控制可接收的值的上限,防止出现上游的发布速度超过下游消费速度的问题。

assign

Combine内建的另一个Subscriber就是assign,它可以用来将 Publisher 的输出值通过 key path 绑定到一个对象的属性上去。

使用assign时有几点需要注意的地方:

  1. 只有 class 上用 var 声明的属性才可以通过 assign 来直接赋值

  2. 上游 Publisher 的 Failure 的类型必须是 Never。如果上游 Publisher 可能会发生错误,必须先对它进行处理,比如使用 replaceError或者 catch 来把错误在绑定之前就处理掉。

  3. 核心概念 中已经有过说明,使用 assign(to:on:) 并存储生成的 AnyCancellable,可能会引起引用循环,所以请尽量使用assign(to:)来替代assign(to:on:)

引用共享

一个Publisher可能会有多个Subscriber,如果这个Publisher是一个网络请求的话,由于 dataTaskPublisher 是Struct,遵循值语义,多次订阅会复制多份,每一份都是一个新的Publisher,而这会造成多次请求,这是很浪费资源的。

解决上面问题的方法就是使用share()来共享Publisher。share()操作会把原来的Publisher包装到class内,对它的进一步变形也会适用于引用语义。

Cancellable & AnyCancellable

使用sink或者assign订阅Publisher时,会返回一个类型为AnyCancellable类型的值;而Timer在执行connect()操作后得到的是一个遵循Cancellable协议的值。

对于 Cancellable 来说,需要在合适的时候主动调用 cancel() 方法来完结。如果在没有调用 cancel() 的情况下就将 connect 的返回值忽略或者释放掉,那么Timer会一直计时,永远不会被终结掉。所以对于需要connect的Publisher,需要显式的调用cancel()来结束事件。

AnyCancellable 是一个 class,它可以对自身的生命周期进行管理。在 AnyCancellable 被释放时,它对应的订阅操作也会停止。

在应用中,我们会在实例当中创建一个Set<AnyCancellable>存储属性,并将sink或者assign返回的AnyCancellable存储其中。这样,当该实例 deinit 时,AnyCancellable 的 deinit 也会触发,并自动释放资源。这跟 RxSwift 中的 DisposeBag 很类似。

参考

王巍 《SwiftUI和Combine编程》

❌
❌