普通视图

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

基于 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编程》

Combine:错误处理

作者 舒迟丶
2023年2月17日 17:51

Publisher的结束事件有两种可能:代表正常完成的.finished和代表发生错误的.failure

Subscriber在订阅上游Publisher时,Combine要求Publisher.outputSubscriber.input的类型一致,同时也要求所接受的Failure的类型一致,否则就算Publisher只发出Output值,也无法使用这个Subscriber去接收。

这种设计提醒我们,在开发中要充分考虑对可能出现的错误的处理。

转换错误类型

enum RequestError: Error {
    case sessionError(error: Error)
}
let imageURLPublisher = PassthroughSubject<URL, RequestError>()
imageURLPublisher.flatMap { url in
    return URLSession.shared.dataTaskPublisher(for: url).map { _ in
        
    }
 }

这段代码会抛出一个错误,明确告诉我们RequestError和URLError的类型不一致:

candidate requires that the types 'RequestError' and 'URLSession.DataTaskPublisher.Failure' (aka 'URLError') be equivalent (requirement specified as 'Self.Failure' == 'P.Failure')

此时,使用mapError进行类型转换非常合适

let cancellable = imageURLPublisher.flatMap { requestURL in
    return URLSession.shared.dataTaskPublisher(for: requestURL)
        .mapError { error -> RequestError in
            return RequestError.sessionError(error: error)
        }
}.sink(receiveCompletion: { (error) in
    print("请求失败: \(String(describing: error))")
}, receiveValue: { (result) in
    let image = UIImage(data: result.data)
    print("请求成功: \(image?.description ?? "")")
})

imageURLPublisher.send(URL(string: "https://httpbin.org/image/jpeg")!)
imageURLPublisher.send(URL(string: "https://unknown.url/image")!)

两次send得到的结果如下:

