ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android屏幕录制与截图开发指南:MediaProjection与View.draw()实战

Android屏幕录制与截图开发指南:MediaProjection与View.draw()实战

1. 项目概述:从需求到实现的完整路径

在移动应用开发中,屏幕录制和截屏功能正变得越来越普遍。无论是用于创建应用演示教程、用户反馈报告,还是实现一些创意性的交互功能,这两个能力都构成了现代应用体验的重要一环。对于Android开发者而言,实现这两个功能不仅仅是调用几个API那么简单,它涉及到权限管理、服务生命周期、UI交互、性能优化以及不同Android版本的兼容性等一系列复杂问题。我见过不少项目,初期只是简单堆砌代码,后期在适配、稳定性和用户体验上栽了大跟头。今天,我就结合自己多年的踩坑经验,为你梳理一套从设计到实现的完整方案,涵盖从Android 5.0到最新版本的核心技术要点和避坑指南,目标是让你写出的功能既健壮又高效。

2. 核心方案选型与架构设计

2.1 屏幕录制方案深度对比

在Android上实现屏幕录制,主流有三种技术路径,选择哪一种取决于你的目标API级别、功能需求以及对系统依赖的容忍度。

方案一:MediaProjection API (Android 5.0+)这是目前最主流、最推荐的方式。它通过MediaProjectionManager创建一个虚拟的“投影”,捕获整个屏幕或指定窗口的帧,然后通过MediaRecorderMediaCodec编码成视频文件。它的优势在于官方支持、功能强大、可以录制系统声音(需额外权限),并且从Android 10开始支持录制内部音频。但它的缺点是需要用户弹窗确认,无法完全静默开启,并且对性能有一定影响。

方案二:adb shell screenrecord 命令这其实是一个调试工具,通过执行adb shell screenrecord /sdcard/demo.mp4命令来录制。在应用内,可以通过Runtime.getRuntime().exec()来调用。这个方案极其简单,但缺点非常明显:需要WRITE_SECURE_SETTINGSDUMP等高级系统权限,普通应用根本无法获取,因此几乎只适用于系统应用或拥有root权限的设备,对绝大多数第三方应用来说不可行。

方案三:通过辅助功能服务(AccessibilityService)模拟点击“截屏”这是一种取巧的方法,通过辅助功能服务监听全局手势或按键事件,当触发录制条件时,模拟按下系统截屏组合键(如电源+音量下),然后利用系统自带的录屏功能。这种方法完全依赖设备厂商的系统实现,不同品牌手机弹出的录屏UI和操作逻辑千差万别,稳定性极差,且无法自定义输出格式、分辨率或进行应用内精细控制,不推荐用于正式产品。

实操心得:无脑选择MediaProjection方案。虽然它需要用户授权,但这恰恰符合Android系统的安全规范。我们应该把弹窗视为一个与用户建立信任的契机,通过清晰的说明引导用户授权,而不是想方设法绕过它。

2.2 截屏方案深度对比

截屏的实现同样有多条路,复杂程度和适用场景各不相同。

方案一:MediaProjection 创建虚拟显示并捕获单帧这是最强大、最灵活的方案,与录屏同源。通过MediaProjection创建ImageReader,可以获取到原始的Image对象,进而转换成Bitmap。你可以精确控制截取哪个屏幕、什么分辨率,甚至可以实现超高速连续截图。但它的代价是重,需要一套类似录屏的权限和生命周期管理。

方案二:View.draw(Canvas) 方法对于截取当前应用自身窗口的内容,这是最简单高效的方法。原理是让View将其UI层次结构绘制到一个你提供的Canvas上,而这个Canvas关联着一个Bitmap。这种方法性能极佳,无需任何权限,但只能截取自己应用的视图,无法截取系统状态栏、导航栏或其他应用。

fun captureView(view: View): Bitmap? { if (view.width <= 0 || view.height <= 0) return null val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) view.draw(canvas) return bitmap }

方案三:反射调用 SurfaceControl.screenshot()这是一个隐藏的底层API。在Android系统中,有一个SurfaceControl类,其静态方法screenshot()可以截取整个屏幕。通过Java反射可以调用它。这种方法速度快,能截取全屏,但在不同版本Android上方法签名可能变化,且从Android 10开始,对非系统应用的限制越来越严格,可能直接返回null或抛出安全异常,稳定性是最大问题。

