都 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/*");