请求成功: <UIImage:0x60000395d290 anonymous {239, 178} renderingMode=automatic(original)>
请求失败: failure(__lldb_expr_9.RequestError.sessionError(error: Error Domain=NSURLErrorDomain Code=-1200 "An SSL error has occurred and a secure connection to the server cannot be made."

抛出错误

Combine 为 Publisher 的 map 操作提供了一个可以抛出错误的版本:tryMap。使用 tryMap 我们就可以将处理数据时发生的错误,转变为标志事件流失败的结束事件:

["1", "2", "Swift", "4"].publisher
    .tryMap { s -> Int in
        guard let value = Int(s) else {
            throw IntError.typeError
        }
        return value
    }.print()
    .sink { _ in } receiveValue: { _ in }

输出的结果是:

receive subscription: (TryMap)
request unlimited
receive value: (1)
receive value: (2)
receive error: (typeError)

"Swift" 字符串是无法被转换为 Int 值的,使用tryMap就将这个错误抛了出来,导致整个事件流以错误结果终止,接下来的 "4" 也不再会被处理。

除了 tryMap 以外,Combine 中还有很多类似的以 try 开头的操作符,比如 tryScantryFiltertryReduce 等等。当需要在数据转换或者处理时,将事件流以错误进行终止,都可以使用对应操作的 try 版本来进行抛出,并在订阅者一侧接收到对应的错误事件。这些try*工作的思路是一致的。

从错误中恢复

开发中遇到不需要将错误信息反馈给用户的情况时,我们可以选择使用默认值来让事件流从错误中“恢复”的方式来处理。

在 Combine 里,有一些 Operator 是专门做这种处理的,比如 replaceErrorcatch。代码始终是最直观的说明方式。

使用replaceError

["1", "2", "Swift", "4"].publisher
    .tryMap { s -> Int in
        guard let value = Int(s) else {
            throw IntError.typeError
        }
        return value
    }
    .replaceError(with: -1)
    .print()
    .sink { _ in
    } receiveValue: { _ in
    }

/// 输出结果
//receive subscription: (ReplaceError)
//request unlimited
//receive value: (1)
//receive value: (2)
//receive value: (-1)
//receive finished

使用catch

["1", "2", "Swift", "4"].publisher
    .tryMap { s -> Int in
        guard let value = Int(s) else {
            throw IntError.typeError
        }
        return value
    }
    .catch { _ in
        Just(-1)
    }
    .print()
    .sink { _ in
    } receiveValue: { _ in
    }
/// 输出结果
//receive subscription: (Catch)
//request unlimited
//receive value: (1)
//receive value: (2)
//receive value: (-1)
//receive finished

两种方式的打印结果是相同的,但是实现的方式却不一样:

1. replaceError是使用单个值替换出错的值

2. catch将产生错误的publisher替换为了一个全新的publisher

这两种方式都可以实现错误替换,但是它们都在发生错误的地方中断了整个事件流,在开发中,这往往不是我们想要的结果。这种状态要怎么解决呢?我们可以组合使用一些操作符来达成目的。

["1", "2", "Swift", "4"].publisher
    .flatMap { s in
        Just(s)
            .tryMap { s -> Int in
                guard let value = Int(s) else {
                    throw IntError.typeError
                }
                return value
            }
            .catch { _ in
                Just(-1)
            }
    }
    .print()
    .sink { _ in
    } receiveValue: { _ in
    }
/// 输出结果
//receive subscription: (FlatMap)
//request unlimited
//receive value: (1)
//receive value: (2)
//receive value: (-1)
//receive value: (4)
//receive finished

在响应式异步编程中,使用 flatMap 进行“包装”的手法是很常见的。这样即使发生了 类似例子当中的"Swift" 无法转换为 Int 的错误,最终的 "4" 依然可以得到正确的处理。

Combine:核心概念

作者 舒迟丶
2023年2月16日 16:50

在上一篇概览 中说过,Combine有三个核心概念:Publisher、Operator和Subscriber。PublisherSubscriber 分别代表事件的发布者和订阅者,Operator兼具两者的特性,它同时遵循Subscriber和 Publisher协议,用来对上游数据进行操作。

只要理解了这三个核心概念,你就可以很好的使用Combine,所以从这个角度来说,我们可以将Combine简单的理解为下面的形式:

Combine = Publishers + Operators + Subscribers

Publisher

定义:

public protocol Publisher<Output, Failure> {

    associatedtype Output
    associatedtype Failure : Error

    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}

publisher被订阅后,会根据subscriber的请求提供数据,一个没有任何subscriber的publisher不会发出任何数据。

Publisher可以发布三种事件:

  • Output:事件流中出现的新值
  • Finished: 事件流中所有元素发布结束,事件流完成使命并终结
  • Failure: 事件流中发生了错误,事件流到此终结

Finished和Failure事件被定义在Subscribers.Completion中

extension Subscribers {
    @frozen public enum Completion<Failure> where Failure : Error {
        case finished
        case failure(Failure)
    }
}

三种事件都不是必须的。Publisher 可能会发出一个或多个 output 值,也可能不发出任何值;它可能永远不会停止,也可能会通过发出failure 或 finished 事件来表示终结

最终将会终止的事件流被称为有限事件流,而不会发出 failure 或者 finished 的事件流则被称为无限事件流。比如,一次网络请求就是一个有限事件流,而某个按钮的点击事件流就是无限事件流。

Subject

Subject也是一个Publisher

public protocol Subject : AnyObject, Publisher {
func send(_ value: Self.Output)
func send(completion: Subscribers.Completion<Self.Failure>)
}

Subject 暴露了两个 send 方法,外部调用者可以通过这两个方法来主动地发布 output 值、failure 事件或 finished 事件。Subject可以将传统的指令式编程中的异步事件和信号转换到响应式的世界中去

Combine内置了两种Subject类型:

  • PassthroughSubject

    简单地将 send 接收到的事件转发给下游的其他 Publisher 或 Subscriber,不会持有最新的output;如果在订阅前执行send操作,是无效的。

let publisher1 = PassthroughSubject<Int, Never>()
print("开始订阅")
publisher1.sink(
receiveCompletion: { complete in
print(complete)
},
receiveValue: { value in
print(value)
    })
publisher1.send(1)
publisher1.send(2)
publisher1.send(completion: .finished)
// 输出:
// 开始订阅
// 1
// 2
// finished

调整一下 sink 订阅的时机,将它延后到 publisher.send(1) 之后,那么订阅者将会从 2 的事件开始进行响应:

let publisher2 = PassthroughSubject<Int, Never>()
publisher2.send(1)
print("开始订阅")
publisher2.sink(
receiveCompletion: { complete in
print(complete)
},
receiveValue: { value in
print(value)
              })
publisher2.send(2)
publisher2.send(completion: .finished)
// 输出:
// 开始订阅
// 2
// finished
  • CurrentValueSubject

    会包装和持有一个值,并在设置该值时发送事件并保留新的值。在订阅发生的瞬间,会把当前保存的值发送给订阅者;接下来对值的每次设置都将触发订阅响应。

let publisher3 = CurrentValueSubject<Int, Never>(0)
print("开始订阅")
publisher3.sink(
receiveCompletion: { complete in
print(complete)
},
receiveValue: { value in
print(value)
    })
publisher3.value = 1
publisher3.value = 2
publisher3.send(completion: .finished)
// 输出:
// 开始订阅
// 0
// 1
// 2
// finished

Subscriber

定义:

public protocol Subscriber<Input, Failure> : CustomCombineIdentifierConvertible {

    associatedtype Input
    associatedtype Failure : Error

    func receive(subscription: Subscription)

    func receive(_ input: Self.Input) -> Subscribers.Demand

    func receive(completion: Subscribers.Completion<Self.Failure>)
}

想要订阅某个 Publisher,Subscriber 中的这两个类型必须与 Publisher 的 Output 和 Failure 一致。

Combine 中也定义了几个比较常见的 Subscriber,可以供我们直接使用。

sink

sink的完整函数签名为

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

receiveCompletion 用来接收 failure 或者 finished 事件,receiveValue 用来接收 output 值。

let just = Just("Hello word!")
    _ = just.sink(receiveCompletion: {
        print("Received completion", $0)
    }, receiveValue: {
        print("Received value", $0)
    })

如果说Subject提供了一条从指令式异步编程通向响应式世界的道路的话,那么sink就补全了另外一侧。sink可以作为响应式代码和基于闭包的指令式代码之间的桥梁,让你可以通过它从响应式的世界中回到指令式的世界。因为receiveValue闭包会将值带给你,想要对它做什么就随你愿意了。

assign

func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root,Self.Output>, on object:Root) -> AnyCancellable

assign 接受一个 class 对象以及对象类型上的某个键路径 (key path)。每当 output 事件到来时,其中包含的值就将被设置到对应的属性上去。

定义一个MyObject类

class MyObject {
var value: String = "123" {
didSet {
print(value)
}
}
}

使用 assign(to:on:)修改MyObject实例对象属性的值

let obj = MyObject()
let _ = ["456"].publisher.assign(to: \.value, on: obj)

assign 还有一个变体, assign(to:) 可将 Publisher 发出的值用于 @Published 属性包装器包装过的属性

class MyObject {
@Published var value = 0
}

let objc = MyObject()
objc.$value.sink {
print($0)
}

(0 ..< 5).publisher.assign(to: &objc.$value)

value 属性用 @Published包装,除了可作为常规属性访问之外,它还为属性创建了一个 Publisher。使用 @Published 属性上的 $ 前缀来访问其底层 Publisher,订阅该 Publisher,并打印出收到的每个值。最后,我们创建一个 0..<5 的 Int Publisher 并将它发出的每个值 assign 给 object 的 value Publisher。 使用 & 来表示对属性的 inout 引用,这里的 inout 来源于函数签名:

func assign(to published: inout Published<Self.Output>.Publisher)

这里有一个值得注意的地方,如果使用 assign(to: .value, on: self) 并存储生成的 AnyCancellable,可能会引起引用循环:MyObject 类实例持有生成的 AnyCancellable,而生成的 AnyCancellable 同样保持对 MyObject 类实例的引用。因此,推荐使用 assign(to:) 来替代 assign(to:on:) ,以避免此问题的发生,因为assign(to:) ::不返回 AnyCancellable,在内部完成了生命周期的管理,在 @Published 属性释放时会取消订阅。::

Operator

关于Operator的介绍,在概览中已经做了相对详细的介绍。

Operator 可以作为上游 Publisher 的输入,同时它们也可以成为新的 Publisher,输出处理过的数据给下游。我们可以把不同的操作符组合起来形成一个处理链:当链条最上端的 Publisher 发布事件或数据时,链条内的 Operator 会对这些数据和事件一步一步地进行处理,最终达到 subscriber 指定的结果。

关于常用操作符,这篇文章 介绍的十分全面,可做参考。

参考

Combine: Asynchronous Programming with Swift

SwiftUI和Combine编程

Combine: 概览

作者 舒迟丶
2023年2月15日 17:05

异步编程

在一种简单的单线程语言中,程序按顺序逐行执行。 例如,在伪代码中:

begin
  var name = "Tom"
  print(name)
  name += " Harding"
  print(name)
end

同步代码很容易理解,并且数据的状态也很清晰。通过单线程执行,始终可以确定数据的当前状态。 在上面的示例中,第一次的打印结果总是“Tom”,第二次的打印结果总是“Tom Harding”。

现在,想象一下使用异步事件驱动的多线程语言来编写程序,就像使用 Swift 和 UIKit 的 iOS 程序一样,会发生什么:

--- Thread 1 ---
begin
  var name = "Tom"
  print(name)

--- Thread 2 ---
name = "Billy Bob"

--- Thread 1 ---
  name += " Harding"
  print(name)
end

在这里,Thread 1 将name的值设置为“Tom”,然后给它追加“Harding”。但是因为Thread 2可以同时执行,所以程序可能会在 name 的两个突变之间运行,并将其设置为另一个值“Billy Bob”。当代码在不同的内核上并发运行时,很难说哪部分代码会先修改共享状态。

运行此代码时究竟会发生什么,最后会得到什么样的结果,取决于系统负载,并且每次运行该程序时都可能会看到与之前一次不一样的结果。在异步并发的世界里,应用程序的可变状态管理等同于系统进行的一项加载任务。而 异步编程的本质是响应未来发生的事件流。

常见的使用异步编程方式的场景:

  • 网络请求
  • 等待响应用户点击事件
  • 进行 UI 动画并在动画结束之后触发一些代码
  • 图片下载和保存
  • 传递数据到机器学习模型,识别其中内容

传统的 Cocoa 和 UIKit 中提供了一系列的异步 API,它们往往以下面的某种形式出现:

  • 闭包回调:事件比较单纯,不需要关心过程中的细节,只需要响应结果。比如弹窗
  • delegate:希望控制更多细节,或者需要关心多种异步事件。比如 UITableViewDelegate,闭包 API 相比 delegate 存在一些设计上的困难
  • 通知:触发时机不确定的,可能长期存在的行为对应的事件。比如退出登录

在异步编程中,不论是用闭包,delegate 还是 notification,实际上都是在当前的时间节点上预先设置好特定的逻辑,去处理未来会发生的事件。

Combine框架

响应式异步编程

事件是异步编程中的核心。在客户端的开发中,异步编程的关注点就是如何处理这些事件,并通过响应这些事件来改变程序状态,最终影响用户界面和体验。

响应式异步编程的核心就是当异步操作中的某个事件发生时,要将这个事件和必要相关数据发布出来,由对这个事件感兴趣的代码订阅它,并在收到发布后进行需要的操作。

通过对事件处理的操作进行组合,来对异步事件进行自定义处理。

Combine 提供了一组声明式的 Swift API,来处理随时间变化的值。这些值可以代表用户界面的事件,网络的响应,计划好的事件,或者很多其他类型的异步数据。你可以为给定的事件创建单个处理链,而不用实现多个委托或者闭包回调。处理链的每个部分都是一个Combine操作符,用来对从上一步接收到的元素执行不同的操作。

核心概念

  • Publisher
  • Operator
  • Subscriber

关于Combine的这几个核心概念,鉴于篇幅问题,后面再做详细的介绍。这里先做一个大概的说明。

PublisherSubscriber 分别代表事件的发布者和订阅者,它们都被定义为protocol。

Publisher和Subscriber总是成对出现的,如果一个Publisher没有一个Subscriber对其进行订阅,那么它将不会发出任何事件,而且Publisher会发出多少事件,发出什么样的事件也由Subscriber来控制,这是一种Back-pressure(背压) 机制。

Operator兼具Publisher和Subscriber的特性,它同时遵循Subscriber和 Publisher协议,用来对上游数据进行操作。

每个 Operator 的行为模式都一样:它们使用上游 Publisher 所发布的数据作为输入,以此产生的新的数据,然后自身成为新的 Publisher,并将这些新的数据作为输出,发布给下游。

我们可以组合使用不同的操作符,使它们形成一条处理链条:当链条最上端的 Publisher 发布某个事件后,链条中的各个 Operator 对事件和数据进行处理,最后得到subscriber想要的数据。

参考

Combine: Asynchronous Programming with Swift

SwiftUI和Combine编程

Combine: Debugging

作者 舒迟丶
2023年2月14日 17:47

理解异步代码中的事件流,对于初学者来说一直是一个挑战。在Combine的上下文中尤其如此,因为事件流中的操作符链可能不会立即发出事件。

例如,throttle(for:scheduler:latest:) 操作符就不会发出接收到的任何事件,要了解这个过程中发生了什么,就需要借助Combine提供的一些操作符来进行调试,以帮助我们解决遇到的困难。

Print

当你不确定流中发生的事件时,首先可以考虑使用print(_:to:)操作符来进行打印操作,它是一个passthrough publisher,可以打印大量事件流信息,帮助我们了解事件传输过程中发生了什么,比如用它来了解事件流的生命周期。

let subscription = (1 ... 3).publisher
    .print("publisher")
    .sink { _ in }

控制台会输出流中发生的事件:

publisher: receive subscription: (1...3)
publisher: request unlimited
publisher: receive value: (1)
publisher: receive value: (2)
publisher: receive value: (3)
publisher: receive finished

print(_:to:)还接受一个 TextOutputStream 对象,我们可以使用它来重定向字符串并打印出来。通过自定义的TextOutputStream,在日志中添加信息,例如当前日期和时间等。

例子:

class TimeLogger: TextOutputStream {
    private var previous = Date()
    private let formatter = NumberFormatter()
    
    init() {
        formatter.maximumFractionDigits = 5
        formatter.minimumFractionDigits = 5
    }
    
    func write(_ string: String) {
        let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            return
        }
        
        let now = Date()
        print("+\(formatter.string(for: now.timeIntervalSince(previous))!)s: \(string)")
        previous = now
    }
}