方案四:辅助功能服务(AccessibilityService)执行全局手势和取巧的录屏方案类似,通过辅助功能服务执行一个“下滑通知栏并点击截屏按钮”的全局手势序列。这同样严重依赖厂商定制,失败率高,体验怪异,仅作为最后的研究手段。

注意事项:如果你的应用只需要截取自身界面,View.draw()是最佳选择。如果需要截取包括状态栏在内的全屏,且应用可能安装在Android 10及以上设备,那么MediaProjection是唯一可靠的选择。务必放弃对反射等黑魔法的幻想,它们带来的维护成本远高于其暂时带来的便利。

2.3 整体架构设计思路

一个健壮的屏幕录制与截屏模块,不应该把代码全部堆在Activity里。我推荐采用基于Service的架构,特别是Foreground Service(前台服务)。

为什么必须用前台服务?屏幕录制是一个长时间运行的后台任务。如果放在Activity中,一旦用户切到后台或屏幕关闭,Activity可能被销毁,录制线程也会随之终止。而Service具有独立的后台生命周期。从Android 8.0开始,后台服务限制非常严格,长时间运行的任务必须启动为前台服务,并显示一个持续的通知,否则系统很快会停止它。对于录屏这种用户明确感知的任务,显示通知是合乎情理的。

核心组件职责划分:

  1. 权限请求与初始化 (Activity):负责向用户申请Manifest.permission.RECORD_AUDIO(如果需要录音)和通过MediaProjectionManager.createScreenCaptureIntent()启动授权弹窗。处理授权结果。
  2. 录制/截图核心引擎 (Foreground Service):在Service中持有MediaProjection对象,管理MediaRecorderImageReader的生命周期,执行实际的编码、写入文件等耗时操作。服务通过Notification保持前台状态。
  3. 控制与状态通信:通过BroadcastReceiverLiveDataEventBus等机制,在ServiceUIActivityFragment)之间传递控制命令(开始/停止)和状态更新(录制时长、文件保存路径等)。
  4. 数据存储与回调Service将处理好的视频文件或图片保存到应用专属目录或公共媒体目录,并通过回调通知UI层结果。

这样的架构解耦清晰,生命周期管理稳妥,能有效应对应用切换、配置变更等复杂场景。

3. 屏幕录制的分步实现与核心代码解析

3.1 权限声明与动态申请

首先在AndroidManifest.xml中声明必要的权限。注意,RECORD_AUDIO权限在Android 13及以上版本需要动态申请。

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <!-- 从Android 10开始,录制内部音频需要此权限 --> <uses-permission android:name="android.permission.CAPTURE_AUDIO_OUTPUT" /> <!-- 写入外部存储,用于保存文件 --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <!-- Android 10及以上,使用媒体存储API,可能不需要WRITE_EXTERNAL_STORAGE -->

Activity中,你需要按顺序申请权限和MediaProjection授权。这是一个关键流程,顺序错了会导致体验不佳。

