深入浅出 Android 存储(五):实战篇(下) - 公共文件访问

371 阅读4分钟

一、MediaStore 全流程实战

1. 查询媒体文件 - 精准定位目标

基础查询模板

fun queryImages(contentResolver: ContentResolver): List<Uri> {
    val projection = arrayOf(
        MediaStore.Images.Media._ID,
        MediaStore.Images.Media.DISPLAY_NAME,
        MediaStore.Images.Media.DATE_ADDED
    )
    
    val selection = "${MediaStore.Images.Media.DATE_ADDED} >= ?"
    val selectionArgs = arrayOf(
        (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30)).toString()
    )
    
    val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
    
    val cursor = contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        projection,
        selection,
        selectionArgs,
        sortOrder
    )
    
    return cursor?.use {
        val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
        val uris = mutableListOf<Uri>()
        
        while (it.moveToNext()) {
            val id = it.getLong(idColumn)
            val contentUri = ContentUris.withAppendedId(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                id
            )
            uris.add(contentUri)
        }
        uris
    } ?: emptyList()
}

高级查询技巧

deepseek_mermaid_20250630_273f5f.png

2. 创建新媒体文件 - 安全写入共享存储

图片保存实现

suspend fun saveImageToGallery(context: Context, bitmap: Bitmap, fileName: String) {
    val resolver = context.contentResolver
    
    // 构建ContentValues
    val contentValues = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MyApp")
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }
    }
    
    // 插入到MediaStore
    val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
        ?: throw IOException("Failed to create new MediaStore record")
    
    // 写入图片数据
    try {
        resolver.openOutputStream(uri)?.use { outputStream ->
            if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)) {
                throw IOException("Failed to save bitmap")
            }
        }
    } catch (e: Exception) {
        resolver.delete(uri, null, null) // 回滚
        throw e
    } finally {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            contentValues.clear()
            contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
            resolver.update(uri, contentValues, null, null)
        }
    }
}

3. 文件操作的高级技巧

批量删除(Android 11+)

// 创建待删除文件列表
List<Uri> urisToDelete = Arrays.asList(uri1, uri2, uri3);

// 创建删除请求
PendingIntent deleteIntent = MediaStore.createDeleteRequest(
        getContentResolver(),
        urisToDelete
);

// 启动用户确认流程
try {
    startIntentSenderForResult(
            deleteIntent.getIntentSender(),
            REQUEST_CODE_DELETE,
            null, 0, 0, 0
    );
} catch (IntentSender.SendIntentException e) {
    Log.e(TAG, "Failed to start delete intent", e);
}

获取文件元数据

fun getImageMetadata(contentResolver: ContentResolver, uri: Uri): ImageMetadata? {
    val projection = arrayOf(
        MediaStore.Images.Media.DISPLAY_NAME,
        MediaStore.Images.Media.WIDTH,
        MediaStore.Images.Media.HEIGHT,
        MediaStore.Images.Media.LATITUDE,
        MediaStore.Images.Media.LONGITUDE
    )
    
    contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
        if (cursor.moveToFirst()) {
            return ImageMetadata(
                name = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)),
                width = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.WIDTH)),
                height = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.HEIGHT)),
                location = if (!cursor.isNull(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.LATITUDE))) {
                    Pair(
                        cursor.getDouble(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.LATITUDE)),
                        cursor.getDouble(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.LONGITUDE))
                    )
                } else null
            )
        }
    }
    return null
}

二、存储访问框架(SAF)应用

1. 三大核心 Intent 详解

deepseek_mermaid_20250630_f27e72.png

2. 文件选择实战

选择单个文件

fun openFilePicker(activity: Activity) {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = "*/*" // 所有文件类型
        putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(
            "application/pdf",
            "application/msword",
            "image/*"
        )) // 限制可选类型
    }
    activity.startActivityForResult(intent, REQUEST_CODE_OPEN_DOCUMENT)
}

// 处理结果
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (requestCode == REQUEST_CODE_OPEN_DOCUMENT && resultCode == RESULT_OK) {
        data?.data?.let { uri ->
            // 获取持久化权限
            contentResolver.takePersistableUriPermission(
                uri,
                Intent.FLAG_GRANT_READ_URI_PERMISSION
            )
            
            // 使用文件
            openFile(uri)
        }
    }
}