使用:

let subscription = (1 ... 3).publisher
    .print("publisher", to: TimeLogger())
    .sink { _ in }

handleEvents

除了打印事件信息之外,使用handleEvents(receiveSubscription:receiveOutput:receiveCompletion:receiveCancel:receiveRequest:)对特定事件进行操作也很有用,它不会直接影响下游其他publisher,但会产生类似于修改外部变量的效果。所以可以称其为“执行副作用”。

handleEvents可以让你拦截一个publisher生命周期内的任何事件,并且可以在每一步对它们进行操作。

想象一下,你正在跟踪的publisher必须执行网络请求,然后发出一些数据。但当你运行它时,却怎么也收不到数据,比如下面的代码就是如此,

let request = URLSession.shared.dataTaskPublisher(for: URL(string: "https://kodeco.com/")!)

request.sink(receiveCompletion: { completion in
    print("Sink received completion: \(completion)")
}) { data, _ in
    print("Sink received data: \(data)")
}

运行之后,控制台没有任何输出。你是否能发现这段代码存在的问题呢?

如果问题你没找到,那么就可以使用 handleEvents来跟踪并查找问题所在。

request.handleEvents(receiveSubscription: { _ in
    print("请求开始了")
}, receiveOutput: { _ in
    print("请求到数据了")
}, receiveCancel: {
    print("请求取消了")
}).sink(receiveCompletion: { completion in
    print("Sink received completion: \(completion)")
}, receiveValue: { data, _ in
    print("Sink received data: \(data)")
})