class RecordActivity : AppCompatActivity() { private lateinit var mediaProjectionManager: MediaProjectionManager private val requestCodeMediaProjection = 100 private val requestCodeAudioPermission = 101 fun startRecordProcess() { // 1. 首先检查并申请录音权限(如果需要音轨) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), requestCodeAudioPermission) return // 等待权限回调 } } // 2. 录音权限已有,则申请屏幕捕获授权 requestScreenCapture() } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == requestCodeAudioPermission && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { // 录音权限获取成功,继续申请屏幕捕获 requestScreenCapture() } else { // 处理权限被拒绝的情况 Toast.makeText(this, "录音权限被拒绝,无法录制声音", Toast.LENGTH_SHORT).show() // 这里可以根据业务决定是否继续申请仅录屏 requestScreenCapture() } } private fun requestScreenCapture() { mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager val captureIntent = mediaProjectionManager.createScreenCaptureIntent() startActivityForResult(captureIntent, requestCodeMediaProjection) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == requestCodeMediaProjection && resultCode == Activity.RESULT_OK && data != null) { // 3. 授权成功,获取MediaProjectionToken并启动录制服务 val projection = mediaProjectionManager.getMediaProjection(resultCode, data) startRecordService(projection) } } private fun startRecordService(mediaProjection: MediaProjection) { val intent = Intent(this, ScreenRecordService::class.java).apply { putExtra("media_projection_token", mediaProjection) // 可以传递其他参数,如分辨率、码率等 putExtra("width", 1080) putExtra("height", 1920) putExtra("bitrate", 6000000) // 6 Mbps } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(intent) } else { startService(intent) } } }

踩坑实录createScreenCaptureIntent()返回的Intent,必须用startActivityForResult启动,用startActivity是没用的。授权结果data非常重要,后续获取MediaProjection对象必须用到它,不能只凭resultCode

3.2 前台服务与MediaRecorder配置

接下来是重头戏,在Service中配置并启动录制。这里以使用MediaRecorder为例,因为它API相对简单,适合大多数场景。如果需要更底层的控制(如实时处理帧数据),则需使用MediaCodec

class ScreenRecordService : Service() { private lateinit var mediaProjection: MediaProjection private lateinit var mediaRecorder: MediaRecorder private lateinit var virtualDisplay: VirtualDisplay private var screenWidth = 1080 private var screenHeight = 1920 private var screenDensity = resources.displayMetrics.densityDpi override fun onCreate() { super.onCreate() mediaRecorder = MediaRecorder() // 必须创建前台通知,否则在Android 8.0+上服务会被快速停止 createNotificationChannel() startForeground(NOTIFICATION_ID, buildRecordingNotification()) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { intent?.let { val token = it.getIBinderExtra("media_projection_token") mediaProjection = MediaProjectionManager.getMediaProjection(this, token) screenWidth = it.getIntExtra("width", 1080) screenHeight = it.getIntExtra("height", 1920) startRecording() } return START_STICKY } private fun startRecording() { try { // 1. 配置MediaRecorder:这是一个严格的顺序流程 mediaRecorder.apply { setAudioSource(MediaRecorder.AudioSource.MIC) // 录制麦克风声音 // 如果想录制系统内部音频(Android 10+),上述方案不行,需要更复杂配置 setVideoSource(MediaRecorder.VideoSource.SURFACE) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioSamplingRate(44100) // 标准采样率 setAudioEncodingBitRate(128000) // 128 kbps setVideoEncoder(MediaRecorder.VideoEncoder.H264) setVideoEncodingBitRate(6000000) // 6 Mbps,根据分辨率调整 setVideoFrameRate(30) // 帧率 setVideoSize(screenWidth, screenHeight) // 设置输出文件路径 val outputDir = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10+ 使用MediaStore getExternalFilesDir(Environment.DIRECTORY_MOVIES)?.absolutePath } else { Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES).absolutePath } val outputFile = File(outputDir, "screen_record_${System.currentTimeMillis()}.mp4") setOutputFile(outputFile.absolutePath) prepare() // 这个调用可能抛出IOException,必须try-catch } // 2. 创建VirtualDisplay,将屏幕内容投射到MediaRecorder的Surface virtualDisplay = mediaProjection.createVirtualDisplay( "ScreenRecorder", screenWidth, screenHeight, screenDensity, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mediaRecorder.surface, // 关键:将Recorder的Surface作为显示目标 null, null ) // 3. 开始录制 mediaRecorder.start() updateNotification("正在录制...") // 更新通知内容 } catch (e: Exception) { Log.e(TAG, "启动录制失败", e) stopSelf() // 启动失败,停止服务 } } private fun stopRecording() { try { mediaRecorder.apply { stop() reset() // 重置到空闲状态,便于下次使用 } virtualDisplay.release() mediaProjection.stop() } catch (e: Exception) { Log.e(TAG, "停止录制失败", e) } finally { stopForeground(true) stopSelf() } } // ... 创建通知渠道、构建通知等方法省略 companion object { private const val NOTIFICATION_ID = 1001 private const val CHANNEL_ID = "screen_record_channel" } }

关键参数解析:

  • VideoSize: 设置的分辨率最好与VirtualDisplay创建的分辨率一致,否则会引发缩放,影响性能和质量。可以通过DisplayMetrics获取真实屏幕分辨率,但录制4K视频对设备压力很大,通常取1080P是一个平衡点。
  • BitRate(码率): 这是影响视频文件大小和清晰度的核心参数。计算公式可参考:码率 ≈ 分辨率宽 × 分辨率高 × 帧率 × 每像素比特数 × 压缩系数。对于H.264编码的1080P 30fps视频,4-8 Mbps是常见范围。码率太低会模糊,太高则文件巨大。
  • OutputFormat:MPEG_4是最通用的容器格式。OutputFormat.THREE_GPP等格式兼容性可能有问题。
  • 输出路径: Android 10(API 29)引入了分区存储(Scoped Storage),直接访问外部存储根目录需要特殊权限。推荐做法是:Android 10以下,使用Environment.getExternalStoragePublicDirectory;Android 10及以上,使用Context.getExternalFilesDir()MediaStoreAPI。上面的示例做了简单版本判断,更健壮的做法是统一使用MediaStore

实操心得MediaRecorderprepare()方法必须被调用,且必须在配置完所有参数之后、start()之前调用。它的调用可能因为参数不兼容、路径不可写等原因抛出IOException,务必进行异常捕获。一个常见的坑是,在prepare()之后又去修改参数(如setOutputFile),这是无效的。

3.3 处理音频录制(系统内部音频)

从Android 10开始,MediaProjection增加了录制系统内部音频的能力,但这不再是简单的setAudioSource(MediaRecorder.AudioSource.MIC)。你需要使用AudioPlaybackCaptureConfigurationAPI。这个过程相当复杂,核心步骤是:

  1. 在创建MediaRecorder前,构建一个AudioPlaybackCaptureConfiguration,通过AudioManager获取允许捕获的音频源列表。
  2. 将此配置设置给MediaRecorder
  3. 动态申请CAPTURE_AUDIO_OUTPUT权限。

由于该API的复杂性和版本碎片化,很多应用选择只录制麦克风声音,或者通过无障碍服务模拟“录制系统音频”的选项(这又回到了兼容性噩梦)。如果你的应用必须录制纯净系统音,需要投入大量精力进行兼容性测试。

4. 屏幕截图的精准实现方案

4.1 基于MediaProjection的截图实现

如果你已经通过MediaProjection授权,那么用它来截图是最强大的。与录制视频不同,截图不需要MediaRecorder,而是使用ImageReader来接收单帧图像。

class ScreenshotCaptureService : Service() { private lateinit var mediaProjection: MediaProjection private lateinit var imageReader: ImageReader private var screenDensity = resources.displayMetrics.densityDpi fun captureScreenshot(projection: MediaProjection, width: Int, height: Int): Bitmap? { this.mediaProjection = projection // 1. 创建ImageReader,用于接收屏幕帧 // 参数:宽度,高度,像素格式(如RGBA_8888),最大图像数量(队列深度) imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2) // 2. 创建VirtualDisplay,将屏幕内容投射到ImageReader的Surface val virtualDisplay = mediaProjection.createVirtualDisplay( "ScreenshotCapture", width, height, screenDensity, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, imageReader.surface, // 关键:Surface来自ImageReader null, null ) // 3. 从ImageReader中获取最新的一帧图像 var bitmap: Bitmap? = null try { val image = imageReader.acquireLatestImage() ?: return null val planes = image.planes val buffer = planes[0].buffer val pixelStride = planes[0].pixelStride val rowStride = planes[0].rowStride val rowPadding = rowStride - pixelStride * width // 4. 将Image数据转换为Bitmap bitmap = Bitmap.createBitmap(width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888) bitmap.copyPixelsFromBuffer(buffer) image.close() // 5. 裁剪掉可能因内存对齐产生的多余padding部分 if (rowPadding > 0) { bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height) } } catch (e: Exception) { Log.e(TAG, "截图失败", e) } finally { virtualDisplay.release() imageReader.close() } return bitmap } }

关键点解析:

