
做iOS开发这几年凡是涉及“用户协议里点链接”“标签可以点击跳转”“长文本要展开收起”这类需求最后基本都绕回同一个控件UITextView。每次接到这种需求很多人的第一反应是“UILabel设置attributedText不也能显示富文本吗”等真正实现“点文字有反馈、折行不串行、高度算得准”的时候才发现坑比想象中多。这篇文章就把UITextView在富文本、点击文字、文字折行这三个方向上的使用心得整理出来配合能直接用的代码和排查思路适合准备在项目里做富文本展示、动态文本交互的iOS开发者参考。1. 为什么是UITextViewUILabel、WKWebView与它的选型纠葛1.1 UITextView和UILabel差的不只是“能不能编辑”UILabel确实能显示NSAttributedString红色文字、加粗、不同字号、下划线都支持但它的交互能力几乎为零。UILabel不会因为设置了.link属性就自动识别链接你就算给它手动加一个UITapGestureRecognizer也只能拿到一个笼统的点击坐标还得自己用CoreText或者TextKit去算“点到的是哪个字”工作量瞬间上来了。UITextView则不同它是TextKit框架在UIKit层面的完整封装内部包含NSTextStorage、NSLayoutManager、NSTextContainer三层结构天生就带一套文本布局和交互体系。链接点击、长按选择、文本复制、光标移动编辑模式下这些能力都是系统内置的开发者只需要配置好属性、实现对应代理方法就行。在实际项目里如果只是单纯展示一段带颜色和字号的文案UILabel完全够用但一旦出现“某个词能点”“某个词能弹菜单”这种需求直接用UITextView才是正确起点。还要注意一个容易踩的坑很多人把UITextView设成isEditable false之后发现链接点不了然后怀疑是不是系统bug。其实是因为同时需要确保isSelectable true链接交互才能正常触发。isEditable控制的是键盘输入和文本修改isSelectable控制的是文本选择、链接交互、长按菜单这些能力。想做成“纯展示但可交互”的富文本视图正确组合就是isEditable false加isSelectable true。1.2 为什么不直接上WKWebView有些同学觉得WKWebView加载HTML也能显示富文本链接点击天然可用为什么不选它我早期也这么干过但后来发现几个很难忍受的问题。首先是性能和体验割裂WKWebView是一个独立的浏览器内核渲染进程页面滚动、手势响应和原生控件有明显差异尤其和列表嵌套时那种“不同世界”的错位感很影响体验。其次是字体适配问题原生App做动态字体适配辅助功能超大字号时WebView里的HTML字体需要额外桥接和同步做起来很麻烦。最后是事件处理链路长WebView内部点击、JS回调、原生交互层层传递调试成本高。UITextView走的是TextKit渲染管线底层是CoreText渲染效率和原生页面是一致的。而且UITextView直接继承自UIScrollView天然拥有滚动能力也能放进UITableViewCell或者UICollectionViewCell里参与复用尺寸计算、手势冲突都在UIKit体系内解决可控性强得多。补充一句TextKit的三层结构去理解它以后排查问题思路会清晰很多组件职责类比NSTextStorage持有全部文本内容和属性相当于数据源一本带批注的书NSLayoutManager负责将文本按容器宽度折行、生成字形相当于排版工人排版师傅NSTextContainer定义文本可绘制的区域和规则如宽度、行数限制纸张边界UITextView把这三者组合好并提供编辑和交互能力。所以如果你有非常冷门的排版需求比如文字绕排、不规则区域折行甚至可以直接绕过UITextView单独操作这三个组件灵活性远超WebView方案。2. 富文本地基NSAttributedString的常用属性与折行的真实规则2.1 构建富文本最常用的几个属性不管是点击文字还是折行计算底层都离不开NSAttributedString。构建富文本时最常用的是这些属性let attributedString NSMutableAttributedString(string: 我已阅读并同意《用户协议》和《隐私政策》) let fullRange NSRange(location: 0, length: attributedString.length) attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 14), range: fullRange) attributedString.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: fullRange) let agreementRange (attributedString.string as NSString).range(of: 《用户协议》) attributedString.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: agreementRange) attributedString.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: agreementRange) attributedString.addAttribute(.link, value: URL(string: app://agreement)!, range: agreementRange)这里有几个细节值得注意。.font、.foregroundColor、.backgroundColor属于字符级属性作用于被标记的字符.link的值可以传URLString也可以传URL对象系统识别为该区域为可点击链接如果要对某段文字加行距或段落间距必须用NSParagraphStyle。let paragraphStyle NSMutableParagraphStyle() paragraphStyle.lineSpacing 4 paragraphStyle.paragraphSpacing 8 paragraphStyle.alignment .left attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange)不要把.paragraphStyle挂在单个字符上它应该作用于整个段落否则不同段落之间可能出现奇怪的间距错乱。这一点我在早期遇到过某段文案的行距时有时无最后发现是范围只选到了前半段。2.2 lineBreakMode在UITextView里的特殊性和UILabel完全不同UILabel里控制文字超出后截断或省略用lineBreakMode就能搞定NSLineBreakMode.byTruncatingTail是最常见的“末尾省略”。但在UITextView里这套思路要打个问号。UITextView的默认折行规则是word wrap也就是按单词边界折行这种折行一定会把超过容器宽度的完整单词挪到下一行保证单词不被切断。真正的“截断”行为由textContainer控制具体来说是两个属性textView.textContainer.maximumNumberOfLines 3 textView.textContainer.lineBreakMode .byTruncatingTail注意textView.textContainer是UITextView在初始化时自动创建的NSTextContainer实例直接改它的属性就可以了不需要自己重建。maximumNumberOfLines设置为3表示最多显示3行超过部分不展示lineBreakMode设置为byTruncatingTail表示第3行末尾显示省略号。但是这里有一个非常隐蔽的坑如果先设置了maximumNumberOfLines再设置lineBreakMode在某些iOS版本上截断表现是正常的反过来先设置lineBreakMode再设置maximumNumberOfLines就有可能不生效。我自己的习惯是固定写成textView.textContainer.maximumNumberOfLines 3 textView.textContainer.lineBreakMode .byTruncatingTail另外textContainer的lineBreakMode只在maximumNumberOfLines大于0时起作用。如果maximumNumberOfLines 0表示不限制行数那lineBreakMode设置了也没意义这也是新手经常配置了却看不到省略号的原因之一。还有一个细节设置maximumNumberOfLines和lineBreakMode后务必确认textView.textContainer.lineFragmentPadding是否符合预期。默认值是5相当于右侧会额外留出5pt空白导致文本区域比视觉上窄一点。在计算折行宽度时如果忘了扣掉这个padding结果会有几像素的偏差。2.3 动态字体和辅助功能适配富文本最容易忘掉的是系统动态字体适配。如果你写死了UIFont.systemFont(ofSize: 14)用户开了超大字号后文本不会跟着变大就违反了iOS辅助功能的基本预期。正确做法是let font UIFont.preferredFont(forTextStyle: .body) let metrics UIFontMetrics(forTextStyle: .body) let scaledFont metrics.scaledFont(for: UIFont.systemFont(ofSize: 14)) textView.font scaledFont textView.adjustsFontForContentSizeCategory trueadjustsFontForContentSizeCategory是UITextView自带的属性设置为true后它的font会跟随系统字体大小变化。但如果你用的是NSAttributedString逐段设置.font那adjustsFontForContentSizeCategory不会自动帮你缩放属性字符串里的字体需要自己监听UIContentSizeCategoryDidChangeNotification重新构建富文本或者给动态字体数据源做监听。这一点在“用户协议、隐私政策”这类页面最容易出问题因为这类文案大多是富文本拼接出来的。3. 点击文字链接跳转、命中检测与手势冲突的完整排查3.1 最省事也是推荐优先用的NSLink方案上一节给出了一段带.link属性的富文本只要再实现代理方法就能响应链接点击。UITextView代理里和链接点击相关的方法有两代第一代是iOS 10之前就开始用的func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) - Bool { if URL.scheme app { // 处理自定义scheme return false } return true }第二代是iOS 10之后新增的多了一个interaction参数可以区分是轻点、长按预览还是辅助操作func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) - Bool { if URL.scheme app { switch interaction { case .tap: // 普通点击做页面跳转 handleInternalURL(URL) case .preview: // 长按预览可以忽略或做自定义处理 break case .actions: // 弹出操作菜单 break unknown default: break } return false } return true }实际使用中我更建议用带UITextItemInteraction的新版代理这样能精细化区分用户行为。需要注意的是return false代表“链接交互由你接管系统不再打开URL”return true代表“按系统默认方式处理比如http链接用Safari打开”。如果你接的是自定义scheme必须return false否则系统会尝试用openURL打开一个你未必注册过的scheme轻则弹提示重则Crash。还有一种情况不想实现代理方法而是想在点击时做一次性的业务埋点。可以保持return true但在代理方法里先埋点再返回这样系统行为不打断埋点也做完了。很多项目上线后的“用户协议点击率”就是这个链路里统计出来的。3.2 点击“非链接”文字命中具体字符的检测方法链接能点之后下一个需求往往是“点击普通文字也要响应”比如点一个用户昵称要跳转个人主页点一个话题标签要进入话题页。这种场景不适合用.link属性因为你想自定义的“目标”不是一个URL而是一个业务ID。我的做法是给这部分文字添加一个自定义属性比如let mentionKey NSAttributedString.Key(MentionUserID) let nickRange (text as NSString).range(of: nickName) attributedString.addAttribute(mentionKey, value: userID, range: nickRange) attributedString.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: nickRange)然后在自定义的UITapGestureRecognizer里把点击坐标换算成字符索引再检查该索引位置有没有对应的自定义属性func tappedCharacterIndex(in textView: UITextView, at point: CGPoint) - Int? { let pointInTextView CGPoint( x: point.x - textView.textContainerInset.left, y: point.y - textView.textContainerInset.top ) var fraction: CGFloat 0 let index textView.layoutManager.characterIndex( for: pointInTextView, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: fraction ) return index } func handleTap(at point: CGPoint, in textView: UITextView) { guard let index tappedCharacterIndex(in: textView, at: point), index textView.attributedText.length else { return } var range NSRange(location: 0, length: 0) if let userID textView.attributedText.attribute(mentionKey, at: index, effectiveRange: range) as? String { // 跳转用户主页 openUserProfile(userID: userID) } }这段逻辑里最关键的偏移量是textContainerInset和lineFragmentPadding漏了任何一个点击坐标换算都会偏。characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:)返回的是字符索引如果用这个索引去获取attributecareful一点的是需要判断index是否越界因为点击到文本末尾空白区域时返回的index可能等于文本长度直接去取attribute会越界。这种方法也能用来判断“点击到了哪张图片附件”。回顾第2节提到TextKit三层结构图片本质上是NSTextAttachment字符通过布局管理器可以获取附件字符的boundingRectlet glyphRange textView.layoutManager.glyphRange(forCharacterRange: characterRange, actualCharacterRange: nil) let rect textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer)拿到rect之后就能判断点击坐标是否落在某张图片的显示区域内从而响应“点击图片查看大图”之类的交互。3.3 手势冲突排查实录自定义手势不触发的完整链路我在做一个社区类App时遇到过一个困扰很久的问题UITextView里添加了自己的UITapGestureRecognizer明明代码逻辑都对但怎么点都不触发。查了半天才发现问题出在UITextView内部手势和自定义手势的竞争关系上。UITextView内部本身带有好几个手势识别器点击手势触发链接跳转和光标定位、长按手势触发文本选择和菜单、双击手势等。系统默认情况下这些内部手势会优先于外部添加的手势识别器。也就是说你的UITapGestureRecognizer拿不到手势识别结果因为内部的点击手势声称“我处理了这次触摸”。排查链路是这样的先确认自定义手势确实添加到了UITextView上而不是它的父视图或contentView。检查是否设置了gesture.cancelsTouchesInView false如果为true手势识别成功后会取消UITextView内部的触摸处理可能破坏文本选择等系统行为。实现UIGestureRecognizerDelegate让自定义手势和系统手势可以共存extension YourClass: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) - Bool { return true } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) - Bool { if otherGestureRecognizer is UITapGestureRecognizer { return false } return false } }实际项目中我通常不在UITextView上自建点击手势去处理链接因为链接已经走代理了我只用自定义手势处理“非链接区域的点击”这时更合适的做法是判断点击位置是否落在链接范围之外。检测方法是在第3.2节的handleTap里先检查该索引处有没有.link属性或有NSLink关联的URL有就直接放行不处理没有才执行自己的逻辑。这样最干净也不会和系统抢交互。还有一类常见问题在UITableViewCell里嵌入UITextViewcell的选中手势和textView的手势互相拦截导致“点一下cell没反应但点文字有反应”。我们把tableView.allowsSelection true的情况下textView会拦截触摸事件导致cell无法选中。此时可以让cell的selectionStyle保持正常然后在tableView(_:didSelectRowAt:)里做对应跳转但需要把UITextView的isUserInteractionEnabled在需要cell点击的场景下设为false。一句话概括手势排查没有银弹先缩小范围再看谁拦截了谁。4. 文字折行的计算高度、行数与动态布局的正确姿势4.1 高度计算的标准姿势sizeThatFits优先级最高用UITextView做动态高度Cell时第一个要解决的问题是“给定宽度文本到底有多高”。最稳妥、简洁的方案是sizeThatFitslet maxWidth textView.bounds.width let fittingSize textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)) let neededHeight fittingSize.height注意这里传的宽度应该是UITextView自身的宽度。如果要在Cell内部使用宽度要扣除左右间距、屏幕安全区、以及tableView的separatorInset等最终文本视图的实际宽度是多少就传多少。很多人会问为什么不用boundingRect(with:options:)直接算NSAttributedString的高度当然可以但要注意两点UILabel的textRect(forBounds:limitedToNumberOfLines:)只适用于单行文本多行富文本计算不准。boundingRect(with:options:)计算出来的高度并不总是等于UITextView实际渲染高度。因为UITextView内部还有textContainerInset上下左右内边距和lineFragmentPadding直接把 boundingRect 高度当成UITextView高度会导致上下被裁切或者多出空白。我自己实测下来sizeThatFits表现最稳定因为它内部已经考虑了textContainer的内边距设置。如果要使用boundingRect那么UITextView的高度可以近似为let textHeight attributedString.boundingRect( with: CGSize(width: maxTextWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil ).height let textViewHeight textHeight textView.textContainerInset.top textView.textContainerInset.bottom注意.usesFontLeading这个option它计算时会把字体的leading行距系数纳入忽略它可能导致行高偏小。这点很多人会漏结果就是“最后一行的字被裁掉一半”。4.2 行数与“展开全文”maximumNumberOfLines的动态变化“展开全文”按钮本质上就是对textContainer.maximumNumberOfLines的切换。收起时设置3行展开时设置0行不限制func toggleExpand(_ expand: Bool) { textView.textContainer.maximumNumberOfLines expand ? 0 : 3 textView.textContainer.lineBreakMode expand ? .byWordWrapping : .byTruncatingTail }这里有一个需要提前判断的边界如果文本实际只有2行那么要不要显示“展开”按钮判断方式是用第4.1节的高度计算如果算出来的完整高度小于等于3行高度就不需要按钮。计算行数的标准姿势是遍历布局管理器生成的line fragmentfunc lineCount(of textView: UITextView) - Int { var count 0 let layoutManager textView.layoutManager let glyphRange layoutManager.glyphRange(for: textView.textContainer) layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { _, _, _, _, _ in count 1 } return count }注意enumerateLineFragments的闭包里返回的是CGRect等参数这里用_接收即可。这个方法的准确度高于“总高度除以行高”因为最后一行可能不满整行高度不一定等于常规行高。关于“展开全文”还有一个小细节切换maximumNumberOfLines后textView的高度约束需要联动更新否则视觉上要么被截断要么多出空白。我的做法是给textView设一个高度约束每次点击展开按钮时重新调用sizeThatFits计算高度然后UIView.animate更新约束常量let newHeight textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)).height heightConstraint.constant newHeight UIView.animate(withDuration: 0.25) { self.contentView.layoutIfNeeded() }在多行折叠场景下cell的高度变化天然伴随contentSize的抖动。如果你发现展开动画高度变化但textView的contentSize没同步刷新可以先调一下textView.layoutIfNeeded()让内部布局落定再做高度约束动画顺序反了会闪一下。4.3 性能取舍别在主线程反复量文本富文本高度计算的性能瓶颈集中在字符串测量上。一个列表页如果同时有几十个Cell每个Cell都要sizeThatFits一次数据源每次reload都会重复测量卡顿会很明显。我的优化策略有三条缓存计算结果。以“文本内容字体设置可用宽度”为key高度为value放进内存字典。注意key必须包含字号、字体、行距这些影响高度的属性否则换字号后取了旧缓存高度会错。尽量不在tableView(_:heightForRowAt:)里做重量级的富文本重建。如果你已经在数据模型里准备好了NSAttributedString就直接测量如果每次进代理才现拼字符串、现设属性性能一定差。数据源层搭建时就一次性组装好富文本这里省下来的时间体感很明显。文本内容特别长超过几千字时考虑把测量放到后台队列或者只对当前可见Cell做精确测量用估算高度占位等滚动到附近再填充精确高度。UITableView的estimatedRowHeightsystemLayoutSizeFitting方案在很多项目里能显著降低首屏耗时。还有一点NSTextStorage构建也是有成本的如果同一段富文本要反复用于展示和高度的测量尽量持有同一个NSMutableAttributedString实例不要每次测量都new一遍。5. 实战进阶图片混排、提及、折叠展开的落地指南5.1 NSTextAttachment实现图片混排富文本里插图片用的是NSTextAttachment。一个比较典型的场景是“在昵称后面显示一个小认证图标”做法是把图标作为附件字符插入富文本let attachment NSTextAttachment() attachment.image UIImage(named: verified_icon) attachment.bounds CGRect(x: 0, y: -3, width: 16, height: 16) let attachmentString NSAttributedString(attachment: attachment) let mutableString NSMutableAttributedString(string: nickName) mutableString.append(attachmentString)bounds的y值需要注意为负值时让图片相对于文字基线稍微下沉视觉上对齐文字中部。这个值没有固定标准不同字体大小下会有偏差建议实测调整。如果不设置bounds默认图片尺寸会基于原图大小经常出现“一个比字号还大的图嵌在文字里”的翻车现场。图片附件如果要支持点击就用第3.2节讲到的boundingRect(forGlyphRange:in:)找到图片的显示rect再判断点击坐标是否落在其中。我的实现里会先把所有附件的rect收集起来点击时遍历判断效率完全够。5.2 提及和话题标签的完整交互方案社交类App最容易出现“某某”和“#话题#”的点击需求。这个场景我的推荐组合是自定义属性 点击检测不使用.link。为什么不用.link因为默认的链接点击代理只能拿到URL而你还需要拿到“了哪个用户ID”或“话题ID”这些业务数据不适合编码进URL分散维护起来也麻烦。自定义属性可以把任意对象挂在range上检测时直接读取逻辑更集中。构建时let bodyText 今天和 小明 一起聊了 #iOS开发# let attributedString NSMutableAttributedString(string: bodyText) let mentionRange (bodyText as NSString).range(of: 小明) attributedString.addAttribute(mentionKey, value: user_id_12345, range: mentionRange) attributedString.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: mentionRange)注意后面的人名不能提前写死在富文本里而应该根据数据源动态填充。如果用户昵称含有空格、表情range(of:)直接按字符串匹配可能会出错更稳妥的方式是用正则表达式匹配“[\u4e00-\u9fa5A-Za-z0-9_]”这类模式或者干脆从数据模型端就明确每个片段的range不靠字符串二次查找。检测时见3.2节先查自定义属性如果该位置没有自定义属性再查.link属性最后fallback给系统默认行为。这样既支持业务属性也不破坏系统链接。5.3 折叠展开按钮文案的处理“展开全文”通常不是普通文字而是一个可点击的按钮样式。我习惯用附加属性把“展开”两个字标成一个可点击的range点击后切换展开状态并更新文案为“收起”。一个经常被忽略的问题是折叠状态下点击“展开”操作的是“第3行末尾的省略号后面那段文字”它的字符索引在原始完整文本中可能位于第100个字符附近但因为折叠时maximumNumberOfLines 3第100个字符在布局上是不可见的。这时通过字符索引判断点击位置会出现矛盾——你在视觉上点到了“展开”但索引对应的却是第80个字符的位置。解决办法是使用独立的字符串独立富文本构建“展开”按钮不要把“展开”两个字嵌入原始文本的末尾再一起截断。换句话说把原始文本设置为“正文…展开”但“展开”两个字单独用一个小范围属性标记让它的字符位置在折叠时仍然可见。实现上我用的技巧是创建一段只含有原文“展开”的富文本然后对“展开”的range单独加属性再整体设置给textView。因为maximumNumberOfLines只会影响整体布局的可见行数并不会真正删除字符“展开”所在字符本身仍然可以参与点击命中。如果用纯文本方案实在绕不开还有一个替代方案用textView.textContainer.exclusionPaths动态挖掉右下角一块区域把“展开”按钮做成真正的UIButton盖在右上角。这个方案交互上更接近原生控件滚动联动也容易处理但布局计算更繁琐属于进阶玩法适合在需要完全自定义的页面上使用。TextKit的体系确实庞大但把UITextView的富文本构建、点击命中、折行高度这三块串起来之后很多需求就变得有章可循了。我自己的体会是不要在出问题的最外层打补丁而是回到NSAttributedString和textContainer这一层去找原因大部分疑难杂症都能在结构上找到答案。最后分享一个小技巧调试UITextView布局问题时可以临时把textView.textContainerInset改大或者加一层背景色肉眼判断文本范围和预期是否一致定位到究竟是内边距、lineFragmentPadding还是宽度约束导致的偏差。这套方法在我的项目里帮了不少忙比反复打日志都快。