You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android 13(API33)屏幕录制视频保存至外部存储解决方案

Android屏幕录制API 32+保存外部存储失效的解决步骤

我开发的屏幕录制功能,需要将MP4视频保存到外部存储。该功能在API 29及以下版本正常运行,但在API 32及以上版本失效。


核心问题分析

API 30+引入的Scoped Storage限制、原代码权限检查逻辑错误、废弃文件路径API,是导致功能失效的主要原因。

具体解决步骤

1. 修复权限检查与申请逻辑

原代码onRequestPermissionsResult中直接用if(true)判断权限授予,属于无效逻辑,需替换为实际权限校验;同时要区分API版本处理权限:

  • API 29及以下:申请WRITE_EXTERNAL_STORAGE和RECORD_AUDIO
  • API 30+:若需写入公共目录,申请MANAGE_EXTERNAL_STORAGE和RECORD_AUDIO;或改用无需额外权限的应用专属外部存储

2. 适配API 29+的文件存储路径

Environment.getExternalStoragePublicDirectory在API 29已被废弃,API 30+强制限制直接访问公共目录,推荐两种适配方式:

  • MediaStore写入公共Downloads目录:无需MANAGE_EXTERNAL_STORAGE权限,通过媒体库插入文件获取可写入Uri
  • 应用专属外部存储:路径为context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),文件仅本应用可见,卸载时自动删除

3. 修正MediaRecorder配置

原代码输出格式使用THREE_GPP但文件后缀为.mp4,格式不匹配可能导致录制失败,需改为MPEG_4格式。