  • ImageReader.newInstance: 第三个参数maxImages设为2就够了,一个用于显示,一个用于排队,避免阻塞。
  • rowStriderowPadding: 这是图像处理中常见的“内存对齐”问题。rowStride是每一行在内存中实际占用的字节数,可能比width * pixelStride大。直接创建Bitmap会导致图像错位,必须根据rowStride计算实际宽度或进行裁剪。
  • 性能与内存acquireLatestImage()会获取队列中最新的图像并丢弃旧的,适合单次截图。如果需要连续截图(如做屏幕监控),应使用acquireNextImage()并妥善管理Image对象的生命周期,及时close(),否则会导致内存泄漏和ImageReader队列阻塞。

4.2 基于View.draw()的应用内截图

对于应用内截图,方法就简单直接得多,性能也最好。

/** * 截取指定View的Bitmap,支持超出屏幕的部分(如ScrollView) * @param view 需要截图的View * @return 截图Bitmap,可能为null */ fun captureView(view: View): Bitmap? { // 确保View已经完成布局测量 if (view.width <= 0 || view.height <= 0) { view.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)) view.layout(0, 0, view.measuredWidth, view.measuredHeight) } val width = view.width val height = view.height if (width <= 0 || height <= 0) return null val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) // 设置背景色,避免透明背景变成黑色 canvas.drawColor(Color.WHITE) view.draw(canvas) return bitmap } /** * 截取整个ScrollView或ListView等可滚动视图的内容 */ fun captureScrollView(scrollView: ScrollView): Bitmap? { // 1. 临时将整个内容的高度设置为可见高度,以便测量总高度 val originalHeight = scrollView.getChildAt(0).height scrollView.getChildAt(0).layoutParams.height = LinearLayout.LayoutParams.WRAP_CONTENT // 2. 重新测量并布局,获取总内容高度 scrollView.measure(View.MeasureSpec.makeMeasureSpec(scrollView.width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)) val totalHeight = scrollView.measuredHeight // 3. 恢复原始高度(可选,取决于你是否需要保持原状) scrollView.getChildAt(0).layoutParams.height = originalHeight scrollView.requestLayout() // 4. 创建足够大的Bitmap val bitmap = Bitmap.createBitmap(scrollView.width, totalHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) canvas.drawColor(Color.WHITE) // 5. 保存当前滚动位置 val scrollY = scrollView.scrollY // 6. 将ScrollView滚动到顶部,开始绘制 scrollView.scrollTo(0, 0) scrollView.draw(canvas) // 7. 绘制完成后恢复滚动位置 scrollView.scrollTo(0, scrollY) return bitmap }

注意事项View.draw()方法截图的是当前视图树在内存中的状态,而不是屏幕上实际渲染的像素。这意味着,如果View的一部分被其他视图(如对话框)遮挡,或者因为硬件加速导致某些效果未同步到软件渲染层,截出来的图可能和屏幕显示有细微差别。对于绝大多数UI截图需求,这已经足够精确。

4.3 截图后的处理与保存

获取到Bitmap只是第一步,通常还需要压缩、保存到文件或分享。

fun saveBitmapToFile(bitmap: Bitmap, context: Context): Uri? { // 压缩并保存为JPEG val bytes = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, 90, bytes) // 90%质量 val fileName = "screenshot_${System.currentTimeMillis()}.jpg" val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/YourAppName") } } return try { val resolver = context.contentResolver val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) uri?.let { resolver.openOutputStream(it)?.use { outputStream -> outputStream.write(bytes.toByteArray()) } } uri // 返回保存后的Uri,可用于分享或显示 } catch (e: IOException) { Log.e(TAG, "保存图片失败", e) null } }

使用MediaStoreAPI进行保存是Android 10+的推荐做法,它不需要WRITE_EXTERNAL_STORAGE权限(在Android 10及以上),并且文件会出现在系统的相册或文件管理器中,用户体验更好。

5. 性能优化、兼容性与常见问题排查

5.1 性能优化关键点

屏幕录制和截图都是资源密集型操作,不当实现会导致应用卡顿、发热甚至崩溃。

1. 分辨率与帧率权衡:不要盲目追求最高分辨率。录制1080P视频比4K视频对CPU和编码器的压力小得多。对于大多数移动设备屏幕分享场景,1080P甚至720P已经足够清晰。帧率也是如此,30fps是流畅的基准线,60fps会显著增加处理负担和文件大小。可以通过DisplayMetrics获取设备屏幕真实分辨率,然后动态选择一个适中的录制分辨率。

fun getOptimalRecordSize(displayMetrics: DisplayMetrics): Pair<Int, Int> { val screenWidth = displayMetrics.widthPixels val screenHeight = displayMetrics.heightPixels // 策略:如果屏幕分辨率超过1080P,则按比例缩放到1080P的短边 val maxEdge = 1080 return if (screenHeight > maxEdge || screenWidth > maxEdge) { val scale = maxEdge / screenHeight.toFloat().coerceAtMost(screenWidth.toFloat()) Pair((screenWidth * scale).toInt(), (screenHeight * scale).toInt()) } else { Pair(screenWidth, screenHeight) } }

2. 编码参数调优:

  • 关键帧间隔(GOP)MediaRecorder默认可能不暴露此设置。如果使用MediaCodec,可以设置KEY_I_FRAME_INTERVAL。较小的间隔(如1秒)有利于视频 seeking,但会增大文件;较大的间隔(如10秒)压缩率高,但拖动时可能卡顿。
  • Profile 和 Level:对于H.264,使用Baseline Profile兼容性最好,但压缩率低。Main ProfileHigh Profile压缩率高,但旧设备可能不支持。在MediaFormat中设置KEY_PROFILEKEY_LEVEL

3. 内存与资源管理:

  • 及时释放VirtualDisplay,ImageReader,Image,MediaRecorder,MediaProjection都是重量级对象,必须在onDestroy()或任务结束时调用release(),stop(),close()
  • Bitmap回收:虽然Kotlin/Java有垃圾回收,但Bitmap占用的是Native内存,回收不及时容易引发OOM。对于大的、不再使用的Bitmap,主动调用recycle(),并置空引用。
  • 使用BitmapFactory.Options.inSampleSize:如果截图只是为了生成缩略图,可以先通过inSampleSize进行下采样,大幅减少内存占用。

5.2 不同Android版本的兼容性处理

Android的权限和存储模型变化是兼容性问题的重灾区。

Android 6.0+ (API 23): 动态权限RECORD_AUDIO权限需要动态申请。务必在Activity中处理onRequestPermissionsResult回调。

Android 8.0+ (API 26): 后台执行限制与通知渠道长时间后台运行的Service必须设置为前台服务并显示通知。同时,必须创建通知渠道

Android 10+ (API 29): 分区存储与音频捕获

  • 存储WRITE_EXTERNAL_STORAGE权限作用域缩小。优先使用MediaStoreAPI或Context.getExternalFilesDir()
  • 音频:录制系统音频需使用新的AudioPlaybackCaptureConfigurationAPI并申请CAPTURE_AUDIO_OUTPUT权限。

Android 11+ (API 30): 软件包可见性如果你的应用需要与其他应用交互(例如检测前台应用以决定是否录制),可能需要在中声明<queries>元素。

Android 12+ (API 31): 前台服务启动限制从后台启动前台服务的限制更加严格。确保你的前台服务启动逻辑是从用户明确的UI操作(如点击按钮)触发的。

一个健壮的兼容性检查与处理示例:

fun checkAndRequestPermissions(activity: Activity) { val permissionsNeeded = mutableListOf<String>() // 录音权限 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (activity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { permissionsNeeded.add(Manifest.permission.RECORD_AUDIO) } } // Android 13+ 需要单独申请通知权限(这里只是示例,录屏通知通常属于豁免类别,但最好检查) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { // 根据Google政策,用于用户主动触发的操作(如录屏)的前台服务通知可能不需要此权限,但建议申请以保证兼容性 permissionsNeeded.add(Manifest.permission.POST_NOTIFICATIONS) } } if (permissionsNeeded.isNotEmpty()) { activity.requestPermissions(permissionsNeeded.toTypedArray(), PERMISSION_REQUEST_CODE) } else { // 权限已具备,继续后续流程(如申请MediaProjection) activity.requestScreenCapture() } }

5.3 常见问题排查速查表

下表列出了开发过程中最常见的问题、可能的原因及解决方案。

问题现象可能原因排查步骤与解决方案
录制开始立即崩溃1.MediaRecorder参数配置顺序错误或参数不兼容。
2. 输出文件路径不可写。
3.prepare()调用异常未捕获。
1. 检查MediaRecorder配置顺序(音视频源->格式->编码器->输出文件->prepare)。
2. 检查输出目录是否存在,应用是否有写入权限(Android 10+注意分区存储)。
3. 用try-catch包裹prepare()start(),打印详细日志。
录制的视频没有声音1. 未申请RECORD_AUDIO权限。
2.setAudioSource()未调用或参数错误。
3. 某些设备麦克风被其他应用占用。
1. 确认动态权限已授予。
2. 确认调用了setAudioSource(MediaRecorder.AudioSource.MIC)
3. 尝试在安静环境下录制,或检查系统录音权限设置。
录制的视频播放时绿屏或花屏1. 视频编码参数(分辨率、码率)与设备硬件编码器不兼容。
2.VirtualDisplay的分辨率与MediaRecorder设置的VideoSize不一致。
1. 降低分辨率或码率,使用更通用的编码参数组合(如H.264 Baseline Profile)。
2. 确保createVirtualDisplay的宽高与setVideoSize的宽高完全一致。
截图获取的Bitmap为null或全黑1.View未完成布局测量(宽高为0)。
2. 使用MediaProjection截图时,ImageReader未成功接收到帧。
3. 硬件加速导致View.draw()内容异常。
1. 在View.post{}中执行截图,或手动调用measurelayout
2. 检查MediaProjection授权是否有效,VirtualDisplay是否创建成功。
3. 尝试在截图前对View调用setLayerType(View.LAYER_TYPE_SOFTWARE, null)临时禁用硬件加速(完成后恢复)。
前台服务通知不显示或录制被系统杀死1. 未正确创建通知渠道(Android 8.0+)。
2. 通知的importance太低,被系统折叠。
3. 服务未调用startForeground()或调用太晚。
1. 确保在startForeground前,使用NotificationManager.createNotificationChannel创建渠道。
2. 将通知渠道的重要性设置为IMPORTANCE_LOW以上。
3. 在ServiceonStartCommand中尽快调用startForeground(必须在onCreateonStartCommand中调用)。
在Android 10+上无法保存文件到相册使用了旧的Environment.getExternalStoragePublicDirectory方法,该方法在Android 10+上失效。改用MediaStoreAPI。通过ContentResolver.insertMediaStore.Images.Media.EXTERNAL_CONTENT_URIMediaStore.Video.Media.EXTERNAL_CONTENT_URI插入一条记录,并获取OutputStream进行写入。
用户授权录屏后,再次进入应用仍需授权MediaProjection对象是临时的,应用进程被杀死后失效。授权结果Intent需要持久化存储。将授权结果Intent(即onActivityResult中的data)用LocalBroadcastManager发送给后台服务,或将其关键信息(如resultCodedata的Extras)序列化存储到SharedPreferences,下次启动服务时尝试用MediaProjectionManager.getMediaProjection重新获取。注意,这不是100%可靠,系统可能清理,因此UI上仍需准备重新授权的流程。

一个高级技巧:处理屏幕旋转在录制过程中,如果设备屏幕旋转,VirtualDisplay的内容可能会出现问题。一个解决方案是,在Service中监听配置变化,当屏幕旋转时,释放当前的VirtualDisplayMediaRecorder,然后以新的屏幕方向重新创建并启动它们。这涉及到保存当前的录制状态到临时文件,然后拼接,逻辑较为复杂。更简单的做法是,在AndroidManifest.xml中为录制Activity固定屏幕方向,或者在开始录制时动态锁定方向。

实现屏幕录制和截屏功能,就像在Android系统的边界上进行一场精细的舞蹈。你需要深刻理解权限、服务、显示系统和媒体框架之间的交互。从简单的View.draw()截图,到复杂的全系统MediaProjection录制,每一层都有其特定的使用场景和陷阱。我个人的经验是,在开始编码前,先用纸笔画出组件生命周期和数据流向图;对于关键步骤,如MediaRecorder的参数配置顺序,写成固定的代码模板;对于兼容性问题,建立一个从Android 5.0到最新版的真机测试矩阵。最后,永远把用户体验放在首位——清晰的授权提示、稳定的录制过程、及时的状态反馈和合理的资源占用,这些才是让用户觉得你的功能“好用”的关键,而不仅仅是“能用”。

返回列表