zhixi-document-runtime/docs/ios-integration.md
2026-06-09 19:58:07 +08:00

9.0 KiB
Raw Blame History

iOS Integration Guide

概述

本文档描述如何将 zhixi-document-runtime 集成到 iOS App 中。所有命令已验证可执行。

环境要求

  • Rust 1.96+ (stable)
  • Xcode 15+
  • iOS 16+ deployment target

1. 构建 Rust 库

# 添加 iOS target首次
rustup target add aarch64-apple-ios aarch64-apple-ios-sim

# 一键构建(含 XCFramework + Swift 绑定)
./scripts/build-ios.sh

# 或手动构建
cargo build --release --target aarch64-apple-ios -p zx_document_ffi
cargo build --release --target aarch64-apple-ios-sim -p zx_document_ffi

产物:

target/aarch64-apple-ios/release/libzx_document_ffi.a
target/aarch64-apple-ios-sim/release/libzx_document_ffi.a

2. 生成 Swift Binding

使用 UDL bindgen + proc-macro 混合模式

# 通过 build-ios.sh 自动生成,或手动:
# 1. 编译 Rustproc-macro 生成 C ABI 符号)
cargo build --release -p zx_document_ffi

# 2. 从 UDL 生成 Swift 绑定(类型 + FFI 调用封装)
uniffi-bindgen generate \
    --language swift \
    --out-dir bindings/ios/generated \
    crates/zx_document_ffi/src/zx_document.udl

输出:bindings/ios/generated/zx_document.swift (~50KB)

UDL 定义类型和函数签名,#[uniffi::export] proc-macro 生成 C ABI 分发符号bindgen 生成 Swift 调用封装。两者协作,非 library 模式。

3. 创建 XCFramework

xcodebuild -create-xcframework \
    -library bindings/ios/device/libzx_document_ffi.a \
    -library bindings/ios/simulator/libzx_document_ffi.a \
    -output bindings/ios/ZxDocumentRuntime.xcframework

产物:bindings/ios/ZxDocumentRuntime.xcframework

4. 引入 Xcode 项目

  1. bindings/ios/ZxDocumentRuntime.xcframework 拖入 Xcode 项目
  2. bindings/ios/generated/zx_document.swift 添加到项目
  3. Target → General → Frameworks, Libraries, and Embedded Content确保 ZxDocumentRuntime.xcframeworkDo Not Embed(静态库)
  4. Build Settings → Library Search Paths添加 framework 路径

5. Swift 调用 API

类型

import ZxDocumentRuntime

// 枚举类型
let type = MaterialType.markdown
let mode = PreviewMode.nativeReader

// 位置
let pos = ReadingPosition.markdown(blockId: "h1", scrollProgress: 0.5)

// 事件
let event = ReadingEvent.materialOpened(materialId: "abc", timestampMs: 1000)

// 锚点
let anchor = NoteAnchor.markdownBlock(materialId: "abc", blockId: "h1")

// 字典类型
let meta = ImageMeta(width: 800, height: 600, format: "png", fileSize: 12345)
let info = DocumentInfo(materialId: "abc", title: "doc.md", 
    materialType: .markdown, previewMode: .nativeReader,
    fileSize: 1024, pageCount: nil, wordCount: 100, createdAt: nil)

错误处理

所有函数抛出 DocumentError

do {
    let path = "/path/to/file.md"
    let type = try detectMaterialType(filePath: path)
    print("Detected: \(type)")
} catch let error as DocumentError {
    switch error {
    case .fileNotFound:    print("File not found")
    case .unsupportedFormat: print("Unsupported format")
    case .parseError:      print("Parse error")
    case .invalidEncoding: print("Invalid encoding")
    case .ioError:         print("IO error")
    }
}

V2 阅读事件集成(推荐)

V2 API 提供 session 管理、事件缓冲、ack 确认、crash recovery 等完整能力。

创建阅读目标和会话

// 1. 创建 ReadingMaterialRefRust 不存储 readingTargetTypeiOS 上传时补充)
let material = ReadingMaterialRef(materialId: "mat_001")

// 2. 启动阅读会话(返回 clientSessionId
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
let sessionId = try startReadingSessionV2(material: material, timestampMs: timestamp)

推送 5 种事件

// MaterialOpened — 打开资料
let opened = try pushMaterialOpenedV2(
    sessionId: sessionId,
    materialId: "mat_001",
    timestampMs: now()
)

// PositionChanged — 位置变化
let pos = ReadingPosition.markdown(blockId: "intro", scrollProgress: 0.25)
let changed = try pushPositionChangedV2(
    sessionId: sessionId,
    materialId: "mat_001",
    position: pos,
    timestampMs: now()
)

// Heartbeat — 心跳(含 activeSecondsDelta
let hb = try pushHeartbeatV2(
    sessionId: sessionId,
    materialId: "mat_001",
    activeSecondsDelta: 15,
    position: nil,
    timestampMs: now()
)

// MarkedAsRead — 标记已读
let mar = try pushMarkedAsReadV2(
    sessionId: sessionId,
    materialId: "mat_001",
    timestampMs: now()
)