修改后的代码示例

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private var mScreenDensity = 0
    private var mProjectionManager: MediaProjectionManager? = null
    private var mMediaProjection: MediaProjection? = null
    private var mVirtualDisplay: VirtualDisplay? = null
    private var mMediaProjectionCallback: MediaProjectionCallback? = null
    private var mMediaRecorder: MediaRecorder? = null
    private lateinit var binding: ActivityMainBinding
    private var outputUri: Uri? = null

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val metrics = DisplayMetrics()
        windowManager.defaultDisplay.getMetrics(metrics)
        mScreenDensity = metrics.densityDpi
        mMediaRecorder = MediaRecorder()
        mProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager

        binding.toggle.setOnClickListener { v: View? ->
            val hasAudioPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
            val hasStoragePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                Environment.isExternalStorageManager()
            } else {
                ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
            }

            if (!hasAudioPermission || !hasStoragePermission) {
                requestNecessaryPermissions()
            } else {
                onToggleScreenShare(v)
            }
        }
    }

    private fun requestNecessaryPermissions() {
        val permissions = mutableListOf<String>()
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            permissions.add(Manifest.permission.RECORD_AUDIO)
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            if (!Environment.isExternalStorageManager()) {
                val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
                intent.data = Uri.parse("package:$packageName")
                startActivity(intent)
                return
            }
        } else {
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
                permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
            }
        }
        if (permissions.isNotEmpty()) {
            ActivityCompat.requestPermissions(this, permissions.toTypedArray(), REQUEST_PERMISSIONS)
        }
    }

    public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode != REQUEST_CODE) {
            Log.e(TAG, "Unknown request code: $requestCode")
            return
        }
        if (resultCode != RESULT_OK) {
            Toast.makeText(this, "屏幕录制权限被拒绝", Toast.LENGTH_SHORT).show()
            binding.toggle.isChecked = false
            return
        }
        mMediaProjectionCallback = MediaProjectionCallback()
        mMediaProjection = mProjectionManager!!.getMediaProjection(resultCode, data!!)
        mMediaProjection?.registerCallback(mMediaProjectionCallback, null)
        mVirtualDisplay = createVirtualDisplay()
        mMediaRecorder!!.start()
    }

    fun onToggleScreenShare(view: View?) {
        val toggleButton = view as ToggleButton
        if (toggleButton.isChecked) {
            if (initRecorder()) {
                shareScreen()
            } else {
                toggleButton.isChecked = false
                Toast.makeText(this, "初始化录制器失败", Toast.LENGTH_SHORT).show()
            }
        } else {
            stopRecording()
        }
    }

    private fun shareScreen() {
        if (mMediaProjection == null) {
            startActivityForResult(mProjectionManager!!.createScreenCaptureIntent(), REQUEST_CODE)
            return
        }
        mVirtualDisplay = createVirtualDisplay()
        mMediaRecorder!!.start()
    }

    private fun createVirtualDisplay(): VirtualDisplay {
        return mMediaProjection!!.createVirtualDisplay(
            "ScreenRecorder",
            DISPLAY_WIDTH, DISPLAY_HEIGHT, mScreenDensity,
            DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
            mMediaRecorder!!.surface, null, null
        )
    }

    @SuppressLint("SimpleDateFormat")
    private fun initRecorder(): Boolean {
        return try {
            mMediaRecorder!!.reset()
            mMediaRecorder!!.setAudioSource(MediaRecorder.AudioSource.MIC)
            mMediaRecorder!!.setVideoSource(MediaRecorder.VideoSource.SURFACE)
            mMediaRecorder!!.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
            mMediaRecorder!!.setVideoSize(DISPLAY_WIDTH, DISPLAY_HEIGHT)
            mMediaRecorder!!.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
            mMediaRecorder!!.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
            mMediaRecorder!!.setVideoEncodingBitRate(512 * 1000)
            mMediaRecorder!!.setVideoFrameRate(30)

            val rotation = windowManager.defaultDisplay.rotation
            val orientation = ORIENTATIONS[rotation]
            mMediaRecorder!!.setOrientationHint(orientation)

            val fileName = "Prank_Record_${SimpleDateFormat("dd-MM-yyyy-hh_mm_ss").format(Date())}.mp4"
            outputUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val contentValues = ContentValues().apply {
                    put(MediaStore.Video.Media.DISPLAY_NAME, fileName)
                    put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
                    put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
                }
                contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
            } else {
                val file = File(
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
                    fileName
                )
                Uri.fromFile(file)
            }

            outputUri?.let { uri ->
                val outputStream = contentResolver.openOutputStream(uri)
                mMediaRecorder!!.setOutputFile(outputStream?.fd)
            } ?: throw IOException("无法获取输出文件Uri")

            mMediaRecorder!!.prepare()
            true
        } catch (e: IOException) {
            e.printStackTrace()
            false
        }
    }

    private fun stopRecording() {
        try {
            mMediaRecorder!!.stop()
            mMediaRecorder!!.reset()
            outputUri?.let {
                sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, it))
                Toast.makeText(this, "录制已保存到下载目录", Toast.LENGTH_SHORT).show()
            }
        } catch (e: IllegalStateException) {
            e.printStackTrace()
        } finally {
            stopScreenSharing()
        }
    }

    private inner class MediaProjectionCallback : MediaProjection.Callback() {
        override fun onStop() {
            if (binding.toggle.isChecked) {
                binding.toggle.isChecked = false
                stopRecording()
            }
            mMediaProjection = null
            stopScreenSharing()
        }
    }

    private fun stopScreenSharing() {
        mVirtualDisplay?.release()
        mVirtualDisplay = null
        destroyMediaProjection()
    }

    public override fun onDestroy() {
        super.onDestroy()
        destroyMediaProjection()
        mMediaRecorder?.release()
        mMediaRecorder = null
    }

    private fun destroyMediaProjection() {
        mMediaProjection?.let {
            it.unregisterCallback(mMediaProjectionCallback)
            it.stop()
            mMediaProjection = null
        }
        Log.i(TAG, "MediaProjection已停止")
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            REQUEST_PERMISSIONS -> {
                val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
                if (allGranted) {
                    onToggleScreenShare(binding.toggle)
                } else {
                    binding.toggle.isChecked = false
                    Snackbar.make(
                        findViewById(android.R.id.content), "需要授予必要权限才能录制",
                        Snackbar.LENGTH_INDEFINITE
                    ).setAction("去设置") {
                        requestNecessaryPermissions()
                    }.show()
                }
            }
        }
    }

    companion object {
        private const val TAG = "ScreenRecorder"
        private const val REQUEST_CODE = 1000
        private const val DISPLAY_WIDTH = 720
        private const val DISPLAY_HEIGHT = 1280
        private val ORIENTATIONS = SparseIntArray()
        private const val REQUEST_PERMISSIONS = 10

        init {
            ORIENTATIONS.append(Surface.ROTATION_0, 90)
            ORIENTATIONS.append(Surface.ROTATION_90, 0)
            ORIENTATIONS.append(Surface.ROTATION_180, 270)
            ORIENTATIONS.append(Surface.ROTATION_270, 180)
        }
    }
}

AndroidManifest.xml

<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
<!-- API 30+若需写入公共目录则保留,否则可移除 -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>

内容的提问来源于stack exchange,提问作者WilliamVietnam

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.21 07:37:05