简介:面向iOS开发者的HTML字符串与富文本互转Demo源码,聚焦NSAttributedString与HTML内容转换这一高频需求,尤其适合处理服务端返回HTML标签、需在UILabel或UITextView中呈现丰富视觉效果的应用场景。资源以NSAttributedString4html为示例工程,展示加载本地HTML内容并转换为富文本的完整可运行逻辑,覆盖标签解析、属性映射、链接识别等常见转换细节,也涉及对复杂HTML标签的兼容性思考,可直接参考或移植到实际项目中。压缩包为ZIP格式,整体大小1.38MB,下载页未单独标注内部文件数量,主体内容为源码工程,便于直接打开查看。该Demo已有2700余人学习下载,适合对iOS富文本渲染、HTML加载与性能优化有提升需求的初中级开发者。通过源码可快速理解HTML到NSAttributedString的转换流程,并掌握在UILabel中展示加粗、斜体、链接等富文本效果的实践方法。
1. 为什么HTML字符串与NSAttributedString互转总在本地HTML上翻车
很多项目做到后期都会遇到同一个坑:服务端下发的HTML片段转NSAttributedString显示得很好,一旦改成从本地Bundle读HTML,图片全裂、字体变小,甚至整个字符串解析不出来。这个现象在“NSAttributedString4html”源码里体现得很典型。核心分歧在于系统提供的initWithData:options:documentAttributes:error:默认行为只按“服务器返回的一段完整网页”来解析,而本地HTML通常带相对路径、非UTF8编码和自定义CSS,这些都不在默认参数覆盖范围内。下面把HTML字符串与富文本互转的两个方向都拆开:正向用NSAttributedString渲染HTML,反向用NSAttributedString导出HTML,再单独讲本地HTML加载时的baseURL、编码和性能问题。适合正被UILabel、UITextView显示HTML折磨的iOS开发,以及准备自己封装富文本解析器的同学参考。
2. HTML转NSAttributedString的底层逻辑与默认选项
2.1 系统API究竟做了什么
从iOS 7开始,NSAttributedString提供了一个看似简单的入口:initWithData:options:documentAttributes:error:。传入HTML的NSData,它就能返回带格式的富文本。底层实现是WebKit的HTML解析器在打工,它先把HTML解析成DOM树,再把节点样式逐一映射到UIKit的属性键上。这意味着它能识别<b>、<i>、<u>、<font>这类标签,但遇到<div>的padding、margin、表格布局时就无能为力了——NSAttributedString没有“边距”和“盒模型”的概念。
这个设计有一个明显代价:当你需要完整还原网页布局时,系统API并不可用。它只负责把文本级的样式搬运过来,任何与“位置”相关的样式都会静默丢弃。所以很多项目在比较了DTCoreText后还是会回到系统API,因为大多数内容展示场景只需要字体、颜色、段落和链接,系统API零依赖且够用。
2.2 options参数表与选值说明
同一份HTML,不同的options配置可能得到完全不同的结果。关键参数如下表:
| options键 | 推荐值 | 作用 |
|---|---|---|
NSDocumentTypeDocumentAttribute | NSHTMLTextDocumentType | 声明输入是HTML,驱动WebKit解析 |
NSCharacterEncodingDocumentAttribute | @(NSUTF8StringEncoding) | 指定输入Data的编码,与Data不一致会乱码 |
NSBaseURLDocumentAttribute | NSURL实例 | 相对路径资源的基础URL,本地HTML必需 |
NSDefaultAttributesDocumentAttribute | @{NSFontAttributeName: font} | 未显式声明样式时的默认字体 |
NSFontSizeMultiplierDocumentAttribute | @1.2 | 对最终字号做整体缩放,适配动态字体 |
其中NSBaseURLDocumentAttribute是本地HTML和网络HTML的分水岭。网络HTML通常用绝对URL,这个参数可有可无;本地HTML里的src="images/a.png"如果没有baseURL,会被解析到沙盒根目录,必然失败。NSDefaultAttributesDocumentAttribute负责兜底:如果你不想正文显示Times New Roman 12号,最好把它设置成App的正文样式。
2.3 一个可直接跑的HTML转富文本示例
NSString *html = @"<html><head><style>body{font-size:16px;}</style></head>" "<body><b>加粗</b> 与 <a href=\"https://example.com\">链接</a></body></html>"; NSData *data = [html dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *options = @{ NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding), NSDefaultAttributesDocumentAttribute: @{ NSFontAttributeName: [UIFont systemFontOfSize:17] } }; NSError *error = nil; NSAttributedString *attributedString = [[NSAttributedString alloc] initWithData:data options:options documentAttributes:nil error:&error]; if (error) { NSLog(@"解析失败: %@", error); }代码里NSDefaultAttributesDocumentAttribute指定的字体,只对没有font-family规则的文本生效。如果HTML内联了style="font-size:20px",最终字号会按20px来,链接颜色则继续使用WebKit默认的蓝色。解析出的NSAttributedString可以直接赋给UITextView.attributedText,但要注意UILabel对NSLinkAttributeName的响应有限,点击链接还是得用UITextView。
提示:
initWithData:内部会尝试修复不规范的HTML,比如补全<li>的闭合标签。这带来两个副作用:一是解析时间随HTML复杂度线性增长,二是某些自定义标签(如<video>占位)会被直接剥离。如果HTML里塞了几百个节点,建议先做一次节点裁剪再转换。
3. 加载本地HTML:从Bundle读取到相对路径资源修复
3.1 从Bundle读取HTML:编码不只是UTF-8
本地HTML的第一步是读入字符串,这一步就经常翻车。pathForResource:ofType:拿到的路径没错,stringWithContentsOfFile:encoding:却返回nil,十有八九是编码问题。国内很多模板文件保存为GBK/GB18030,直接用NSUTF8StringEncoding读必然失败。
NSString *path = [[NSBundle mainBundle] pathForResource:@"article" ofType:@"html"]; NSError *readError = nil; NSString *htmlString = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:&readError]; if (!htmlString) { NSLog(@"UTF-8读取失败,尝试GB18030: %@", readError); htmlString = [NSString stringWithContentsOfFile:path encoding:CFStringConvertEncodingToNSStringEncoding(kCFStringEncodingGB_18030_2000) error:&readError]; }这里CFStringConvertEncodingToNSStringEncoding把CoreFoundation编码常量转为Foundation编码。kCFStringEncodingGB_18030_2000是GBK的超集,能覆盖绝大多数中文HTML。如果两者都失败,就要检查文件是否带BOM或是否被额外加密。读取成功后,建议再通过htmlString.length做一次非空校验,避免空文件继续往下走。
3.2 相对路径图片失效:baseURL缺席
很多人在第一步成功后,直接把htmlString丢给initWithData:,然后发现<img src="images/pic.png">变成空白。原因在上面提过:没有NSBaseURLDocumentAttribute时,系统把相对路径拼到沙盒根目录下,这个路径下根本不存在images目录。这个问题的表现也有好几种:
| 现象 | 根因 | 解决 |
|---|---|---|
| 图片全部空白 | 相对路径无baseURL | 传入HTML文件所在目录的fileURL |
| 只有部分图片显示 | HTML混用/images和images | 统一改为相对路径 |
| 图片显示但尺寸巨大 | 未做尺寸适配 | 在HTML中限制img的width/height |
| 链接点击无反应 | UITextView未开启交互 | 设置selectable并实现delegate |
这些根因里,baseURL是定位起来最绕的。因为调试时URL看着是file:///images/pic.png,容易误以为是图片文件放错位置,实际上只是系统没拿到正确的参照目录。
3.3 用baseURL解决本地资源引用
NSString *path = [[NSBundle mainBundle] pathForResource:@"article" ofType:@"html"]; NSURL *baseURL = [NSURL fileURLWithPath:[path stringByDeletingLastPathComponent]]; NSData *htmlData = [NSData dataWithContentsOfFile:path]; NSDictionary *options = @{ NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding), NSBaseURLDocumentAttribute: baseURL }; NSAttributedString *result = [[NSAttributedString alloc] initWithData:htmlData options:options documentAttributes:nil error:nil];注意baseURL必须指向HTML文件所在的目录,而不是文件本身。stringByDeletingLastPathComponent的作用就是去掉文件名、保留目录。如果误传文件URL,图片的URL会变成article.html/images/pic.png这类畸形路径,同样加载失败。
另外一个容易被忽略的点:本地HTML里常有<link rel="stylesheet" href="css/style.css">,这种外部样式表不会被initWithData:加载。系统API只处理内联样式和<style>标签。如果你依赖外部CSS,需要先手动读取CSS文件内容,把内容拼成一个<style>块注入HTML。这也是“NSAttributedString4html”源码里值得借鉴的处理:它在读取HTML后、转换前,做了CSS注入和baseURL组装,避免每个调用方重复踩坑。
4. 富文本转HTML:反向转换与样式保留
4.1 dataFromRange导出HTML的默认行为
反向转换使用dataFromRange:documentAttributes:error:,把NSAttributedString序列化为HTML格式的NSData。这个过程会遍历富文本的所有属性,生成对应的内联HTML标签。
NSAttributedString *input = attributedString; // 已有富文本 NSError *error = nil; NSData *htmlData = [input dataFromRange:NSMakeRange(0, input.length) documentAttributes:@{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType} error:&error]; NSString *html = [[NSString alloc] initWithData:htmlData encoding:NSUTF8StringEncoding];默认输出会带有大量内联样式,类似这样:
<span style="font-family: -apple-system, 'Helvetica Neue'; font-size: 17px; color: rgb(0, 0, 0);">文本</span>如果只是做富文本编辑器的本地持久化,这种格式可以接受。但如果要交给服务端或嵌入网页,内联样式会非常膨胀,而且rgb()格式和命名颜色的混用会让其他端解析工作量翻倍。常见的优化是导出后做一次清洗,把rgb()转为十六进制,把连续的<span>合并。
4.2 RTF与HTML:按用途选择
除了HTML,系统还支持导出RTF和纯文本。三者适用场景差异很大:
| 导出格式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
NSHTMLTextDocumentType | 跨端展示、网页复用 | 可被浏览器直接解析 | 内联样式冗余,体积大 |
NSRTFTextDocumentType | 本地还原富文本 | 还原度高、体积小 | iOS私有,其他端解析成本高 |
NSPlainTextDocumentType | 纯文本导出 | 体积最小 | 丢失所有格式 |
NSRTFTextDocumentType得到的RTF数据在iOS内部可以无损还原,但服务端如果做全文检索或网页预览,还是要转成HTML。所以除非你的业务明确只在App内使用,否则绕不开HTML导出的内联样式问题。
4.3 图片附件导致HTML体积膨胀
富文本里的图片在导出时,会被dataFromRange:转成base64内嵌。一张1MB的图片,base64后约1.37MB,10张就是13MB。这种HTML存到服务器既浪费存储,下发也会变慢。更严重的是,图片数据每次编辑都会重复序列化,内存峰值很高。
我的做法是:导出前把NSTextAttachment从富文本中剥离,替换成自定义占位符,把图片数据单独压缩上传。
NSMutableAttributedString *mutableCopy = [input mutableCopy]; __block NSInteger imageIndex = 0; [mutableCopy enumerateAttribute:NSAttachmentAttributeName inRange:NSMakeRange(0, mutableCopy.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) { if ([value isKindOfClass:[NSTextAttachment class]]) { NSTextAttachment *attachment = (NSTextAttachment *)value; NSData *imageData = UIImageJPEGRepresentation(attachment.image, 0.8); // 这里将 imageData 标记为待上传,用占位符替换原附件 NSString *placeholder = [NSString stringWithFormat:@"{image_%ld}", (long)imageIndex++]; [mutableCopy replaceCharactersInRange:range withString:placeholder]; } }];枚举属性时不能在block里直接修改富文本,必须先用mutableCopy复制一份,否则会触发“mutated while enumerating”异常。占位符替换后得到的HTML体积只与文字量相关,图片数据走单独的上传接口,最后再通过URL回填进HTML,这样整体数据会小一个数量级。
5. 本地HTML场景下的三个高频坑
5.1 字体被内联样式覆盖
服务端或本地HTML经常在根节点写font-family: Arial,这会导致iOS端设置的中文字体失效。常见做法是注入一段覆盖样式:
NSString *overrideCSS = @"<style>body * { font-family: -apple-system, \"PingFang SC\", sans-serif !important; }</style>"; NSString *finalHTML = [overrideCSS stringByAppendingString:html];!important让这条规则优先生效。有个细节:<style>必须放在HTML最前面,如果拼接到</head>之后,部分版本的WebKit解析器会把它当成正文文本,直接显示在界面的最顶部。
5.2 转换卡主线程
HTML解析是同步且耗时的。一个50KB的HTML在旧设备上可能需要几百毫秒,直接在主线程调用会造成掉帧或App短暂无响应。正确做法是丢到后台队列:
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ NSAttributedString *result = [[NSAttributedString alloc] initWithData:data options:options documentAttributes:nil error:nil]; dispatch_async(dispatch_get_main_queue(), ^{ textView.attributedText = result; }); });这里不用担心线程安全,因为result只在其创建队列内部使用,最后通过主队列赋值给UI。如果担心转换过程中的内存峰值,可以用@autoreleasepool包住转换逻辑,在转换完成后主动将htmlData置空释放。
5.3 UITextView链接点击无响应
initWithData:转换出的富文本里带有NSLinkAttributeName,但直接设置到UITextView上经常点不动。原因是UITextView默认不是可编辑状态,但链接点击需要同时满足selectable = YES和delegate回调两个条件。
textView.editable = NO; textView.selectable = YES; textView.delegate = self;- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange { // 返回YES交给系统打开,返回NO自己处理 return YES; }iOS 10之后这个delegate方法可以选实现,但如果不实现,系统对Universal Link的默认处理可能把你带到浏览器或别的App。所以即使只是做拦截,也建议显式实现并返回NO,统一走App内部路由。
6. 进阶:统一封装转换入口与回归测试
6.1 封装一个全场景可用的转换方法
把前面提到的编码兜底、CSS注入、baseURL、异步处理整合成一个工具方法,日常调用就干净多了。核心实现如下:
+ (void)attributedStringFromLocalHTMLPath:(NSString *)path completion:(void (^)(NSAttributedString * _Nullable, NSError * _Nullable))completion { dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ NSError *readError = nil; NSString *html = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:&readError]; if (!html) { html = [NSString stringWithContentsOfFile:path encoding:CFStringConvertEncodingToNSStringEncoding(kCFStringEncodingGB_18030_2000) error:&readError]; } if (!html) { dispatch_async(dispatch_get_main_queue(), ^{ completion(nil, readError); }); return; } NSString *css = @"<style>body *{font-family:-apple-system,'PingFang SC',sans-serif !important;}</style>"; NSString *finalHTML = [css stringByAppendingString:html]; NSURL *baseURL = [NSURL fileURLWithPath:[path stringByDeletingLastPathComponent]]; NSAttributedString *attr = [[NSAttributedString alloc] initWithData:[finalHTML dataUsingEncoding:NSUTF8StringEncoding] options:@{ NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding), NSBaseURLDocumentAttribute: baseURL } documentAttributes:nil error:&readError]; dispatch_async(dispatch_get_main_queue(), ^{ completion(attr, readError); }); }); }调用方只需传HTML文件路径和回调,编码、字体、相对路径问题都在内部消化。这个方法适合放在NSAttributedString+HTML分类里,与“NSAttributedString4html”源码的思路一致:把系统API不可控的那部分边界处理收敛到一个入口。
6.2 用单元测试锁定转换回归
HTML转换这类代码最容易出现“这次改好了,下次又坏了”的情况。最有效的保险是写一个最小用例,断言关键属性和链接位置:
- (void)testHTMLBoldAndLinkConversion { NSString *html = @"<html><body><b>bold</b> <a href='https://apple.com'>link</a></body></html>"; NSData *data = [html dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *options = @{ NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding) }; NSAttributedString *attr = [[NSAttributedString alloc] initWithData:data options:options documentAttributes:nil error:nil]; XCTAssertNotNil(attr); XCTAssertTrue([attr.string containsString:@"bold"]); NSRange linkRange = NSMakeRange(0, 0); id link = [attr attribute:NSLinkAttributeName atIndex:0 effectiveRange:&linkRange]; XCTAssertNotNil(link); }断言的关键是校验NSLinkAttributeName的位置正确,而不是只检查字符串非空。因为WebKit有时会给整段HTML加一层默认字体属性,字符串不为空但链接可能丢失。针对本地HTML的用例,还需要额外断言图片对应的NSTextAttachment数量,以及字体是否被CSS注入覆盖。这类精确断言,才能在后续改版时替你拦住那些只在特定HTML结构下才出现的隐性回归。
本文还有配套的精品资源,点击获取