1. 问题现象 (Problem Space)
在 Android 15/16 环境下,从 Gmail 等外部应用通过 Intent 调起单 Activity 架构的 App 时,会出现以下典型错误:
-
报错信息:
java.lang.SecurityException: Permission Denial: reading ... requires the provider be exported, or grantUriPermission()。 -
表现:UI 陷入死循环或
NaN刷屏,ReaderViewModel异步加载文件时由于权限校验失败导致openInputStream抛出异常。
2. 根因分析 (Root Cause Analysis)
-
授权对象错位:系统将 URI 临时权限授予了
MainActivity实例。在单 Activity 架构中,URI 常作为Navigation Argument传递。当 URI 被toString()序列化再重新Uri.parse()时,新的 URI 对象丢失了原始 Intent 中的权限令牌 (Permission Token)。 -
异步校验失效:
ViewModelScope启动的协程属于异步执行。当底层ContentProvider进行权限检查时,如果原始授权的上下文(Context)已进入非活跃状态或令牌未显式传递,异步线程的读取请求会被拦截。
3. 架构级解决方案:预读与沙盒隔离 (The Intake Pattern)
不再依赖 content:// 协议进行跨页面传递,而是在 Activity 层的入口处完成“权限变现”。
A. MainActivity 入口拦截
在 onCreate 或 onNewIntent 中,利用 Activity 权限尚存的窗口期,立即将流拷贝至 App 私有目录(cacheDir)。
private suspend fun handleExternalPdf(uri: Uri): File? = withContext(Dispatchers.IO) {
val tempFile = File(cacheDir, "ext_${System.currentTimeMillis()}.pdf")
try {
contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output -> input.copyTo(output) }
}
if (tempFile.exists() && tempFile.length() > 0) tempFile else null
} catch (e: Exception) {
null
}
}
B. 导航参数解耦
NavHost 仅传递本地 File.absolutePath。由于本地沙盒文件不涉及 ContentProvider 权限校验,ReaderViewModel 和 PDFView 可以稳定读取。
4. 性能与稳定性优化 (Stability & Performance)
-
NaN 帧率异常规避:在 Compose UI 层,必须对
PDFView的加载状态进行物理隔离。-
逻辑:在
totalPages == 0时,禁止执行依赖总页数的布局计算(如滚动条 Progress)。 -
效果:消除
(current / 0)导致的NaN计算,解决setRequestedFrameRate刷屏问题。
-
逻辑:在
-
状态保持 (State Restoration):使用
rememberSaveable配合自定义Saver。-
关键点:保存
lastLoadedFilePath而非Uri。在屏幕旋转(Configuration Change)后,对比路径字符串决定是否重新load(),防止重复拷贝导致的 IO 浪费。
-
关键点:保存
5. 结论 (Conclusion)
单 Activity 架构将权限管理的责任从系统自动分配转嫁给了开发者。通过“Activity 认领 -> 私有化拷贝 -> 路径导航”这一链路,可以彻底解决外部 URI 授权不稳定的问题,并提升 SmartPDF (v0.6) 的加载成功率。