// MaterialClosed — 关闭资料
let closed = try pushMaterialClosedV2(
    sessionId: sessionId,
    materialId: "mat_001",
    activeSecondsDelta: 0,
    timestampMs: now()
)

Export + Ack 流程

// 1. 导出待上传事件(标记为 Exported
let events = exportPendingEventsV2(limit: 100, timestampMs: now())

// 2. 构造上传请求iOS 补充 readingTargetType/platform/appVersion/timezone
let uploadItems = events.map { event in
    ReadingEventUploadItem(
        eventId: event.eventId,
        clientSessionId: event.clientSessionId,
        materialId: event.materialId,
        eventType: event.eventType,
        position: event.position,
        activeSecondsDelta: event.activeSecondsDelta,
        clientTimestampMs: event.timestampMs,
        sequence: event.sequence,
        // iOS 补充字段
        readingTargetType: getTargetType(for: event.materialId),
        platform: "ios",
        appVersion: Bundle.main.appVersion,
        clientTimezoneOffsetMinutes: Int(TimeZone.current.secondsFromGMT() / 60)
    )
}

// 3. 上传到 API
api.uploadEvents(uploadItems) { result in
    switch result {
    case .success:
        // 成功后 ACK从 buffer 移除)
        let ids = events.map(\.eventId)
        ackEventsV2(eventIds: ids)
    case .failure:
        // 失败标记为 Failed下次 export 重试)
        let ids = events.map(\.eventId)
        markEventsFailedV2(eventIds: ids)
    }
}

App 启动恢复

// App 启动时调用,恢复上次未 ack 的事件
func applicationDidFinishLaunching() {
    reloadStaleEventsV2() // 内部 Pending/Exported→Pending可重新 export
}

搜索和笔记锚点

// 1. 解析 Markdown
let blocks = try parseMarkdown(content: mdContent)

// 2. 搜索
let results = searchMarkdownBlocks(blocks: blocks, query: "keyword")

// 3. 从搜索结果创建笔记锚点
if let firstResult = results.first {
    let anchor = createNoteAnchorFromSearch(
        materialId: "mat_001",
        result: firstResult
    )
    // anchor 包含 materialId/blockId/pageNumber/chapterId/snippet
}

// 4. 从阅读位置创建锚点
let pos = ReadingPosition.pdf(pageNumber: 3, pageProgress: 0.5, overallProgress: 0.1)
let anchor = createNoteAnchor(materialId: "mat_001", position: pos)
// → NoteAnchor.pdfPage(materialId: "mat_001", pageNumber: 3, positionSnapshot: pos)

// 5. 从锚点恢复阅读位置
if let restored = restorePositionFromAnchor(anchor: anchor) {
    // navigate to restored position
}

// PDF 搜索
let pages = searchPdfPages(
    pageNumbers: [1, 2, 3],
    pageTexts: ["page 1 text", "page 2 text", "page 3 text"],
    query: "keyword"
) // → [SearchResult] with page_number

// EPUB 搜索
let chapters = searchEpubChaptersFfi(
    chapterIds: ["intro", "ch1"],
    chapterTexts: ["Welcome", "Content with keyword"],
    query: "keyword"
) // → [SearchResult] with chapter_id

PDF/EPUB 元数据

// PDF
let pdfMeta = try readPdfMetadataFfi(filePath: "/path/to/doc.pdf")
print("Pages: \(pdfMeta.pageCount), Title: \(pdfMeta.title ?? "N/A")")

// EPUB
let epubMeta = try readEpubMetadataFfi(filePath: "/path/to/book.epub")
print("Chapters: \(epubMeta.chapterCount)")
let chapters = try readEpubChaptersFfi(filePath: "/path/to/book.epub")
for ch in chapters {
    print("  \(ch.title)\(ch.path)")
}

// Office
let config = try getOfficePreviewConfigFfi(
    materialType: .word,
    fileSize: 1024
)
print("Strategy: \(config.strategy)") // PlatformPreview

6. 使用真实 XCFramework 的项目示例

bindings/ios/demo/ 目录,包含最小 SwiftUI App 示例:

  • MaterialReaderDemo.swift — 调用 detect_material_type 并展示结果
  • Info.plist — App 配置

7. 常见问题

问题 解决
No such module 'zx_documentFFI' 确保 XCFramework 已添加到 Link Binary with Libraries
library not found for -lzx_document_ffi 检查 Library Search Paths
Swift 类型不匹配 重新生成 zx_document.swiftUDL 更新后必须重新生成)
模拟器 crash 确保用的是 ios-arm64-simulator slice
文件路径错误 确保路径字符串是 OS 路径而非 Rust 路径
大文件解析慢 在后台线程调用 Rust 函数

8. 当前验证状态

  • cargo build --release --target aarch64-apple-ios 通过
  • cargo build --release --target aarch64-apple-ios-sim 通过
  • XCFramework 已生成
  • Swift bindings 已生成48KB, 所有类型)
  • cdylibstaticlib 两种产物类型