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>
257 lines
8.5 KiB
Markdown
257 lines
8.5 KiB
Markdown
# 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 }
|
||
|
||
// 转为 UploadItem(iOS 补充业务字段)
|
||
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 接入
|