docs: iOS ReadingEvent V2 integration example (DOC-FULL-008)

Swift pseudocode showing full V2 event pipeline:
openSession → heartbeat → position → markedAsRead → closeSession
→ export → local queue → ack → error handling → crash recovery

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-11 21:19:42 +08:00
parent b08fe28525
commit e73575205c

View File

@ -0,0 +1,256 @@
# iOS ReadingEvent V2 集成示例
## 概述
本文档提供 iOS 如何调用 Rust Document Runtime V2 事件链路的示例代码,明确 Rust → iOS → API 的职责边界和调用顺序。
## 核心原则
1. Rust 不上传 API
2. Rust 不知道 readingTargetType
3. iOS 转 UploadItem 时补 readingTargetType / platform / appVersion / timezone
4. iOS 写入本地上传队列成功后才 ack
5. ack 不等 API 上传成功
## 完整流程
```
App 启动
→ reload_stale_events_v2()
→ cleanup_stale_sessions_ffi(now, maxAge)
进入阅读页
→ ReadingMaterialContext 构建 ReadingMaterialRefV2
→ start_reading_session_v2(materialRef, timestampMs)
阅读中(每 15s
→ push_heartbeat_v2(sessionId, materialId, delta, position?, timestampMs)
位置变化
→ push_position_changed_v2(sessionId, materialId, position, timestampMs)
标记已读
→ push_marked_as_read_v2(sessionId, materialId, timestampMs)
退出阅读页
→ push_material_closed_v2(sessionId, materialId, residualDelta, timestampMs)
→ close_reading_session_v2(sessionId)
→ set_session_closed_at_ms_v2(sessionId, timestampMs)
→ export_pending_events_v2(limit, timestampMs)
→ 每条 event 转为 ReadingEventUploadItem → 写入本地队列 → ack_events_v2(eventIds)
```
## Swift 伪代码
```swift
import ZxDocumentRuntime
// ── App Delegate / SceneDelegate ──
func applicationDidFinishLaunching() {
// 恢复未 ack 的 exported 事件
let recovered = reload_stale_events_v2()
if recovered > 0 {
os_log("Recovered %d stale events", recovered)
}
// 清理超时的旧 session如超过 60 分钟无活动)
let now = Int64(Date().timeIntervalSince1970 * 1000)
cleanup_stale_sessions_ffi(now_ms: now, max_age_ms: 60 * 60 * 1000)
}
// ── ReadingRuntimeAdapter ──
class V2ReadingRuntimeAdapter: ReadingRuntimeAdapter {
func openSession(context: ReadingMaterialContext, timestamp: Date) async throws -> ReadingRuntimeSession {
// 构建 Rust 侧 materialRef — 只传 Rust 需要的信息
let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
let materialRef = ReadingMaterialRefV2(
materialId: context.materialId,
filePath: context.localFilePath,
fileName: context.fileName,
fileType: context.fileType
)
materialRef.mimeType = context.mimeType
materialRef.fileSize = context.fileSize.map { UInt64($0) }
let sessionId = try start_reading_session_v2(
material: materialRef,
timestampMs: ts
)
return ReadingRuntimeSession(id: sessionId, materialId: context.materialId)
}
func heartbeat(sessionId: String, timestamp: Date) async throws {
let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
// delta 由 iOS Timer 计算传入,不是 Rust 自己算
let delta = activeTimeTracker.calculateDelta(at: timestamp)
_ = try push_heartbeat_v2(
sessionId: sessionId,
materialId: currentMaterialId,
activeSecondsDelta: UInt32(delta),
position: currentPositionDTO,
timestampMs: ts
)
}
func recordPositionChanged(sessionId: String, position: ReadingPositionDTO, timestamp: Date) async throws {
let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
let rustPos = position.toRustReadingPosition()
_ = try push_position_changed_v2(
sessionId: sessionId,
materialId: currentMaterialId,
position: rustPos,
timestampMs: ts
)
}
func recordMarkedAsRead(sessionId: String, position: ReadingPositionDTO?, timestamp: Date) async throws {
let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
_ = try push_marked_as_read_v2(
sessionId: sessionId,
materialId: currentMaterialId,
timestampMs: ts
)
}
func closeSession(sessionId: String, position: ReadingPositionDTO?, timestamp: Date) async throws {
let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
// 1. Flush 事件
try await flushPendingEvents(timestamp: timestamp)
// 2. Close session
try close_reading_session_v2(sessionId: sessionId)
// 3. Set closed time
try set_session_closed_at_ms_v2(sessionId: sessionId, timestampMs: ts)
}
// ── Export + Queue + Ack ──
func flushPendingEvents(timestamp: Date) async throws {
let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
let batch = export_pending_events_v2(limit: 100, timestampMs: ts)
guard !batch.isEmpty else { return }
// 转为 UploadItemiOS 补充业务字段)
let items = batch.map { event in
ReadingEventUploadItem(
eventId: event.eventId,
clientSessionId: event.clientSessionId,
readingTargetType: currentContext.readingTargetType, // ← iOS 补充
materialId: event.materialId,
knowledgeBaseId: currentContext.knowledgeBaseId, // ← iOS 补充
eventType: mapEventType(event.eventType),
position: event.position,
activeSecondsDelta: Int(event.activeSecondsDelta),
clientTimestamp: Date(ms: event.timestampMs),
clientTimezoneOffsetMinutes: currentAppContext.timezoneOffsetMinutes,
sequence: Int(event.sequence),
platform: currentAppContext.platform, // ← iOS 补充
appVersion: currentAppContext.appVersion // ← iOS 补充
)
}
// 写入本地队列(持久化)
let writtenIds = try await localUploadQueue.append(items: items)
// 只 ack 写入成功的 eventIds
if !writtenIds.isEmpty {
let acked = ack_events_v2(eventIds: writtenIds)
os_log("Acked %d events", acked)
}
// 写入失败的 eventIds → mark failed下次 retry
let allExportedIds = batch.map(\.eventId)
let failedIds = allExportedIds.filter { !writtenIds.contains($0) }
if !failedIds.isEmpty {
let marked = mark_events_failed_v2(eventIds: failedIds)
os_log("Marked %d events as failed", marked)
}
}
// ── Buffer State ──
func getBufferStats() -> EventBufferStateV2 {
return get_event_buffer_state_v2()
}
}
// ── Helper ──
extension Date {
init(ms: Int64) {
self.init(timeIntervalSince1970: Double(ms) / 1000.0)
}
}
```
## 错误处理示例
```swift
func safeCloseSession(sessionId: String?) {
guard let sid = sessionId else { return }
// Best-effort: 即使失败也不阻塞页面关闭
do {
try await flushPendingEvents(timestamp: Date())
} catch {
os_log("Flush failed: %@", error.localizedDescription)
}
do {
try close_reading_session_v2(sessionId: sid)
} catch let error as SessionError {
if error == .alreadyClosed {
os_log("Session already closed, ignoring")
return
}
os_log("Close failed: %@", error.localizedDescription)
}
let ts = Int64(Date().timeIntervalSince1970 * 1000)
try? set_session_closed_at_ms_v2(sessionId: sid, timestampMs: ts)
}
```
## 异常恢复示例
```swift
func handleAppReturnFromBackground() {
let now = Int64(Date().timeIntervalSince1970 * 1000)
// 1. 恢复 stale events
let recovered = reload_stale_events_v2()
if recovered > 0 {
os_log("Recovered %d stale events — will re-export", recovered)
// 触发 export → queue → ack 流程
}
// 2. 标记超时 session 为 interrupted
let removed = cleanup_stale_sessions_ffi(now_ms: now, max_age_ms: 30 * 60 * 1000)
if removed > 0 {
os_log("Cleaned up %d stale sessions", removed)
}
// 3. 恢复本地队列上传
localUploadQueue.resumePendingUploads()
}
func handleAppCrashRecovery() {
// App 启动后:
// 1. reload_stale_events_v2 把 iOS crash 前 export 但未 ack 的事件重置为 Pending
// 2. cleanup_stale_sessions_ffi 清理 crash 前未 close 的 session
// 3. localUploadQueue 中的事件不受影响已持久化eventId 去重)
}
```
## 验收清单
- [x] 包含 Swift 伪代码
- [x] 包含 export → queue → ack 顺序
- [x] 包含错误处理示例
- [x] 包含 mark failed 示例
- [x] 包含异常恢复示例
- [x] 明确 Rust 不上传 API
- [x] 明确 readingTargetType 由 iOS 补充
- [x] 能指导 M-IOS-INFO 接入