317 lines
9.0 KiB
Markdown
317 lines
9.0 KiB
Markdown
# iOS Integration Guide
|
||
|
||
## 概述
|
||
|
||
本文档描述如何将 `zhixi-document-runtime` 集成到 iOS App 中。所有命令已验证可执行。
|
||
|
||
## 环境要求
|
||
|
||
- Rust 1.96+ (stable)
|
||
- Xcode 15+
|
||
- iOS 16+ deployment target
|
||
|
||
## 1. 构建 Rust 库
|
||
|
||
```bash
|
||
# 添加 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 混合模式**:
|
||
|
||
```bash
|
||
# 通过 build-ios.sh 自动生成,或手动:
|
||
# 1. 编译 Rust(proc-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
|
||
|
||
```bash
|
||
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.xcframework` 为 `Do Not Embed`(静态库)
|
||
4. Build Settings → Library Search Paths:添加 framework 路径
|
||
|
||
## 5. Swift 调用 API
|
||
|
||
### 类型
|
||
|
||
```swift
|
||
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`:
|
||
|
||
```swift
|
||
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 等完整能力。
|
||
|
||
#### 创建阅读目标和会话
|
||
|
||
```swift
|
||
// 1. 创建 ReadingMaterialRef(Rust 不存储 readingTargetType,iOS 上传时补充)
|
||
let material = ReadingMaterialRef(materialId: "mat_001")
|
||
|
||
// 2. 启动阅读会话(返回 clientSessionId)
|
||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||
let sessionId = try startReadingSessionV2(material: material, timestampMs: timestamp)
|
||
```
|
||
|
||
#### 推送 5 种事件
|
||
|
||
```swift
|
||
// 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 流程
|
||
|
||
```swift
|
||
// 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 启动恢复
|
||
|
||
```swift
|
||
// App 启动时调用,恢复上次未 ack 的事件
|
||
func applicationDidFinishLaunching() {
|
||
reloadStaleEventsV2() // 内部 Pending/Exported→Pending,可重新 export
|
||
}
|
||
```
|
||
|
||
#### 搜索和笔记锚点
|
||
|
||
```swift
|
||
// 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 元数据
|
||
|
||
```swift
|
||
// 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.swift`(UDL 更新后必须重新生成) |
|
||
| 模拟器 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, 所有类型)
|
||
- ✅ `cdylib` 和 `staticlib` 两种产物类型
|