再次执行,可以看到打印:

请求开始了
请求取消了

因为Subscriber 返回的是一个AnyCancellable 对象,如果不持有这个对象,那么它会马上被取消(释放),这里的问题就是没有持有 Cancellable 对象,导致publisher被提前释放了, 修改代码:

let subscription = request.handleEvents(receiveSubscription: { _ in
    print("请求开始了")
}, receiveOutput: { _ in
    print("请求到数据了")
}, receiveCancel: {
    print("请求取消了")
}).sink(receiveCompletion: { completion in
    print("Sink received completion: \(completion)")
}, receiveValue: { data, _ in
    print("Sink received data: \(data)")
})

再次运行,打印如下:

请求开始了
请求到数据了
Sink received data: 266785 bytes
Sink received completion: finished

终极大招

当你用尽浑身解数也无法找到问题所在时,“万不得已”操作符是帮助你解决问题的终极方案。

简单的“万不得已”操作符: breakpointOnError() 。 顾名思义,使用此运算符时,如果任何上游publisher发出错误,Xcode 将中断调试器,让你从堆栈中找出publisher出错的原因和位置。

完整的变体是 breakpoint(receiveSubscription:receiveOutput:receiveCompletion:) 。 它允许你拦截所有事件并根据具体情况决定是否要暂停调试器。比如,只有当某些值通过publisher时才可以中断:

.breakpoint(receiveOutput: { value in
    value > 10 && value < 15
})

假设上游publisher发出整数值,但值 11 到 14 永远不会发生,就可以将断点配置为仅在这种情况下中断,以进行检查。 你还可以有条件地中断订阅和完成时间,但不能像 handleEvents 运算符那样拦截取消。

总结

以上我们已经介绍了几种Combine的调试方法,下面做一个简单的总结:

  • 使用print操作符跟踪publisher的生命周期
  • 创建自定义的TextOutputStream来输出需要的调试信息
  • 使用handleEvents操作符拦截生命周期事件,并执行操作
  • 使用breakpointOnErrorbreakpoint操作符来中断特定事件

参考

Combine: Asynchronous Programming with Swift

使用 LLDB 提高开发中的调试效率

作者 舒迟丶
2022年9月30日 14:58

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天

LLDB 是高性能的程序调试器,默认集成在 XCode 中,支持 C 语言、Objective-C 语言和 C++语言程序代码进行调试,包括查看变量、修改变量、执行指令等功能。

当 Xcode 触发断点时,程序会自动进入 LLDB 调试环境,开发者可以在控制台进行 LLDB 指令的执行。

使用 expression 指令进行动态代码执行

expression 指令用来动态执行代码,可以在运行时修改内存中变量的值,改变程序运行轨迹。 示例:

iShot_2022-09-30_14.49.50.png

断点处进入了 LLDB调试模式,在控制台输入expression a会输出内存中变量 a 的值 (int)表示类型, $0是 LLDB 自动生成的一个临时符号,表示变量 a 的值 1,后面可以使用$0来获取值 1

