Android 錄屏原來可以這麼優雅

都 2020 年了,聽說 Android 還沒有自帶錄屏功能,當項目經理把你一頓吊打的時候問你爲啥 IOS 本身就自帶這個功能,
你竟然無言以對,你說再等等,最近看到 android11 預覽版已經發布,看介紹說明之前砍掉的錄屏功能將在此版本正式迴歸,
就自帶了
3OzeYt.jpg

經理說現在大多用的還是android8、9的版本,不能再等了,現在就開始盤它。
好嘛,既然要求搞,那就要弄得優雅些,想到優雅,那必須是這樣。

3XnMcQ.jpg

來康康順着這種優雅的思路最終實現的效果,上圖走起

3OzKl8.png

恩哼哼,竟然有人說靜圖不夠優雅,滿足你,上個 gif 鎮樓

3OzQOg.gif

那麼要實現這麼優雅的效果,你需要怎麼做呢?來來來,先康康實現流程圖

3OzmfP.png

思路如此清奇,攏共也就修改了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、錄屏動態權限處理

錄屏動態權限申請是啥呢?喏,就是下面這貨

3OzZFI.png

正常流程通過 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 是怎麼出來的呢?

89erAH.jpg

只能說熟悉 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/*");
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章