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:
parent
b02efe18c7
commit
254907194c
256
docs/ios-reading-event-v2-integration.md
Normal file
256
docs/ios-reading-event-v2-integration.md
Normal 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 }
|
||||
|
||||
// 转为 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 接入
|
||||
Loading…
x
Reference in New Issue
Block a user