选择多个文件

Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); // 关键参数

startActivityForResult(intent, REQUEST_CODE_OPEN_MULTIPLE);

// 处理结果
if (requestCode == REQUEST_CODE_OPEN_MULTIPLE && resultCode == RESULT_OK) {
    ClipData clipData = data.getClipData();
    if (clipData != null) {
        for (int i = 0; i < clipData.getItemCount(); i++) {
            Uri uri = clipData.getItemAt(i).getUri();
            // 处理每个URI
        }
    } else if (data.getData() != null) {
        // 单个文件的情况
        Uri uri = data.getData();
    }
}

3. 目录访问与管理

请求目录访问权限

fun requestDirectoryAccess(activity: Activity) {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
        // 可选:设置初始目录
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            putExtra(DocumentsContract.EXTRA_INITIAL_URI, 
                Uri.parse("content://com.android.externalstorage.documents/document/primary:Documents"))
        }
    }
    activity.startActivityForResult(intent, REQUEST_CODE_DIRECTORY)
}

// 处理结果
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (requestCode == REQUEST_CODE_DIRECTORY && resultCode == RESULT_OK) {
        data?.data?.let { treeUri ->
            // 获取持久化权限
            contentResolver.takePersistableUriPermission(
                treeUri,
                Intent.FLAG_GRANT_READ_URI_PERMISSION or 
                Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            )
            
            // 保存URI以供后续使用
            saveTreeUri(treeUri)
        }
    }
}

使用 DocumentFile 操作目录

// 创建DocumentFile实例
DocumentFile treeDir = DocumentFile.fromTreeUri(context, treeUri);

// 列出目录内容
for (DocumentFile file : treeDir.listFiles()) {
    if (file.isDirectory()) {
        Log.d(TAG, "Directory: " + file.getName());
    } else {
        Log.d(TAG, "File: " + file.getName() + " Size: " + file.length());
    }
}

// 创建新文件
DocumentFile newFile = treeDir.createFile(
    "text/plain", 
    "new_document.txt"
);

// 写入内容
try (OutputStream out = getContentResolver().openOutputStream(newFile.getUri())) {
    out.write("Hello SAF World!".getBytes());
}

// 删除文件
newFile.delete();

三、跨应用文件共享实战

1. FileProvider 配置与使用

配置步骤

<!-- AndroidManifest.xml -->
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

<!-- res/xml/file_paths.xml -->
<paths>
    <!-- 内部存储目录 -->
    <files-path name="internal_files" path="." />
    
    <!-- 外部应用专属目录 -->
    <external-files-path name="external_files" path="." />
    
    <!-- 缓存目录 -->
    <cache-path name="cache_files" path="." />
    
    <!-- 自定义目录 -->
    <external-path name="shared_downloads" path="Download/Shared" />
</paths>

生成共享URI

fun getShareableUri(context: Context, file: File): Uri {
    return FileProvider.getUriForFile(
        context,
        "${context.packageName}.fileprovider",
        file
    )
}

// 创建分享Intent
fun createShareIntent(context: Context, file: File): Intent {
    val uri = getShareableUri(context, file)
    
    return Intent(Intent.ACTION_SEND).apply {
        type = context.contentResolver.getType(uri)
        putExtra(Intent.EXTRA_STREAM, uri)
        flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
    }
}

2. 安全接收外部文件

处理接收的文件

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    // 检查ACTION_VIEW Intent
    if (intent?.action == Intent.ACTION_VIEW) {
        handleIncomingFile(intent.data)
    }
}

private fun handleIncomingFile(uri: Uri?) {
    uri ?: return
    
    // 检查权限有效期
    val modeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
    if (context.checkUriPermission(uri, Process.myPid(), Process.myUid(), 
            modeFlags) != PackageManager.PERMISSION_GRANTED) {
        // 请求临时权限
        contentResolver.takePersistableUriPermission(uri, modeFlags)
    }
    
    // 复制到安全位置
    val inputStream = contentResolver.openInputStream(uri)
    val outputDir = context.getExternalFilesDir(null)
    val outputFile = File(outputDir, "received_file_${System.currentTimeMillis()}")
    
    inputStream?.use { input ->
        FileOutputStream(outputFile).use { output ->
            input.copyTo(output)
        }
    }
    
    // 处理文件
    processReceivedFile(outputFile)
}