iShot_2022-09-30_14.50.36.png

当然,我们可以使用expression 指令来修改 a 的值(这个特性简直太酷了😃,相见恨晚)

iShot_2022-09-30_14.51.23.png

使用 LLDB 的expression 指令,不需要添加额外的打印代码,也不需要直接修改源代码就可以按需来多次调试我们的程序,方便我们找到正确的代码修改方法。

frame 指令查看代码帧信息

使用frame指令可以查看当前代码帧信息,查看函数名称、参数和所在位置信息,并且可以进行代码回溯调试。 断点处进入 LLDB 时,导航栏可以看到当前线程和数据帧

iShot_2022-09-30_14.52.02.png

使用frame info可以打印当前数据帧信息

iShot_2022-09-30_14.52.38.png 当前所在的数据帧编号为 0,内存地址为0x000000010630ef0a,工程名LLDBTest,所执行的函数为ViewController中的test:,函数中的参数也被分别打印出来并标明了代码所在的文件位置和行数。

使用frame variable指令可以获取当前数据帧的变量信息

iShot_2022-09-30_14.53.12.png

上面说到的expression命令只能操作当前数据帧中的变量,在viewDidLoad定义的变量是无法在当前数据帧访问的,所以在实际开发中就要使用数据回溯。例如 test 函数中的入参是在viewDidLoad中计算的,如果这个参数有误,就需要回溯到viewDidLoad中检查,此时可以使用frame select [count]来切换数据帧,[count]就是导航区数据帧在前面的数字编号。

iShot_2022-09-30_14.54.11.png

不适用 LLDB,这种情境下就需要多个断点配合来一步步追溯代码来检查变量,调试麻烦且耗时,过程中也容易错过某个断点而导致重头来过。使用 frame 数据帧可以十分方便的解决这种问题。

thread 相关指令操作线程

程序运行中可能会开启多个线程,每个线程包含了多个数据帧块,使用 thread 相关指令可以查看程序中开启的线程,并且可以切换线程进行调试。

使用thread backtrace可以打印当前线程中的所有数据帧

iShot_2022-09-30_14.54.55.png

之后可以使用frame相关指令来查看某个数据帧的内容。

使用thread list可以查看当前所有被激活的线程

iShot_2022-09-30_14.55.50.png

*标识出了当前的所调试的线程。

使用thread Info可以查看当前正在调试的线程信息:

iShot_2022-09-30_14.56.25.png

还有一些相关的thread 指令可以用来在 LLDB 调试器中控制程序运行,例如使用 continue 可以跳过当前断点向下执行,使用jump可以跳转到指定的内存地址等。

其他

LLDB 非常强大,指令也很多。除了以上说明的以外,还有一些常用指令,比如print 指令打印变量,r 指令重新运行程序,quit 指令结束 LLDB 调试等。LLDB 还可以实现直接添加断点、控制断点行为、反编译代码、读取内存二进制数据等。

help 指令提供了帮助文档,有兴趣的可以自行查看和调试

UITableView 性能优化

作者 舒迟丶
2022年9月29日 18:22

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

UITableView 的加载原理

可变高的列表载体 Cell 是在开发中经常处理到的一个技术点。 UITableViewCell 的高度需要在数据源代理中设置:

-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
return height;
}

heightForRowAtIndexPath方法会重复执行很多次,并且heightForRowAtIndexPath 方法的执行机制在不同版本 iOS 系统中还会有很大不同。在 iOS 11 中,默认已经采取了一些优化手段。

在 iOS 9上,要显示一行 cell,至少要执行 5 遍heightForRowAtIndexPath 方法:

  • UITableView 配置部分
    • 当 UITableView 视图即将被展示在屏幕上时,会拉取所有行高数据
    • UITableView 在执行setLayoutMargins方法进行自身布局时会拉取所有行高数据
    • UITableView 在执行layoutSubviews方法进行子视图布局时会再次拉取所有行高数据
  • UITableViewCell 配置部分
    • 当使用 CellID 获取与 UITableView 绑定的 cell 时会拉取本行 cell 的高度数据
    • 当 cell 调用 layoutSubviews 方法急性布局时会再次拉取本行 cell 的高度数据

在上面列举的 5 种拉取 cell 高度数据的场景中,UITableView 配置部分只会在 UITableView 第一次展现在屏幕上时出现,但是它拉取的是所有行的行高数据,如果表视图有 100 行或者更多,那么这就是一个非常耗能的过程。

UITableViewCell 的配置部分,只有当 cell 将要出现在屏幕上时才会出现,并且只拉取当前 cell 的行高,这两种场景会在用户滑动 UITableView 时不断被执行,并且根据 UITableView 的 cell 布局原理,系统会默认准备比当前一屏高度所能容纳cell 的个数多 1 个 cell。

当执行reloadData方法进行界面刷新时,系统先会把所有行的行高数据拉取一遍,之后和 UITableViewCell 配置部分的场景一致,会拉取即将出现在屏幕上的 cell 的行高数据。下面是UITableView 的加载原理示意图:

iShot_2022-09-29_18.16.48.png

通过以上分析,以 10 行数据的视图为例,若一屏幕可以呈现 7 行数据(UITableView 需要准备 8 行),则在第一次展示 UITableView 视图时,会执行 44 次heightForRowAtIndexPath ,每次刷新 UITableView 需要执行 24 次heightForRowAtIndexPath ,如果 UITableView 行数增加的三位数,这个方法的执行次数将会非常惊人。

可变行高的优化方式

如果将复杂的计算代码写在heightForRowAtIndexPath 中,代价是巨大的。滑动不流畅、屏幕卡顿等很多性能问题都是由于这个原因。对于行高固定的表格视图,可以直接设置 UITableView 的固定行高,如果行高是不固定的,则应该想办法让heightForRowAtIndexPath 方法完成最少的工作。

