都 2020 年了,听说 Android 还没有自带录屏功能,当项目经理把你一顿吊打的时候问你为啥 IOS 本身就自带这个功能,
你竟然无言以对,你说再等等,最近看到 android11 预览版已经发布,看介绍说明之前砍掉的录屏功能将在此版本正式回归,
就自带了。
经理说现在大多用的还是android8、9的版本,不能再等了,现在就开始盘它。
好嘛,既然要求搞,那就要弄得优雅些,想到优雅,那必须是这样。
来康康顺着这种优雅的思路最终实现的效果,上图走起
恩哼哼,竟然有人说静图不够优雅,满足你,上个 gif 镇楼
那么要实现这么优雅的效果,你需要怎么做呢?来来来,先康康实现流程图
思路如此清奇,拢共也就修改了10来个文件,不多不多,再来康康修改文件清单,也许你的 SystemUI 是在 framework目录下的,没关系代码也是一样的,继续看就是了。
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/AndroidManifest.xml modified: vendor/mediatek/proprietary/packages/apps/SystemUI/res/values-zh-rCN/strings.xml modified: vendor/mediatek/proprietary/packages/apps/SystemUI/res/values/config.xml modified: vendor/mediatek/proprietary/packages/apps/SystemUI/res/values/strings.xml modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/SystemUIApplication.java modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/qs/tileimpl/QSFactoryImpl.java modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/statusbar/phone/PanelView.java modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/statusbar/phone/SharedConfig.java modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/drawable-xhdpi/ic_rs_on.png add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/drawable/message_dialog_bg.xml add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/drawable/oval_count_bg.xml add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/layout/countdown_layout.xml add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/layout/stoprecordtip_layout.xml add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/xml/file_paths.xml add: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/qs/tiles/ScreenRecordTile.java add: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/screenrecord/
完整修改代码已经同步至 github
一、QSPanel 快捷控制开关
快捷开关添加可以参照 AirplaneModeTile.java 写法,
第一步、在 qs/tiles 路径下新增 ScreenRecordTile.java 处理快捷开关点击逻辑
第二步、在 res/values/config.xml 中增加 quick_settings_tiles_default 默认初始化项 recordscreen,
这个名字可以自己定,因为在接下来会判断是否 equals
第三步、在 qs/tileimpl/QSFactoryImpl.java 中新增 else if 条件实例化 ScreenRecordTile
<!-- The default tiles to display in QuickSettings --> <string name="quick_settings_tiles_default" translatable="false"> wifi,bt,dnd,battery,cell,airplane,cast,recordscreen </string> else if (tileSpec.equals("nfc")) return new NfcTile(mHost); else if (tileSpec.equals("recordscreen")) return new ScreenRecordTile(mHost);
ScreenRecordTile 中核心的两个方法,handleClick() 和 handleUpdateState(BooleanState state, Object arg)
点击 icon 时都会触发 handleClick,我们就在这调用开始/结束录屏的方法,并保存一个录屏状态 KEY_SCREEN_RECORDING bool 值,用于每次 Panel 收起后展开都会触发 handleUpdateState 刷新 icon 状态,根据 bool 确定当前图标是否需要显示切割切线。
@Override public void handleClick() { Log.d(TAG, "handleClick, RecordScreen enable = " + mState.value); boolean newState = !mState.value; refreshState(newState); } private boolean getRecordStatus(){ return SharedConfig.getInstance(mContext).readBoolean(SharedConfig.KEY_SCREEN_RECORDING, false); } @Override protected void handleUpdateState(BooleanState state, Object arg) { Log.i(TAG, "handleUpdateState arg = " + arg); final boolean running = arg instanceof Boolean ? (Boolean)arg : getRecordStatus(); state.value = running; state.label = mContext.getString(R.string.quick_settings_screenrecord_label); state.icon = ResourceIcon.get(R.drawable.ic_rs_on); if (state.slash == null) { state.slash = new SlashState(); } state.slash.isSlashed = !running; state.state = running ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE; state.contentDescription = state.label; state.expandedAccessibilityClassName = Switch.class.getName(); }
二、开始录制
界面显示部分暂时先这样,接下来就得来真格的了。通过后台 Service 监听开始/结束广播,这样既可以给 QSPanel 调用,也可作为接口提供给客户调用。(嘿嘿,没想到吧,这思路清奇不) 那么应该何时候启动 Service 呢,我们都知道 SystemUI 启动是非常早的,早在 Launcher 出现前,基本还处在开机动画阶段就已经启动完成,首先我们能想到的就是开机广播,在项目中全局搜索找到 SystemUIApplication.java,在 onCreat() 中监听了开机广播,那我们就加在这吧。
vendor\mediatek\proprietary\packages\apps\SystemUI\src\com\android\systemui\SystemUIApplication.java
@Override public void onCreate() { super.onCreate(); setTheme(R.style.Theme_SystemUI); SystemUIFactory.createFromConfig(this); if (Process.myUserHandle().equals(UserHandle.SYSTEM)) { IntentFilter filter = new IntentFilter(Intent.ACTION_BOOT_COMPLETED); filter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY); registerReceiver(new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (mBootCompleted) return; //cczheng add for ScreenRecordService Log.d("ScreenRecordAP", "BOOT_COMPLETED received,start RecordService"); startService(new Intent(SystemUIApplication.this, com.android.systemui.screenrecord.RecordService.class)); if (DEBUG) Log.v(TAG, "BOOT_COMPLETED received"); unregisterReceiver(this); mBootCompleted = true;
同时别忘记在 AndroidManifest.xml 中进行配置 RecordService
1、权限相关
1.1、录屏动态权限处理
录屏动态权限申请是啥呢?喏,就是下面这货
正常流程通过 projectionManager.createScreenCaptureIntent(),在 onActivityResult() 接收授权结果,授权成功获取 data 中的 IBinder 对象从而得到 MediaProjection。
frameworks\base\media\java\android\media\projection\MediaProjectionManager.java
/** * Returns an Intent that <b>must</b> passed to startActivityForResult() * in order to start screen capture. The activity will prompt * the user whether to allow screen capture. The result of this * activity should be passed to getMediaProjection. */ public Intent createScreenCaptureIntent() { Intent i = new Intent(); i.setClassName("com.android.systemui", "com.android.systemui.media.MediaProjectionPermissionActivity"); return i; } public MediaProjection getMediaProjection(int resultCode, @NonNull Intent resultData) { if (resultCode != Activity.RESULT_OK || resultData == null) { return null; } IBinder projection = resultData.getIBinderExtra(EXTRA_MEDIA_PROJECTION); if (projection == null) { return null; } return new MediaProjection(mContext, IMediaProjection.Stub.asInterface(projection)); }
通过 Layout Inspetor 工具或者看上面的 createScreenCaptureIntent() 源码发现这货就在 SystemUI 中,对应 MediaProjectionPermissionActivity,只是设置了 Dialog 的主题,那我们就来康康它的源码,简化授权过程。
vendor\mediatek\proprietary\packages\apps\SystemUI\src\com\android\systemui\media\MediaProjectionPermissionActivity.java
IBinder b = ServiceManager.getService(MEDIA_PROJECTION_SERVICE); mService = IMediaProjectionManager.Stub.asInterface(b); try { if (mService.hasProjectionPermission(mUid, mPackageName)) { setResult(RESULT_OK, getMediaProjectionIntent(mUid, mPackageName, false /*permanentGrant*/)); finish(); return; } } catch (RemoteException e) { Log.e(TAG, "Error checking projection permissions", e); finish(); return; } @Override public void onClick(DialogInterface dialog, int which) { try { if (which == AlertDialog.BUTTON_POSITIVE) { setResult(RESULT_OK, getMediaProjectionIntent( mUid, mPackageName, mPermanentGrant)); } } catch (RemoteException e) { Log.e(TAG, "Error granting projection permission", e); setResult(RESULT_CANCELED); } finally { if (mDialog != null) { mDialog.dismiss(); } finish(); } } private Intent getMediaProjectionIntent(int uid, String packageName, boolean permanentGrant) throws RemoteException { IMediaProjection projection = mService.createProjection(uid, packageName, MediaProjectionManager.TYPE_SCREEN_CAPTURE, permanentGrant); Intent intent = new Intent(); intent.putExtra(MediaProjectionManager.EXTRA_MEDIA_PROJECTION, projection.asBinder()); return intent; }
可以看到 setResult() 对应的 Intent 都来自 getMediaProjectionIntent(),这下找到源头了,既然我们本身就在源码里修改那
就别绕弯子,拿来就用。所以综合上面的两块源码最终获取 MediaProjection 对象是介个亚子的
private void initMediaProjection(){ try { IBinder b = ServiceManager.getService(Context.MEDIA_PROJECTION_SERVICE); IMediaProjectionManager mService = IMediaProjectionManager.Stub.asInterface(b); String mPackageName = "com.android.systemui"; ApplicationInfo aInfo = getPackageManager().getApplicationInfo(mPackageName, 0); IMediaProjection projection = mService.createProjection(aInfo.uid, mPackageName, MediaProjectionManager.TYPE_SCREEN_CAPTURE, true); mediaProjection = new MediaProjection(RecordService.this, IMediaProjection.Stub.asInterface(projection.asBinder())); } catch (Exception e) { Log.e(TAG, "initMediaProjection happen some Exception", e); e.printStackTrace(); } //projectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); //mediaProjection = mProjectionManager.getMediaProjection(mResultCode, mResultData); }
1.2、录音动态权限处理
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
录屏的同时也需要采集 MIC 的声音,所以需要在 AndroidManifest.xml 中声明权限
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
采用之前 Android9.0/8.1/6.0 默认给系统 app 授予所有权限 方案,在 pms_sysapp_grant_permission_list.txt 添加包名 com.android.systemui 即可
2、显示相关
2.1、3秒倒计时动画(缩放+透明)
通过 WindowManager 添加类型为 TYPE_APPLICATION_OVERLAY 的窗体
private void showCountDownWindow() { if (isShowCountDownView || inflateWindow != null || running) return; WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(); layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; layoutParams.format = PixelFormat.RGBA_8888; layoutParams.gravity = Gravity.CENTER; layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; layoutParams.width = 500; layoutParams.height = 500; inflateWindow = LayoutInflater.from(RecordService.this).inflate(R.layout.countdown_layout, null); TextView animNumberTv = (TextView) inflateWindow.findViewById(R.id.tv_number_anim); mWindowManager.addView(inflateWindow, layoutParams); isShowCountDownView = true; doCountDownAnim(animNumberTv); } View inflateWindow; boolean isShowCountDownView; int sCurCount = 3; int repeatCount = 2; private void doCountDownAnim(final TextView animationViewTv){ animationViewTv.setText(String.valueOf(sCurCount)); animationViewTv.setVisibility(View.VISIBLE); AlphaAnimation alphaAnimation = new AlphaAnimation(1, 0); ScaleAnimation scaleAnimation = new ScaleAnimation( 0.1f, 1.3f, 0.1f, 1.3f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); scaleAnimation.setRepeatCount(repeatCount); alphaAnimation.setRepeatCount(repeatCount); alphaAnimation.setDuration(1000); scaleAnimation.setDuration(1000); scaleAnimation.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) { } @Override public void onAnimationEnd(Animation animation) { animationViewTv.setVisibility(View.GONE); startRecord(); sCurCount = 3; } @Override public void onAnimationRepeat(Animation animation) { animationViewTv.setText(String.valueOf(--sCurCount)); } }); AnimationSet animationSet = new AnimationSet(true); animationSet.addAnimation(alphaAnimation); animationSet.addAnimation(scaleAnimation); animationViewTv.startAnimation(animationSet); }
2.2、statusBar背景修改为红色,表示正在录屏中
在 Activity 中通过 getWindow().setStatusBarColor(Color.RED); 就能修改 statusBar 背景色为空色,但这仅仅只属于顶部窗体,当你切换其它app时,将不再显示红色。我们的需求是只要在录屏中,不管如何操作,statusBar 就得保持红色。这是个问题?
经过布局文件等一顿分析后,在 StatusBar.java 中通过 mStatusBarView 可达到我们想要的效果。
private static final String OP_STATUSBAR_COLOR = "com.android.action.SET_STATUSBAR_COLOR"; private BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (DEBUG) Log.v(TAG, "onReceive: " + intent); String action = intent.getAction(); if (Intent.ACTION_SCREEN_OFF.equals(action)) { finishBarAnimations(); resetUserExpandedStates(); }else if (OP_STATUSBAR_COLOR.equals(action)) { Log.e("StatusBar", "OP_STATUSBAR_COLOR red: "); if(intent.hasExtra("is_recording")){ boolean result = intent.getBooleanExtra("is_recording", false); if (mStatusBarView != null) mStatusBarView.setBackgroundColor(result ? android.graphics.Color.RED : android.graphics.Color.TRANSPARENT); } }
2.3、statusBar 点击弹出 IOS 风格对话框是否停止录制
IOS 风格对话框网上一大推,随便征用一个吧。还记得上面录屏权限那货吧,这里我们仿照它使用 Dialog 主题,便于在 PanelView 通过 startActivity 方式展示对话框。那么,PanelView 是怎么出来的呢?
只能说熟悉 StatusBar 的盆友们一看就知道,不知道的我给你说下,反手就是一个三连。
PhoneStatusBarView.java PanelBar.java PanelView.java
简单来说,PhoneStatusBarView 对应顶部的状态栏,PanelBar 对应状态栏下拉面板,PanelView 对应面板具体内容
触摸事件就是按照上面的顺序来传递的,以往我们屏蔽下拉最简单办法就在 PhoneStatusBarView 拦截,但现在我们得到事件处理的最底端
PanelView 中
vendor\mediatek\proprietary\packages\apps\SystemUI\src\com\android\systemui\statusbar\phone\PanelView.java
boolean justClick; @Override public boolean onTouchEvent(MotionEvent event) { boolean isRecording = SharedConfig.getInstance(mContext).readBoolean(SharedConfig.KEY_SCREEN_RECORDING, false); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: //cczheng if (!isRecording) { startExpandMotion(x, y, false /* startTracking */, mExpandedHeight); } justClick = true; log("ACTION_DOWN"); ..... break; ...... case MotionEvent.ACTION_MOVE: .... justClick = false; android.util.Log.d("cpanel","ACTION_MOVE"); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (justClick && isRecording) { log("showdialog"); mContext.startActivity(new android.content.Intent("com.android.systemui.stoprecrodtipactivity") .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)); } log("ACTION_CANCEL =="+justClick); trackMovement(event); endMotionEvent(event, x, y, false /* forceCancel */); break; } return !mGestureWaitForTouchSlop || mTracking; }
正常情况下,快速点击抬起手指,PanelView 会有个向下伸张回弹的动画,很显然在录屏模式下我们要屏蔽这个操作,
case MotionEvent.ACTION_DOWN isRecording 情况下不走 startExpandMotion() 即可
case MotionEvent.ACTION_MOVE justClick = false,有拖动则正常下拉
case MotionEvent.ACTION_UP justClick && isRecording 弹出是否停止录屏对话框
三、结束录制
1、显示相关
3.1、statusBar 恢复默认背景
private void setStatusBarColor(){ sendBroadcast(new Intent("com.android.action.SET_STATUSBAR_COLOR").putExtra("is_recording", false)); }
3.2、悬挂通知刚刚录制成功的视频,更新媒体库数据
需要注意的几个地方,悬挂通知类型需要设置为 NotificationManager.IMPORTANCE_HIGH,不然不能正常显示
7.0 以上访问文件uri时需要通过 FileProvider 来访问,并在 AndroidManif.xml 中配置,不然会报 FileUriExposedException异常
<provider android:name="android.support.v4.content.FileProvider" android:authorities="com.android.systemui.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider> private void showHeadUpNotification(){ final NotificationManager mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); String channelId = java.util.UUID.randomUUID().toString().replaceAll("-", ""); NotificationChannel notificationChannel = new NotificationChannel(channelId, "recordScreen", NotificationManager.IMPORTANCE_HIGH); notificationChannel.setSound(null, null);//mute mNotificationManager.createNotificationChannel(notificationChannel); Notification.Builder builder = new Notification.Builder(this, channelId); builder.setSmallIcon(R.drawable.ic_rs_on); builder.setSubText(getResources().getString(R.string.record_notification_subtext)); builder.setAutoCancel(true); builder.setContentText(getResources().getString(R.string.record_notification_contentext)); //for goto gallery Intent intent = new Intent(Intent.ACTION_VIEW); File recordFile = new File(recordFilePath); Uri uriForFile = FileProvider.getUriForFile(this,"com.android.systemui.fileprovider", recordFile); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setDataAndType(uriForFile, "video/*"); //scan media file to gallery MediaScannerConnection.scanFile(this, new String[]{recordFile.getAbsolutePath()}, new String[]{"video/*"}, null); PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0); builder.setContentIntent(pendingIntent); builder.setFullScreenIntent(pendingIntent, true); final int notifyId = 100; mNotificationManager.notify(notifyId, builder.build()); new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { @Override public void run() { mNotificationManager.cancel(notifyId); } },4500); }
3.3、点击通知跳转至系统媒体库自动播放
//scan media file to gallery Intent intent = new Intent(Intent.ACTION_VIEW); File recordFile = new File(recordFilePath); Uri uriForFile = FileProvider.getUriForFile(this,"com.android.systemui.fileprovider", recordFile); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setDataAndType(uriForFile, "video/*");






