Bring expression to your app with Genmoji
System Frameworks 进阶 20m

用 Genmoji 和 NSAdaptiveImageGlyph 为你的 App 增加表现力

Bring expression to your app with Genmoji

2024年6月10日

在 Apple 官方观看视频

一句话判断

如果你的 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 附件代码中提供了参考实现。
WWDC 2024