其实最少的工作莫过于拿一个高度直接返回,因此通常会将对应行的行高计算一次后,把值保存起来,之后在执行heightForRowAtIndexPath 拉取行高时,直接返回。具体操作比较灵活,可以对应一个数组属性,将计算后的行高放入数组,在每次取行高时,检查数组中是否有已经计算过的行高数据,如果有直接返回。一种更好的方式是将行高数据封装进 cell 的数据模型 Model 中。

然而,这只是提高了代码性能,工作量和复杂度有增无减。iOS 7 之后,可以使用estimateRowHeight属性,iOS 11 中,系统已经默认定义这个值为 44 来进行列表视图的性能优化。这个值设置 cell 的大约行高。设置estimateRowHeight 无需再设置rowHeight,也不需要实现heightForRowAtIndexPath ,系统会根据 cell 中的 contentView 的约束来计算自己的行高。estimateRowHeight 用于 UITableView的初始化,会影响到表格视图右侧滚动条的宽度。当 cell 展现出来时,真正的行高并不受这个属性值的影响。

So,问题来了:如何让cell 正确计算自己的高度? 答案是使用 AutoLayout,给 cell 布局足够的压力,让 contentView 的上下左右必须被内部控件的约束撑满。

Note:cell 的子视图必须添加到 contentView 上,否则计算时会出现问题

这是性能最优的列表渲染方式。

上面说预估行高会影响到 UITableView 右侧滚动条的展示,如果每个 cell 行高跳跃跨度比较大,则滚动条宽度的配置会失准,随着用户滑动,右侧滚动条可能会出现长短跳跃的情况,如果想要精准这个滚动条的配置,可以在如下代理方法中返回具体 cell 的估计行高:

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
//这里根据不同分区或者不同行设置估计的行高
return 44;
}

关于estimatedHeightForRowAtIndexPath 这个方法还有一种应用场景: 对于没有使用自动布局、cell 的高度需要手动计算的场景,如果实现了这个方法,并且实现了heightForRowAtIndexPath方法,那么heightForRowAtIndexPath方法会以懒加载的方法执行,只有在 cell 将要被展现在屏幕上时才会被执行,这也可以有效减少由于高度计算带来的性能负担。

Note: UITableViewCell在被创建出来时,宽度并不一定和 UITableView 的宽度一致,如果需要获取 cell 的绝对宽度来处理逻辑,那么需要在 cell的 layoutSubviews 方法里面进行,此时 cell 的宽度才正确

高度不固定的列表分区头、尾视图

一般头尾高度固定,不考虑它们带来的性能问题。对比 cell 的布局原理,也可以使用自动布局实现自适应高度。

参考

《iOS 性能优化实战》

SwiftUI 学习之propertyWrapper

作者 舒迟丶
2022年9月29日 18:01

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

属性包装

propertyWrapper(属性包装器)是 Swift 语言特性,它允许我们定义一个自定义类型,该类型包含了 setter 和 getter,我们可以在需要的地方重用它。SwiftUI 中常用的 @State、@Binding等修饰符都是通过属性包装来实现的。

以 @State 为例,看一下其实现

@frozen @propertyWrapper public struct State<Value> : DynamicProperty {

    public init(wrappedValue value: Value)

    public init(initialValue value: Value)

    public var wrappedValue: Value { get nonmutating set }

    public var projectedValue: Binding<Value> { get }
}

init(initialValue:) ,wrappedValue  和 projectedValue  构成了一个 propertyWrapper 最重要的部分。

关于这部分我会在以后的文章中陆续更新。

仅仅是源码还不足以使我们了解和掌握 propertyWrapper 是如何实现属性包装的,下面来组装一个属于自己的“State”。

使用 propertyWrapper 组装自己的“State”

假设我们要给 App 添加额外的日志,每次属性更改时,都将其新值打印到控制台,以此来跟踪操作的有效性,那么以前我们大概率会这么做:

struct Bar {
    private var _x = 0
    var x: Int {
        get { _x }
        set {
            _x = newValue
            print("New value is \(newValue)") 
        }
    }
}

var bar = Bar()
bar.x = 1 // Prints 'New value is 1'

大多数人应该能很快发现这么做存在的问题。是的,这并不是一个通用的方法,实际操作中,既降低开发效率又会让我们对重复代码产生不良情绪。

可以借助泛型来做优化

struct ConsoleLogged<Value> {
    private var value: Value
    
    init(wrappedValue: Value) {
        self.value = wrappedValue
    }
    
    var wrappedValue: Value {
        get { value }
        set { 
            value = newValue
            print("New value is \(newValue)") 
        }
    }
}

现在就可以像下面这样来使用了

struct Bar {
    private var _x = ConsoleLogged<Int>(wrappedValue: 0)
    var x: Int {
        get { _x.wrappedValue }
        set { _x.wrappedValue = newValue }
    }
}
var bar = Bar()
bar.x = 1 // Prints 'New value is 1'

但是这样代码仍不够简洁,这很不 Swift。现在就轮到 @propertyWrapper 这个大杀器出场了。

@propertyWrapper
struct ConsoleLogged<Value> {

/// 这块代码和上面一模一样
    private var value: Value
    init(wrappedValue: Value) {
        self.value = wrappedValue
    }
    
    var wrappedValue: Value {
        get { value }
        set { 
            value = newValue
            print("New value is \(newValue)") 
        }
    }
}

使用时与 @State 一样

struct Bar {
    @ConsoleLogged var x = 0
}
var bar = Bar()
bar.x = 1 // Prints 'New value is 1'

@ConsoleLogged 实际上就是被包装了一个语法糖,底层将 x 属性包装到了一个 ConsoleLogged<Int> 中,并保留了使用者对其进行操作的可能性,可参考上面泛型优化后的使用代码。

