设置ViewModel的Flow值时出现竞态条件,PDF首次加载失败
PDF加载异常问题:首次下载后显示"Pdf is not ready123",重启后正常渲染
问题描述
实现从网络下载PDF并保存至内部存储,本地已存在则跳过下载。首次下载PDF时出现异常:
- 在
renderPDF()中已将ViewModel的isLoadingFlow设为true - 日志显示
mPdfRenderer的pageCount大于0,但UI却显示“Pdf is not ready123” - 重启应用(本地已有PDF)则能正常渲染LazyColumn
相关代码
class MainActivity : ComponentActivity() { private var file: File? = null private var mFileDescriptor: ParcelFileDescriptor? = null private var mPdfRenderer: PdfRenderer? = null var mCurrentPage: PdfRenderer.Page? = null private lateinit var myViewModel: MyViewModel var downloadID: Long = 0 var downloadManager: DownloadManager? = null private fun renderPDF() { mFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) if (mFileDescriptor != null) { mPdfRenderer = PdfRenderer(mFileDescriptor!!) Log.i("pdfrenderd ", mPdfRenderer?.pageCount.toString()) myViewModel.isLoaded() } } private fun isExternalStorageDocument(uri: Uri): Boolean { return "com.android.externalstorage.documents" == uri.authority } private fun isDownloadsDocument(uri: Uri): Boolean { return "com.android.providers.downloads.documents" == uri.authority } private fun isMediaDocument(uri: Uri): Boolean { return "com.android.providers.media.documents" == uri.authority } private fun isGooglePhotosUri(uri: Uri): Boolean { return "com.google.android.apps.photos.content" == uri.authority } private fun isGoogleDriveUri(uri: Uri): Boolean { return ( "com.google.android.apps.docs.storage" == uri.authority || "com.google.android.apps.docs.storage.legacy" == uri.authority ) } private fun File.copyInputStreamToFile(inputStream: InputStream) { this.outputStream().use { fileOut -> inputStream.copyTo(fileOut) } } suspend fun copyFileToInternalStorage(path: String) { val outputFile = File(filesDir, "mypdfs.pdf") outputFile.copyInputStreamToFile(File(path).inputStream()) file = outputFile renderPDF() } private fun getMediaDocumentPath( context: Context, uri: Uri?, selection: String?, selectionArgs: Array<String>? ): String? { var cursor: Cursor? = null val column = "_data" val projection = arrayOf(column) try { if (uri == null) return null cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null) if (cursor != null && cursor.moveToFirst()) { val index = cursor.getColumnIndexOrThrow(column) return cursor.getString(index) } } finally { cursor?.close() } return null } private val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val action = intent.action if (DownloadManager.ACTION_DOWNLOAD_COMPLETE == action) { val fileUrl: Uri? = downloadManager?.getUriForDownloadedFile(downloadID) lifecycleScope.launch { withContext(Dispatchers.IO) { val path = getRealPathFromURI(fileUrl) if (path != null) { copyFileToInternalStorage(path) } } } } } } private fun getDriveFilePath(uri: Uri, context: Context): String? { val returnCursor = context.contentResolver.query( uri, null, null, null, null ) val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME) returnCursor.moveToFirst() val name = returnCursor.getString(nameIndex) returnCursor.close() val file = File(context.cacheDir, name) try { val inputStream = context.contentResolver.openInputStream(uri) val outputStream = FileOutputStream(file) var read = 0 val maxBufferSize = 1 * 1024 * 1024 val bytesAvailable = inputStream!!.available() val bufferSize = min(bytesAvailable, maxBufferSize) val buffers = ByteArray(bufferSize) while (inputStream.read(buffers).also { read = it } != -1) { outputStream.write(buffers, 0, read) } inputStream.close() outputStream.close() } catch (e: Exception) { context.unregisterReceiver(onDownloadComplete) } return file.path } private fun getFilePath(context: Context, uri: Uri?): String? { var cursor: Cursor? = null val projection = arrayOf(MediaStore.MediaColumns.DISPLAY_NAME) try { if (uri == null) return null cursor = context.contentResolver.query( uri, projection, null, null, null ) if (cursor != null && cursor!!.moveToFirst()) { val index = cursor!!.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) return cursor!!.getString(index) } } finally { cursor?.close() } return null } private fun getDataColumn( context: Context, uri: Uri?, selection: String?, selectionArgs: Array<String>? ): String? { var cursor: Cursor? = null val column = "_data" val projection = arrayOf(column) try { if (uri == null) return null cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null) if (cursor != null && cursor!!.moveToFirst()) { val index = cursor!!.getColumnIndexOrThrow(column) return cursor!!.getString(index) } } finally { cursor?.close() } return null } fun getRealPathFromURI(uri: Uri?): String? { when { DocumentsContract.isDocumentUri(this, uri) -> { when { uri?.let { isExternalStorageDocument(it) } == true -> { val docId = DocumentsContract.getDocumentId(uri) val split = docId.split(":").toTypedArray() val type = split[0] return if ("primary".equals(type, ignoreCase = true)) { if (split.size > 1) { Environment.getExternalStorageDirectory() .toString() + "/" + split[1] } else { Environment.getExternalStorageDirectory().toString() + "/" } } else { "storage" + "/" + docId.replace(":", "/") } } uri?.let { isDownloadsDocument(it) } == true -> { val fileName = getFilePath(this, uri) if (fileName != null) { return Environment.getExternalStorageDirectory() .toString() + "/Download/" + fileName } var id = DocumentsContract.getDocumentId(uri) if (id.startsWith("raw:")) { id = id.replaceFirst("raw:".toRegex(), "") val file = File(id) if (file.exists()) return id } val contentUri = ContentUris.withAppendedId( Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id) ) return getDataColumn(this, contentUri, null, null) } uri?.let { isMediaDocument(it) } == true -> { val doc docId = DocumentsContract.getDocumentId(uri) val split = docId.split(":").toTypedArray() val type = split[0] val contentUri: Uri? when (type) { "image" -> { contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI } "video" -> { contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI } "audio" -> { contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI } else -> { contentUri = MediaStore.Files.getContentUri("external") val selection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { MediaStore.MediaColumns.RELATIVE_PATH + "=?" } else { "_id=?" } val selectionArgs = arrayOf(Environment.DIRECTORY_DOCUMENTS) return getMediaDocumentPath( this, contentUri, selection, selectionArgs ) } } val selection = "_id=?" val selectionArgs = arrayOf(split[1]) return getDataColumn(this, contentUri, selection, selectionArgs) } uri?.let { isGoogleDriveUri(it) } == true -> { return getDriveFilePath(uri, this) } else -> { unregisterReceiver(onDownloadComplete) } } } "content".equals(uri?.scheme, ignoreCase = true) -> { return if (uri?.let { isGooglePhotosUri(it) } == true) { uri.lastPathSegment } else { getDataColumn( this, uri, null, null ) } } "file".equals(uri?.scheme, ignoreCase = true) -> { return uri?.path } } return null } private fun downloadFile(url: String) { try { val uri = Uri.parse(url) val request = DownloadManager.Request(uri) request.setAllowedNetworkTypes( DownloadManager.Request.NETWORK_MOBILE or DownloadManager.Request.NETWORK_WIFI ) request.setNotificationVisibility( DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED ) request.setDestinationInExternalFilesDir( this, Environment.DIRECTORY_DOCUMENTS, "myfile.pdf" ) downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager downloadID = downloadManager!!.enqueue(request) } catch (e: Exception) { unregisterReceiver(onDownloadComplete) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) myViewModel = ViewModelProvider(this).get(MyViewModel::class.java) registerReceiver( onDownloadComplete, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) ) val outputFile = File(filesDir, "mypdfs.pdf") if (outputFile.exists()) { file = outputFile renderPDF() } else { downloadFile("https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf") } setContent { PdfLazyTheme { var scale by remember { mutableStateOf(0.5f) } val loaded by myViewModel.isLoading.collectAsState() if (loaded) { if ((mPdfRenderer?.pageCount ?: 0) <= 0) { Text(text = "Pdf is not ready123") } else { LazyColumn() { items(mPdfRenderer?.pageCount!!) { message -> if (null != mCurrentPage) { mCurrentPage?.close() } mCurrentPage = mPdfRenderer?.openPage(message) val bitmap = mCurrentPage?.width?.let { mCurrentPage?.height?.let { it1 -> Bitmap.createBitmap( it, it1, Bitmap.Config.ARGB_8888 ) } } bitmap?.let { mCurrentPage?.render( it, null, Matrix().apply { postRotate(0F) }, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY ) } Image( bitmap = bitmap!!.asImageBitmap(), contentDescription = "some useful description", modifier = Modifier .fillMaxWidth() .border(width = 1.dp, color = Color.Gray) .aspectRatio(1f) .pointerInput(Unit) { detectZoom { zoom -> scale *= zoom } } .graphicsLayer { scaleX = maxOf(1f, minOf(3f, scale)); scaleY = maxOf(1f, minOf(3f, scale)) } .zIndex(scale) .shadow( elevation = 4.dp, spotColor = Color.Gray, ) } } } } } else { Text(text = "Pdf is not ready") } } } } } class MyViewModel : ViewModel() { private val _isLoading = MutableStateFlow(true) val isLoading = _isLoading.asStateFlow() fun isLoaded(){ _isLoading.value = true } }
问题原因分析
- ViewModel状态逻辑错误:
MyViewModel中_isLoading初始值为true,isLoaded()方法又将其设为true,导致UI在PDF未加载完成时就进入了loaded分支,此时mPdfRenderer还是null,触发“Pdf is not ready123”提示。重启时renderPDF()在onCreate同步执行,mPdfRenderer已初始化,所以正常显示。 mPdfRenderer未纳入可观察状态:Compose UI不会监听Activity中局部变量mPdfRenderer的变化,首次下载完成后mPdfRenderer被赋值,但UI不会触发重组,依然读取旧的null值。
修复方案
1. 修正ViewModel状态逻辑
调整_isLoading初始值和isLoaded()方法,确保只有PDF加载完成后才标记为已加载:
class MyViewModel : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() fun isLoaded(){ _isLoading.value = true } }
2. 将PdfRenderer转为可观察状态
在ViewModel中管理PdfRenderer,让Compose能感知其变化:
class MyViewModel : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() private val _pdfRenderer = MutableStateFlow<PdfRenderer?>(null) val pdfRenderer = _pdfRenderer.asStateFlow() fun setPdfRenderer(renderer: PdfRenderer) { _pdfRenderer.value = renderer _isLoading.value = true } }
修改renderPDF()中的调用:
private fun renderPDF() { mFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) if (mFileDescriptor != null) { mPdfRenderer = PdfRenderer(mFileDescriptor!!) Log.i("pdfrenderd ", mPdfRenderer?.pageCount.toString()) myViewModel.setPdfRenderer(mPdfRenderer!!) } }
3. 调整UI逻辑,观察可观察状态
在Compose中收集pdfRenderer状态,替代直接读取Activity变量:
setContent { PdfLazyTheme { var scale by remember { mutableStateOf(0.5f) } val loaded by myViewModel.isLoading.collectAsState() val pdfRenderer by myViewModel.pdfRenderer.collectAsState() if (loaded) { if (pdfRenderer?.pageCount ?: 0 <= 0) { Text(text = "Pdf is not ready123") } else { LazyColumn { items(pdfRenderer!!.pageCount) { pageIndex -> val bitmap = remember(pageIndex) { pdfRenderer!!.openPage(pageIndex).run { Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { render(it, null, Matrix().apply { postRotate(0F) }, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) close() } } } Image( bitmap = bitmap.asImageBitmap(), contentDescription = "PDF页面 $pageIndex", modifier = Modifier .fillMaxWidth() .border(width = 1.dp, color = Color.Gray) .aspectRatio(1f) .pointerInput(Unit) { detectZoom { zoom -> scale *= zoom } } .graphicsLayer { scaleX = maxOf(1f,
相关产品推荐
相关产品推荐

