用 Genmoji 和 NSAdaptiveImageGlyph 为你的 App 增加表现力
Bring expression to your app with Genmoji
2024年6月10日
一句话判断
如果你的 App 支持富文本,Genmoji 支持大概率已经自动生效了——但如果你的数据存储用的是纯文本,你需要了解 NSAdaptiveImageGlyph 的存储和兼容性策略。
这场 Session 讲了什么
Apple 在 iOS 18/macOS 15 中引入了 Genmoji——用户可以生成完全自定义的表情图片。但 Genmoji 不是 Unicode 字符,它是带有元数据的位图图片。为了让 Genmoji 能像标准 emoji 一样在文本中使用(复制、粘贴、格式化、序列化),Apple 创建了 NSAdaptiveImageGlyph 这个新类。
演讲者 Aaron 从三个层面展开讲解。首先是”几乎免费的支持”:如果你的 App 已经使用 NSTextView / UITextView 的富文本模式,并且通过 RTFD 或 NSAttributedString 的标准序列化方式存储数据,Genmoji 支持是自动开启的。系统的 SecureCoding、Pasteboard 等序列化框架都已经原生支持 NSAdaptiveImageGlyph。
其次是纯文本场景的处理策略。如果你的 App 用纯文本(而非富文本)存储数据(比如博客标题、消息内容),你需要手动处理 image glyph 的拆解和重组——在文本位置存储 Unicode 附件字符加 image glyph 的唯一标识符,在 image store 中保存图片本身。Session 提供了具体的代码示例。
最后是高级场景:推送通知中的 Genmoji、HTML 渲染中的 fallback 策略、以及完全自定义文本引擎的集成方式。
值得深挖的点
富文本 vs 纯文本:两种存储策略的选择
NSAdaptiveImageGlyph 的数据模型包含三部分:多分辨率的正方形图片(标准图片格式)、全局唯一且稳定的内容标识符(contentIdentifier)、以及对齐度量信息(用于和周围文字的基线对齐)。
如果你的 App 用 RTFD 格式存储富文本,Genmoji 会作为标准的 text attachment 被序列化。读取时直接从 RTFD data 还原 NSAttributedString 就行,image glyph 自动恢复。这是最简单的路径。
但很多 App 的后端需要纯文本格式(比如要在网页上显示)。这时你需要把 image glyph 从 NSAttributedString 中”拆出来”——在纯文本中用 Unicode 附件字符(U+FFFC)占位,旁边记录 image glyph 的 contentIdentifier,图片本身存到你的 image store。读取时反向操作:从纯文本和 image store 中重组 NSAttributedString。contentIdentifier 的稳定性保证了同一个 Genmoji 只需存储一次——下次遇到相同标识符直接引用已有图片。
兼容性和 Fallback 策略
Genmoji 不是 Unicode,在不支持的平台上无法显示。Apple 在 RTFD 编码中做了向后兼容:image glyph 会被编码为标准的 text attachment,所以旧版本系统或旧版本 App 打开 RTFD 文件时,Genmoji 会自动降级为普通图片附件——不会丢失,但失去了和文字的内联对齐效果。
对于 HTML 输出场景,data(from:documentAttributes:) 方法请求 HTML 格式时会生成两种输出:支持 apple-adaptive-glyph 类型的引擎(如 WebKit)会显示内联 image glyph;不支持的引擎会显示 fallback 图片。fallback 图片的 alt-text 来自 NSAdaptiveImageGlyph 的 contentDescription——所以提供准确的 content description 不只是无障碍要求,也是跨平台兼容性的需要。
代码片段
最简单的方式:富文本自动支持
// 如果你已经使用富文本视图,只需要确认一个属性
class PetMemoryViewController: UIViewController {
@IBOutlet var textView: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
// iOS 上如果 textView 已经有 pasteConfiguration 或
// target action 支持 image 粘贴,这个属性自动为 true
textView.supportsAdaptiveImageGlyph = true
}
}
// 保存:序列化为 RTFD 数据
func saveContent() {
let rtfdData = try? textView.attributedText.data(
from: NSRange(location: 0, length: textView.attributedText.length),
documentAttributes: [
.documentType: NSAttributedString.DocumentType.rtfd
]
)
// 将 rtfdData 存入数据库
database.save(content: rtfdData)
}
// 读取:从 RTFD 数据还原
func loadContent() {
guard let data = database.loadContent() else { return }
let attributedString = try? NSAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.rtfd
],
documentAttributes: nil
)
textView.attributedText = attributedString
}
坑点:macOS 上需要声明 importsGraphics = true 才能自动支持 Genmoji。如果你的 App 在两个平台上都有文本输入,记得分别检查配置。
纯文本存储:手动拆解和重组
// 将包含 Genmoji 的富文本拆解为纯文本 + 图片引用
func decomposeAttributedText(_ attributed: NSAttributedString)
-> (plainText: String, imageStore: [String: NSAdaptiveImageGlyph]) {
var plainText = ""
var imageStore: [String: NSAdaptiveImageGlyph] = [:]
attributed.enumerateAttribute(
.adaptiveImageGlyph,
in: NSRange(location: 0, length: attributed.length)
) { glyph, range, _ in
if let glyph = glyph as? NSAdaptiveImageGlyph {
// 用 Unicode 附件字符占位 + 标识符引用
let identifier = glyph.contentIdentifier
plainText += "\u{FFFC}[\(identifier)]"
// contentIdentifier 是稳定的,重复的不需要重复存储
if imageStore[identifier] == nil {
imageStore[identifier] = glyph
}
} else {
plainText += attributed.attributedSubstring(from: range).string
}
}
return (plainText, imageStore)
}
坑点:拆解时必须保持文本位置的对应关系。如果 image glyph 的位置信息丢失,重组时 Genmoji 会出现在错误的位置。建议使用 range 信息而非简单的字符串拼接。
HTML 输出和 Web 兼容
// 生成兼容 HTML 输出
func generateHTML(from attributed: NSAttributedString) -> String {
let htmlData = try? attributed.data(
from: NSRange(location: 0, length: attributed.length),
documentAttributes: [
.documentType: NSAttributedString.DocumentType.html
]
)
// WebKit: 显示内联 adaptive glyph
// 其他浏览器: 显示 fallback 图片
// alt-text 来自 contentDescription,确保无障碍访问
return String(data: htmlData ?? Data(), encoding: .utf8) ?? ""
}
坑点:HTML 输出中的 fallback 图片依赖于 NSAdaptiveImageGlyph 的 contentDescription。如果你在创建自定义 image glyph 时没有提供有意义的 description,在不支持的浏览器上用户只能看到一个空白的 alt 文本。
推送通知中的 Genmoji
import UserNotifications
// 在 Notification Service Extension 中处理推送通知里的 Genmoji
class NotificationService: UNNotificationServiceExtension {
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
let content = request.content.mutableCopy() as! UNMutableNotificationContent
// 从推送 payload 中解析富文本
if let rtfdData = content.userInfo["attributedBody"] as? Data {
let attributedBody = try? NSAttributedString(
data: rtfdData,
options: [.documentType: NSAttributedString.DocumentType.rtfd],
documentAttributes: nil
)
if let attributed = attributedBody {
// 使用新的 attributed message context API
let context = try? UNNotificationAttributedMessageContext(
attributedContent: attributed
)
if let context = context {
do {
try content.update(attributedMessageContext: context)
} catch {
// fallback 到纯文本
}
}
}
}
contentHandler(content)
}
}
最佳实践
如果你的 App 已经支持富文本输入和 RTFD 存储,升级到支持 Genmoji 几乎不需要做什么——确认 supportsAdaptiveImageGlyph 属性(iOS)或 importsGraphics(macOS)已开启即可。建议先检查你的文本视图是否满足自动支持的条件。
如果你的后端需要纯文本格式,需要实现 image glyph 的拆解/重组逻辑。核心思路是利用 contentIdentifier 做去重和引用,不要每次都存储完整的图片数据。对于需要跨平台显示的场景(Web、Android),利用 HTML 输出的 fallback 机制,并确保 contentDescription 字段始终有值。
注意 Genmoji 不适合用于纯文本字段——邮箱地址、电话号码、搜索关键词这些场景不应该支持 image glyph。如果用户在这些字段中粘贴了 Genmoji,应该降级为 contentDescription 的纯文本表示。
还有什么值得关注
- SwiftUI 的 Text 视图原生支持 NSAdaptiveImageGlyph,不需要额外配置。
- contentEditable WebView 也自动支持 Genmoji,适合 Web-based 的编辑器。
- 完全自定义的文本引擎可以通过底层的 NSAdaptiveImageGlyph API 实现 Genmoji 支持,Apple 在 session 附件代码中提供了参考实现。