关于 ConsoleLogged 的初始化方法,当 wrappedValue 出现在 init 方法的第一个参数位置时,编译器允许我们在声明的时候直接为 @ConsoleLogged var x 进行赋值,在 State 的定义中源码注释说明了不要直接调用初始化方法

Don't call this initializer directly. Instead, declare a property with the State attribute, and provide an initial value:

@State private var isPlaying: Bool = false

参考:

1.The Complete Guide to Property Wrappers in Swift 5

2.《SwiftUI 和 Combine 编程 第二版》王巍

通过 Xcode 启动终端

作者 舒迟丶
2022年7月18日 18:55

使用过其他 IDE 的同学应该知道,大多数都集成了终端,而 Xcode 却没有。当我们要对一个项目执行命令行时,总要将项目拖到终端窗口中,这样很不方便。那如何使用 Xcode 一键启动终端并 cd 到当前项目目录呢?这篇文章就来介绍一种方案。

新建脚本文件:

Image.png 编辑脚本文件:

#!/usr/bin/env bash 
open -a iTerm "`pwd`"

脚本文件默认没有执行权限,启动终端,使用命令行

chmod +x <脚本路径>

赋予脚本权限。

在 Xcode → Preferences → Behaviors 中添加自定义 Open Terminal 选项,设置脚本路径及惯用快捷键

Image (2).png

完成之后就可以在 Xcode中通过快捷键一键调起终端并 cd 到项目目录。

配置起来很简单,使用起来很划算哦😏

iOS 线程保活

作者 舒迟丶
2021年9月26日 15:24

开发中,经常会遇到将耗时操作放到子线程中执行,来提升应用性能的场景。当子线程中的任务执行完毕后,线程就被立刻销毁。

如果开发中需要经常在子线程中执行任务,那么频繁的创建和销毁线程就会造成资源的浪费,这不符合我们的初衷。 此时就需要我们对线程进行保活,保证线程在应该处理事情的时候醒来,空闲的时候休眠。

我们知道 RunLoop 可以在需要处理事务的时候醒来执行任务,空闲的时候休眠来节省资源,利用这个特性就可以来处理线程的保活,控制线程的生命周期。

从探索到成功

