Post

iOS UITextView 文绕图编辑器开发攻略|Swift 图片插入与记忆体优化实战

针对 iOS UITextView 图片穿插与多图上传痛点,分享 Swift 实作文绕图编辑器的完整解法,包含图片插入游标定位、上传失败处理及记忆体优化技巧,有效避免多图导致卡顿与闪退,提升 APP 稳定度与使用体验。

iOS UITextView 文绕图编辑器开发攻略|Swift 图片插入与记忆体优化实战

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


iOS UITextView 文绕图编辑器 (Swift)

实战路线

目标功能:

APP上有一个让使用者能发表文章的讨论区功能,发表文章功能介面需要能输入文字、插入多张图片、支援文绕图穿插.

功能需求:

  • 能输入多行文字

  • 能在行中穿插图片

  • 能上传多张图片

  • 能随意移除插入的图片

  • 图片上传效果/失败处理

  • 能将输入内容转译成可传递文本 EX: BBCODE

先上个成品效果图:

[结婚吧APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

结婚吧APP

开始:

第一章

什么?你说第一章?不就用UITextView就能做到编辑器功能,哪来还需要分到「章节」;是的,我一开始的反应也是如此,直到我开始做才发现事情没有那么简单,其中苦恼了我两个星期、翻片国内外各种资料最后才找到解法,实作的心路历程就让我娓娓道来….

如果想直接知道最终解法,请直接跳到最后一章(往下滚滚滚滚滚).

一开始

文字编辑器理所当然是使用UITextView元件,看了一下文件UITextView attributedText 自带 NSTextAttachment物件 可以附加图片实做出文绕图效果,程式码也很简单:

1
2
3
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(named: "example")
self.contentTextView.attributedText = NSAttributedString(attachment: imageAttachment)

当初天真的我还很开心想说蛮简单的啊、好方便;问题现在才正要开始:

  • 图片要能是从本地选择&上传:这好解决,图片选择器我使用 TLPhotoPicker 这个套件(支援多图选择/客制化设定/切换相机拍照/Live Photos),具体作法就是 TLPhotoPicker选完图片Callback后将PHAsset转成UIImage塞进去imageAttachment.image并预先在背景上传图片至Server。

  • 图片上传要有效果并能添加互动操作(点击查看原图/点击X能删除):没做出来,找不到NSTextAttachment有什么办法能做到这项需求,不过这功能没有还行反正还是能删除(在图片后按键盘上的「Back」键能删除图片),我们继续…

  • 原始图档案过大,上传慢、插入慢、吃效能:插入及上传前先Resize过,用 Kingfisher 的resizeTo

  • 图片插入在游标停留的位置:这里就要将原本的Code改成如下

1
2
3
4
let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText) //取得当前内容
combination.insert(NSAttributedString(attachment: imageAttachment), at: range)
self.contentTextView.attributedText = combination //回写回去
  • 图片上传失败处理:这里要说一下,我实际另外写了一个Class 扩充原始的 NSTextAttachment 目的就是要多塞个属性存识别用的值
1
2
3
class UploadImageNSTextAttachment:NSTextAttachment {
   var uuid:String?
}

上传图片时改成:

1
2
3
let id = UUID().uuidString
let attachment = UploadImageNSTextAttachment()
attachment.uuid = id

有办法辨识NSTextAttachment的对应之后,我们就能针对上传失败的图片,去attributedTextd里做NSTextAttachment搜索,找到他并取代成错误提示图或直接移除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if let content = self.contentTextView.attributedText {
    content.enumerateAttributes(in: NSMakeRange(0, content.length),  options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in
        if object.keys.contains(NSAttributedStringKey.attachment) {
            if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == "目标ID" {
                attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30)
                attachment.image =  UIImage(named: "IconError")
                let combination = NSMutableAttributedString(attributedString: content)
                combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
                //如要直接移除可用deleteCharacters(in: range)
                self.contentTextView.attributedText = combination
            }
        }
    }
}

克服上述问题后,程式码大约会长成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class UploadImageNSTextAttachment:NSTextAttachment {
    var uuid:String?
}
func dismissPhotoPicker(withTLPHAssets: [TLPHAsset]) {
    //TLPhotoPicker 图片选择器的Callback
    
    let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
    //取得游标停留位置,无则从头
    
    guard withTLPHAssets.count > 0 else {
        return
    }
    
    DispatchQueue.global().async { in
        //在背景处理
        let orderWithTLPHAssets = withTLPHAssets.sorted(by: { $0.selectedOrder > $1.selectedOrder })
        orderWithTLPHAssets.forEach { (obj) in
            if var image = obj.fullResolutionImage {
                
                let id = UUID().uuidString
                
                var maxWidth:CGFloat = 1500
                var size = image.size
                if size.width > maxWidth {
                    size.width = maxWidth
                    size.height = (maxWidth/image.size.width) * size.height
                }
                image = image.resizeTo(scaledToSize: size)
                //缩图
                
                let attachment = UploadImageNSTextAttachment()
                attachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)
                attachment.uuid = id
                
                DispatchQueue.main.async {
                    //切回主执行绪更新UI插入图片
                    let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText)
                    attachments.forEach({ (attachment) in
                        combination.insert(NSAttributedString(string: "\n"), at: range)
                        combination.insert(NSAttributedString(attachment: attachment), at: range)
                        combination.insert(NSAttributedString(string: "\n"), at: range)
                    })
                    self.contentTextView.attributedText = combination
                    
                }
                
                //上传图片至Server
                //Alamofire post or....
                //POST image
                //if failed {
                    if let content = self.contentTextView.attributedText {
                        content.enumerateAttributes(in: NSMakeRange(0, content.length),  options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in
                            
                            if object.keys.contains(NSAttributedStringKey.attachment) {
                                if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == obj.key {
                                    
                                    //REPLACE:
                                    attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30)
                                    attachment.image = //ERROR Image
                                    let combination = NSMutableAttributedString(attributedString: content)
                                    combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
                                    //OR DELETE:
                                    //combination.deleteCharacters(in: range)
                                    
                                    self.contentTextView.attributedText = combination
                                }
                            }
                        }
                    }
                //}
                //
                
            }
        }
    }
}