四、分区存储兼容性全攻略

1. 版本兼容处理策略

deepseek_mermaid_20250630_095253.png

2. 兼容性工具类实现

object StorageCompat {
    
    /**
     * 保存图片到相册(兼容所有Android版本)
     */
    fun saveImage(context: Context, bitmap: Bitmap, fileName: String) {
        when {
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
                // Android 10+ 使用MediaStore
                saveViaMediaStore(context, bitmap, fileName)
            }
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
                // Android 6-9 检查权限后保存
                if (hasStoragePermission(context)) {
                    saveToPublicDirectory(context, bitmap, fileName)
                } else {
                    requestStoragePermission(context)
                }
            }
            else -> {
                // Android 5- 直接保存
                saveToPublicDirectory(context, bitmap, fileName)
            }
        }
    }
    
    /**
     * 读取图片文件(兼容实现)
     */
    fun loadImage(context: Context, uri: Uri): Bitmap? {
        return try {
            context.contentResolver.openInputStream(uri)?.use { stream ->
                BitmapFactory.decodeStream(stream)
            }
        } catch (e: SecurityException) {
            // 处理权限丢失
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                requestStoragePermission(context)
            }
            null
        }
    }
    
    // 私有辅助方法
    private fun saveViaMediaStore(context: Context, bitmap: Bitmap, fileName: String) {
        // 实现参考前面章节
    }
    
    private fun saveToPublicDirectory(context: Context, bitmap: Bitmap, fileName: String) {
        val picturesDir = Environment.getExternalStoragePublicDirectory(
            Environment.DIRECTORY_PICTURES
        )
        val imageFile = File(picturesDir, fileName)
        FileOutputStream(imageFile).use { out ->
            bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
        }
        // 通知媒体库更新
        MediaScannerConnection.scanFile(
            context,
            arrayOf(imageFile.absolutePath),
            null,
            null
        )
    }
}

3. 权限兼容处理

fun requestNeededPermissions(activity: Activity) {
    val permissionsToRequest = mutableListOf<String>()
    
    if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
        // Android 9及以下需要读写权限
        if (!hasPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
            permissionsToRequest.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
        }
    } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
        // Android 10-12 需要读权限访问媒体
        if (!hasPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE)) {
            permissionsToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE)
        }
    } else {
        // Android 13+ 需要细化媒体权限
        if (!hasPermission(activity, Manifest.permission.READ_MEDIA_IMAGES)) {
            permissionsToRequest.add(Manifest.permission.READ_MEDIA_IMAGES)
        }
        if (!hasPermission(activity, Manifest.permission.READ_MEDIA_VIDEO)) {
            permissionsToRequest.add(Manifest.permission.READ_MEDIA_VIDEO)
        }
    }
    
    if (permissionsToRequest.isNotEmpty()) {
        ActivityCompat.requestPermissions(
            activity,
            permissionsToRequest.toTypedArray(),
            REQUEST_CODE_STORAGE_PERMISSION
        )
    }
}

五、用户交互设计最佳实践

1. 权限请求时机优化

deepseek_mermaid_20250630_207aaf.png

2. 文件操作进度反馈

fun copyFileWithProgress(
    context: Context,
    sourceUri: Uri,
    destUri: Uri,
    progressCallback: (Float) -> Unit
) {
    val input = context.contentResolver.openInputStream(sourceUri)
    val output = context.contentResolver.openOutputStream(destUri)
    
    input?.use { inStream ->
        output?.use { outStream ->
            val totalBytes = context.contentResolver.openAssetFileDescriptor(sourceUri, "r")
                ?.use { it.length } ?: 0L
            
            var copiedBytes = 0L
            val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
            var bytesRead = inStream.read(buffer)
            
            while (bytesRead >= 0) {
                outStream.write(buffer, 0, bytesRead)
                copiedBytes += bytesRead
                
                // 更新进度
                val progress = if (totalBytes > 0) {
                    copiedBytes.toFloat() / totalBytes
                } else 0f
                
                progressCallback(progress)
                
                bytesRead = inStream.read(buffer)
            }
        }
    }
}