- (void)viewDidLoad {
    [super viewDidLoad];
    
    LSThread *thread = [[LSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
    [thread start];
}

- (void)run {
    NSLog(@"func -- %s   thread -- %@", __func__, [NSThread currentThread]);
    [[NSRunLoop currentRunLoop] run];
    NSLog(@"--- 结束 ---");
}

LSThread 继承自 NSThread ,重写了 dealloc 方法

- (void)dealloc {
    NSLog(@"%s", __func__);
}

执行之后的结果:

保活失败.png

可以看到线程没能保活:

  • 虽然启动了 RunLoop,依然执行了下面的结束 log
  • 线程在执行完毕之后被销毁了

为了保证线程执行完毕不被销毁,可以强引用线程

self.thread = [[LSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
[self.thread start];

但是这样并不能解决 RunLoop 问题。那么已经启动了 RunLoop,为什么并没有保持它的持续运行呢?

我们来看一下 run 方法的定义

If no input sources or timers are attached to the run loop, this method exits immediately.

意思是如果没有sources或timers附加到RunLoop,那么这个方法会立即退出。

那么我们给 RunLoop 添加一个 source 或者 timer 应该就可以解决这个问题了

[[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode];

再次运行并没有执行结束 log,线程保活成功。

保活成功.png

下面继续来完善我们的需求。当持有线程的控制器销毁时,新建的子线程也应该跟着被销毁,在控制器里添加 dealloc

- (void)dealloc {
    NSLog(@"--- 销毁控制器 --- %s", __func__);
}

在控制器出现时,创建子线程,控制器消失时控制台输出如下

循环引用.png

控制器和子线程都没有被销毁。查看代码

self.thread = [[LSThread alloc] initWithTarget:self selector:@selector(run) object:nil];

这里控制器强引用了 thread,thread 又在内部持有了控制器 self,造成了引用循环。

那么要打破这个引用循环可以使用 Block

self.thread = [[LSThread alloc] initWithBlock:^{
        NSLog(@"func -- %s   thread -- %@", __func__, [NSThread currentThread]);
        [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode];
        [[NSRunLoop currentRunLoop] run];
        NSLog(@"--- 结束 ---");
    }];

执行结果

解决循环引用.png

控制器被成功释放,但是子线程并没有被销毁,那么这个子线程变成了一个全局性质的。到这里就要说一下 RunLoop 的启动了。

RunLoop 有三种启动方式

- (void)run;

- (void)runUntilDate:(NSDate *)limitDate;

- (void)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;

run 方法内部会在NSDefaultRunLoopMode模式下重复调用runMode:beforeDate:方法。

runUntilDate: 方法可以设置超时时间,在超时时间到达之前,RunLoop会一直运行,在此期间RunLoop会处理来自sources的数据,并且 像 run 一样,也会在NSDefaultRunLoopMode模式下重复调用runMode:beforeDate:方法。

runMode:beforeDate: 方法RunLoop会运行一次,超时时间到达或者第一个source被处理,则RunLoop就会退出。

关于 run 方法 Apple 文档中有说如果希望退出 RunLoop,不应使用此方法。

如果RunLoop没有input sources或者附加的timer,RunLoop就会退出。虽然这样可以将RunLoop退出,但是Apple不建议我们这么做,系统内部有可能会在当前线程的RunLoop中添加一些输入源,所以通过手动移除input source或者timer这种方式,并不能保证RunLoop一定会退出。

那么问题就很明了了,我们不应该使用 run 方法来启动 RunLoop,因为它创建的是一个不会退出的循环,使用这个方法的子线程自然无法被销毁。我们可以像run 一样利用runMode:beforeDate: 方法来创建一个符合我们条件的子线程:

[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];

把它放到一个 while 循环中,利用一个是否停止 RunLoop 的全局标记来辅助处理线程的生命周期问题

__weak typeof(self) weakSelf = self;
    self.thread = [[LSThread alloc] initWithBlock:^{
        NSLog(@"func -- %s   thread -- %@", __func__, [NSThread currentThread]);
        [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode];
//        [[NSRunLoop currentRunLoop] run];
        while (!weakSelf.isStopedThread) {
            [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        }
        NSLog(@"--- 结束 ---");
    }];

停止 RunLoop 的方法

- (void)stop {
    self.isStopedThread = YES;
    CFRunLoopStop(CFRunLoopGetCurrent());
}

这里有一点需要注意,停止操作一定要在我们的目标线程执行,比如我们直接调用 stop 方法并不能达到我们预期的效果,这是因为stop 默认在主线程执行,没有拿到目标线程,停止无效。

- (void)stopAction {
    [self performSelector:@selector(stop) onThread:self.thread withObject:nil waitUntilDone:YES];
    self.thread = nil;
}

在当前线程调用stop ,我们的目的就达到了,顺利的结束了 RunLoop,线程也跟着销毁了。

完成保活.png

Flutter 生命周期

作者 舒迟丶
2021年9月26日 14:13

如果曾对 iOS 中的 ViewController 有过接触,那就很容易理解生命周期在 UI 绘制中的重要作用。Flutter 中也存在生命周期,它的回调方法都体现在 State 中,源码参考

Flutter 的生命周期分为页面(Widget)和 APP 两块。理解Flutter生命周期, 对写出一个合理的 Widget 和一个健壮的 APP 至关重要。

页面的生命周期

以 StatefulWidget 为例,来看一下 Flutter 页面的生命周期是怎样的。 Widget 的生命周期大体上可以分为三个阶段:

1. 初始化

  • createState
     //这个方法是必须重写的      
     @override
     _LifecycleWidgetState createState() => _LifecycleWidgetState();

当构建一个 StatefulWidget 时这个方法会被首先调用,而且这个方法是必须要重写的。

  • initState
@override
  void initState() {
  super.initState();
}

这个方法调用发生在 createState之后,是除构造方法之外,调用的第一个方法,它的作用类似于 Android 的 onCreate()和 iOS 的 viewDidLoad()。这个方法中通常会做一些初始化工作,比如 channel 的初始化、监听器的初始化等。 与 dispose() 相对应。

2. 状态改变

  • didChangeDependencies

    @override
      void didChangeDependencies() {
        super.didChangeDependencies();
      }
    

    这个方法要求必须要调用父类的方法super.didChangeDependencies,当依赖的 State 的对象改变时会调用。

    1. 在第一次构建 Widget 时,在 initState()之后立即调用此方法。
    2. 如果 StatefulWidgets 依赖于 InhertedWidget,那么当当前 State 所依赖 InheritedWidget 中的变量改变时会再次调用它。
  • build

@override
  Widget build(BuildContext context) {
      return Container();
  }

是一个必须实现的方法,在这里实现要呈现的页面内容。它会在didChangeDependencies()之后立即调用,另外当调用 setState() 后也会再次调用这个方法

  • didUpdateWidget
@override
void didUpdateWidget(covariant LifecycleWidget oldWidget) {
     super.didUpdateWidget(oldWidget);
}

调用 setState 将 Widget 的状态改变时 didUpdateWidget 会被调用,Flutter 会创建一个新的 Widget 来绑定这个 State,并在这个方法中传递旧的 Widget ,因此如果想比对新旧 Widget 并且对 State 做一些调整,可以使用它。

另外如果某些 Widget 上涉及到 controller 的变更,那么一定要在这个回调方法中移除旧的 controller 并创建新的 controller 监听。

3. 销毁

  • deactivate

    @override
      void deactivate() {
        super.deactivate();
      }
    

    这个方法不常用,它会在组件被移除时调用,而且是在dispose 调用之前

  • dispose

    @override
      void dispose() {
        super.dispose();
      }
    

    与 initState() 对应。

    组件销毁时调用,通常该方法中执行一些释放资源的工作,如监听器的移除,channel 的销毁等,相当于 iOS 的 dealloc方法参考

App 的生命周期

App 中会有比如从前台切入到后台再从后台切回到前台的场景,在 iOS 中这些生命周期都可以在 AppDelegate 中被体现,那么 Flutter 中我们该如何处理这些场景?对于 App 级别的生命周期与上述 Widget 生命周期相比,稍有不同。

源码参考

如果想监听 App 的生命周期需要使用 WidgetsBinding 来监听 WidgetsBindingObserverWidgetsBindingObserver是一个 Widgets 绑定观察器,通过它来监听应用的生命周期,使用混入的方式绑定观察器,并且需要在 dispose 回调方法中移除这个监听。

重写 didChangeAppLifecycleState ,当生命周期发生变化的时候会回调这个方法,

class _LifecycleAPPState extends State<LifecycleAPP>
    with WidgetsBindingObserver {
@override
  void initState() {
    //添加监听
    WidgetsBinding.instance!.addObserver(this);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Container();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    super.didChangeAppLifecycleState(state);
print(state);
  }

  @override
  void dispose() {
// 移除监听
    WidgetsBinding.instance!.removeObserver(this);
    super.dispose();
  }
}

Flutter 封装了一个枚举 AppLifecycleState 来描述 APP 的生命周期:

enum AppLifecycleState {
  resumed, // 进入前台
  inactive, // app 处于非活跃状态,并且未接收到用户输入的时候调用。比如接听来电
  paused,// 进入后台
  detached, // app 仍寄存在Flutter引擎上,但与原生平台分离
}

我们可以拿到这个 state 在 didChangeAppLifecycleState()来做一些我们需要的处理逻辑。

❌
❌