到此差不多问题都解决了,那是什么苦恼了我两周呢?

答:「记忆体」问题

iPhone 6顶不住啊!

iPhone 6顶不住啊!

以上做法插入超过5张图片,UITextView就会开始卡顿;到一个程度就会因为记忆体负荷不了APP直接闪退

p.s 试过各种压缩/其他储存方式,结果依然

推测原因是,UITextView没有针对图片的NSTextAttachment做Reuse,你所插入的所有图片都Load在记忆体之中不会释放;所以除非是拿来穿插表情符号那种小图😅,不然根本不能拿来做文绕图

第二章

发现记忆体这个「硬伤」后,继续在网路上搜索解决方案,得到以下其他做法:

  • 用WebView嵌套HTML档案( <div contentEditable=”true”></div>)并用JS跟WebView做交互处理

  • 用UITableView结合UITextView,能Reuse

  • 基于TextKit自行扩充UITextView🏆

第一项用WebView嵌套HTML档案的做法;考量到效能跟使用者体验,所以不考虑,有兴趣的朋友可以在Github搜寻相关的解决方案(EX: RichTextDemo )

第二项用UITableView结合UITextView

我实作了大约7成出来,具体大约是每一行都是一个Cell,Cell有两种,一种是UITextView另一种是UIImageView,图片一行文字一行;内容必须用阵列去储存,避免Reuse过程消失

能优秀的Reuse解决记忆体问题,但做到后面还是放弃了,在 控制行尾按Return要能新建一行并跳到该行控制行头按Back键要能跳到上一行(若当前为空行要能删除该行) 这两个部分上吃足苦头,非常难控制

有兴趣的朋友可参考: MMRichTextEdit

最终章

走到这里已经耗费了许多时间,开发时程严重拖延;目前最终解法就是用TextKit

这里附上两篇找到的文章给有兴趣研究的朋友:

但有一定的学习门槛,对我这个菜鸟来说太难了,再说时间也已不够,只能漫无目的在Github寻找他山之石借借用用

最终找到 XLYTextKitExtension 这个项目,可以直接引入Code使用

✔ 让 NSTextAttachment 支援自订义UIView 要加什么交互操作都可以

✔ NSTextAttachment 可以Reuse 不会撑爆记忆体

具体实作方式跟 第一章 差不多,就只差在原本是用NSTextAttachment而现在改用XLYTextAttachment

针对要使用的UITextView:

1
contentTextView.setUseXLYLayoutManager()

Tip 1:插入NSTextAttachment的地方改为

1
2
3
4
5
6
7
8
9
let combine = NSMutableAttributedString(attributedString: NSAttributedString(string: ""))
let imageView = UIView() // your custom view
let imageAttachment = XLYTextAttachment { () -> UIView in
    return imageView
}
imageAttachment.id = id
imageAttachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)
combine.append(NSAttributedString(attachment: imageAttachment))
self.contentTextView.textStorage.insert(combine, at: range)

Tip 2:NSTextAttachment搜索改为

1
2
3
4
5
self.contentTextView.textStorage.enumerateAttribute(NSAttributedStringKey.attachment, in: NSRange(location: 0, length: self.contentTextView.textStorage.length), options: []) { (value, range, stop) in
    if let attachment = value as? XLYTextAttachment {
        //attachment.id
    }
}

Tip 3:删除NSTextAttachment项目改为

1
self.contentTextView.textStorage.deleteCharacters(in: range)

Tip 4:取得当前内容长度

1
self.contentTextView.textStorage.length

Tip 5:刷新Attachment的Bounds大小

主因是为了使用者体验;插入图片时我会先塞一张loading图,插入的图片在背景压缩后才会替换上去,要去更新TextAttachment的Bounds成Resize后大小

1
self.contentTextView.textStorage.addAttributes([:], range: range)

(新增空属性,触发刷新)

Tip 6: 将输入内容转译成可传递文本

运用Tip 2搜索全部输入内容并将找到的Attachment取出ID组合成类似[ [ID] ]格式传递

Tip 7: 内容取代

1
self.contentTextView.textStorage.replaceCharacters(in: range,with: NSAttributedString(attachment: newImageAttachment))

Tip 8: 正规表示法匹配内容所在Range

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let pattern = "(\\[\\[image_id=){1}([0-9]+){1}(\\]\\]){1}"
let textStorage = self.contentTextView.textStorage

if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
    while true {
        let range = NSRange(location: 0, length: textStorage.length)
        if let match = regex.matches(in: textStorage.string, options: .withTransparentBounds, range: range).first {
            let matchString = textStorage.attributedSubstring(from: match.range)
            //FINDED!
        } else {
            break
        }
    }
}

注意:如果你要搜寻&取代项目,需要使用While回圈,不然当有多个搜寻结果时,找到第一个并取代后,后面的搜寻结果的Range就会错误导致闪退.

结语

目前使用此方法完成成品并上线了,还没遇到有什么问题;有时间我再来好好探究一下其中的原理吧!

这篇比较不是教学文章,而是个人解题心得分享;如果您也在实作类似功能,希望有帮助到你,有任何问题及指教欢迎与我联络.

Medium的正式第一篇

延伸阅读

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.