Android App內(nèi)截屏監(jiān)控及涂鴉功能實現(xiàn)

Android截屏功能是一個常用的功能,可以方便的用來分享或者發(fā)送給好友,本文介紹了如何實現(xiàn)app內(nèi)截屏監(jiān)控功能,當(dāng)發(fā)現(xiàn)用戶在我們的app內(nèi)進行了截屏操作時,將自動展示該截屏,并提供用戶隨意圈點涂鴉,添加馬賽克,撤銷,分享等功能。

本文GitHub源碼地址
實現(xiàn)效果如下:

監(jiān)聽截屏,展示截屏并涂鴉

實現(xiàn)該功能有以下技術(shù)需求:
1. 當(dāng)app在前臺展示的時候能夠自動監(jiān)聽用戶在app內(nèi)的截屏,當(dāng)app進入后臺,停止監(jiān)聽
2. 監(jiān)聽到截屏后展示該截屏,并提供涂鴉(包括隨意圈點和敏感信息馬賽克)和上傳分享功能
3. 涂鴉的每一步都可以撤銷

涉及如下知識點:
1. App內(nèi)截屏監(jiān)聽
2. 大圖壓縮
3. ImageView尺寸自適應(yīng)
4. 自定義View實現(xiàn)涂鴉功能
5. 涂鴉撤銷操作

對于截圖監(jiān)聽有兩種常用方案,方案一是通過FileObserver監(jiān)聽截屏文件夾,當(dāng)有新的截屏文件產(chǎn)生時,調(diào)用設(shè)定的回調(diào)函數(shù)執(zhí)行相關(guān)操作。該方案優(yōu)缺點如下:
優(yōu)點:
1. 實現(xiàn)簡單
缺點:
1. 不同手機默認的截屏路徑可能不同,需要做適配處理
2. 不同手機截屏觸發(fā)的事件名稱可能不同,需要測試適配
3. 監(jiān)聽到截屏事件后馬上獲取圖片獲取不到,需要延遲一段時間

方案二是通過ContentObserver監(jiān)聽多媒體圖片庫資源的變化。當(dāng)手機上有新的圖片文件產(chǎn)生時都會通過MediaProvider類向圖片數(shù)據(jù)庫插入一條記錄,以方便系統(tǒng)的圖片庫進行圖片查詢,可以通過ContentObserver接收圖片插入事件,并獲取插入圖片的URI。
優(yōu)點:
1. 不同手機觸發(fā)的事件是一樣的
缺點:
1. 不同手機截屏文件的前綴可能不同,需要做適配
2. 監(jiān)聽到截屏事件后馬上獲取圖片獲取不到,需要延遲一段時間

這兩種方式都需要根據(jù)手機做適配,第一種方式可以控制截屏監(jiān)控只在App前臺展示的時候進行,操作簡單,我們使用這種方式做截屏監(jiān)控。

接下來通過代碼介紹具體實現(xiàn)。

FileObserver通過startWatching/stopWatching方法進行啟動/停止文件監(jiān)控,我們在BaseActivity的onResume和onPause方法中分別調(diào)用兩個方法,其他Activity繼承BaseActivity,實現(xiàn)App進入前臺開始監(jiān)控,轉(zhuǎn)入后臺停止監(jiān)控的效果。

BaseActivity.java

public class BaseActivity extends AppCompatActivity {
    @Override
    protected void onResume() {
        super.onResume();

        //  設(shè)置回調(diào)函數(shù)
        FileObserverUtils.setSnapShotCallBack(new SnapShotTakeCallBack(this));
        FileObserverUtils.startSnapshotWatching();
    }

    @Override
    protected void onPause() {
        super.onPause();

        FileObserverUtils.stopSnapshotWatching();
    }
}

通過setSnapShotCallBack設(shè)置回調(diào)函數(shù),并進行FileObserver初始化:

public class FileObserverUtils {
    ...
    public static void setSnapShotCallBack(ISnapShotCallBack callBack) {
        snapShotCallBack = callBack;
        initFileObserver();
    }

    private static void initFileObserver() {
        SNAP_SHOT_FOLDER_PATH = Environment.getExternalStorageDirectory()
                + File.separator + Environment.DIRECTORY_PICTURES
                + File.separator + "Screenshots" + File.separator;

        fileObserver = new FileObserver(SNAP_SHOT_FOLDER_PATH, FileObserver.CREATE) {
            @Override
            public void onEvent(int event, String path) {
                if (null != path && event == FileObserver.CREATE && (!path.equals(lastShownSnapshot))){
                    lastShownSnapshot = path; // 有些手機同一張截圖會觸發(fā)多個CREATE事件,避免重復(fù)展示

                    String snapShotFilePath = SNAP_SHOT_FOLDER_PATH + path;

                    int tryTimes = 0;
                    while (true) {
                        try { // 收到CREATE事件后馬上獲取并不能獲取到,需要延遲一段時間
                            Thread.sleep(600);
                        } catch (Exception e) {
                            e.printStackTrace();
                        }

                        try {
                            BitmapFactory.decodeFile(snapShotFilePath);
                            break;
                        } catch (Exception e) {
                            e.printStackTrace();
                            tryTimes++;
                            if (tryTimes >= MAX_TRYS) { // 嘗試MAX_TRYS次失敗后,放棄
                                return;
                            }
                        }
                    }

                    snapShotCallBack.snapShotTaken(path);
                }
            }
        };
    }
    ...
}

FileObserver初始化傳入要監(jiān)控的截屏圖片文件夾路徑,當(dāng)該文件夾下面的文件發(fā)生變化,包括截圖生成新的圖片時,調(diào)用onEvent函數(shù),傳入event和文件的path。我們根據(jù)event過濾出截屏事件,在這里是FileObserver.CREATE事件。收到事件后馬上獲取截圖是獲取不到的,需要過幾百毫秒才能獲取到,這里會讓線程sleep一段時間再嘗試獲取,重試兩次如果還獲取失敗就放棄。獲取成功的話調(diào)用設(shè)置好的回調(diào)函數(shù)進行下一步操作。

我們的回調(diào)函數(shù)很簡單,就是打開一個用于展示截屏的新的Activity叫SnapShotEditActivity,并傳入截屏路徑:

public class SnapShotTakeCallBack implements ISnapShotCallBack {
   public static final String SNAP_SHOT_PATH_KEY = "snap_shot_path_key";
   private Context context;

   public SnapShotTakeCallBack(Context context) {
       this.context = context;
   }

   @Override
   public void snapShotTaken(String path) {
       Intent intent = new Intent(context, SnapShotEditActivity.class);
       intent.putExtra(SNAP_SHOT_PATH_KEY, path);
       context.startActivity(intent);
   }
}

該Activity界面如下:

截屏編輯界面

通過圈出問題隨意圈點,通過馬賽克覆蓋隱私信息,回退一步可以撤銷之前的操作。

我們使用一個自定義View實現(xiàn)涂鴉的功能:

public class PaintableImageView extends ImageView {
    private List<LineInfo> lineList; // 線條列表

    private LineInfo currentLine; // 當(dāng)前線條
    private LineInfo.LineType currentLineType = LineInfo.LineType.NormalLine; // 當(dāng)前線條類型

    private Paint normalPaint = new Paint();
    private static final float NORMAL_LINE_STROKE = 5.0f;

    private Paint mosaicPaint = new Paint();
    private static final int MOSAIC_CELL_LENGTH = 30; // 馬賽克每個大小40*40像素,共三行

    private Drawable drawable;
    private Bitmap bitmap;

    private boolean mosaics[][]; // 馬賽克繪制中用于記錄某個馬賽克格子的數(shù)值是否計算過
    private int mosaicRows; // 馬賽克行數(shù)
    private int mosaicColumns; // 馬賽克列數(shù)

    {
        lineList = new ArrayList<>();
        normalPaint.setColor(Color.RED);
        normalPaint.setStrokeWidth(NORMAL_LINE_STROKE);
    }

    public PaintableImageView(Context context) {
        super(context);
    }

    public PaintableImageView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public PaintableImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    /**
     * 設(shè)置線條類型
     * @param type
     */
    public void setLineType(LineInfo.LineType type) {
        currentLineType = type;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float xPos = event.getX();
        float yPos = event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                currentLine = new LineInfo(currentLineType);
                currentLine.addPoint(new PointInfo(xPos, yPos));
                lineList.add(currentLine);
                invalidate();
                return true; // return true消費掉ACTION_DOWN事件,否則不會觸發(fā)ACTION_UP
            case MotionEvent.ACTION_MOVE:
                currentLine.addPoint(new PointInfo(xPos, yPos));
                invalidate();
                return true;
            case MotionEvent.ACTION_UP:
                currentLine.addPoint(new PointInfo(xPos, yPos));
                invalidate();
                break;
        }

        return super.onTouchEvent(event);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        for (int i = 0; i < mosaicRows; i++) {
            for (int j = 0; j < mosaicColumns; j++) {
                mosaics[i][j] = false;
            }
        }

        for (LineInfo lineinfo : lineList) {
            if (lineinfo.getLineType() == LineInfo.LineType.NormalLine) {
                drawNormalLine(canvas, lineinfo);
            } else if (lineinfo.getLineType() == LineInfo.LineType.MosaicLine) {
                drawMosaicLine(canvas, lineinfo);
            }
        }
    }

    /**
     * 繪制馬賽克線條
     * @param canvas
     * @param lineinfo
     */
    private void drawMosaicLine(Canvas canvas, LineInfo lineinfo) {
        if (null == bitmap) {
            init();
        }

        if (null == bitmap) {
            return;
        }

        for (PointInfo pointInfo : lineinfo.getPointList()) {
            // 對每一個點,填充所在的小格子以及上下兩個格子(如果有上下格子)
            int currentRow = (int) ((pointInfo.y -1) / MOSAIC_CELL_LENGTH);
            int currentCol = (int) ((pointInfo.x -1) / MOSAIC_CELL_LENGTH);

            fillMosaicCell(canvas, currentRow, currentCol);
            fillMosaicCell(canvas, currentRow - 1, currentCol);
            fillMosaicCell(canvas, currentRow + 1, currentCol);
        }
    }

    /**
     * 填充一個馬賽克格子
     * @param cavas
     * @param row 馬賽克格子行
     * @param col 馬賽克格子列
     */
    private void fillMosaicCell(Canvas cavas, int row, int col) {
        if (row >= 0 && row < mosaicRows && col >= 0 && col < mosaicColumns) {
            if (!mosaics[row][col]) {
                mosaicPaint.setColor(bitmap.getPixel(col * MOSAIC_CELL_LENGTH, row * MOSAIC_CELL_LENGTH));

                cavas.drawRect(col * MOSAIC_CELL_LENGTH, row * MOSAIC_CELL_LENGTH, (col + 1) * MOSAIC_CELL_LENGTH, (row + 1) * MOSAIC_CELL_LENGTH, mosaicPaint);
                mosaics[row][col] = true;
            }
        }
    }

    /**
     * 繪制普通線條
     * @param canvas
     * @param lineinfo
     */
    private void drawNormalLine(Canvas canvas, LineInfo lineinfo) {
        if (lineinfo.getPointList().size() <= 1) {
            return;
        }

        for (int i = 0; i < lineinfo.getPointList().size() - 1; i++) {
            PointInfo startPoint  = lineinfo.getPointList().get(i);
            PointInfo endPoint  = lineinfo.getPointList().get(i + 1);

            canvas.drawLine(startPoint.x, startPoint.y, endPoint.x, endPoint.y, normalPaint);
        }
    }

    /**
     * 初始化馬賽克繪制相關(guān)
     */
    private void init() {
        drawable = getDrawable();

        try {
            bitmap = ((BitmapDrawable)drawable).getBitmap();
        } catch (ClassCastException e) {
            e.printStackTrace();
            return;
        }

        mosaicColumns = (int)Math.ceil(bitmap.getWidth() / MOSAIC_CELL_LENGTH);
        mosaicRows = (int)Math.ceil(bitmap.getHeight() / MOSAIC_CELL_LENGTH);
        mosaics = new boolean[mosaicRows][mosaicColumns];
    }

    /**
     * 刪除最后添加的線
     */
    public void withDrawLastLine() {
        if (lineList.size() > 0) {
            lineList.remove(lineList.size() - 1);
            invalidate();
        }
    }

    /**
     * 判斷是否可以繼續(xù)撤銷
     * @return
     */
    public boolean canStillWithdraw() {
        return lineList.size() > 0;
    }
}

該自定義View繼承自ImageView,通過onTouchEvent獲取要繪制的線條,MotionEvent.ACTION_DOWN/ACTION_UP標(biāo)志一條線的起止,用數(shù)組保存所有的線條,每條線是數(shù)組的一個元素,記錄了改線上面的所有點和線條的類型,是普通線條還是馬賽克線條。然后通過onDraw在Canvas上進行繪制。

繪制過程根據(jù)線條類型調(diào)用不同的繪制方法,普通繪制調(diào)用drawNormalLine通過canvas.drawLine進行,馬賽克繪制調(diào)用drawMosaicLine進行。馬賽克繪制思路是首先將截圖分割成若干個大小相同的格子,判斷每個點落在哪個格子里,繪制該格子和上下兩個格子,每個格子的顏色采用格子左上角的像素顏色填充,實現(xiàn)馬賽克效果。為了避免相鄰的點所在的格子重復(fù)繪制,采用一個二維數(shù)組標(biāo)志某個格子是否被繪制過,只繪制尚未繪制過的格子。

撤銷上一步只需要將數(shù)組中最后一條記錄刪除,重繪即可。

由于SnapShotEditActivity中圖片布局的高度是未知的,需要在布局加載完成后才能獲取,這里我們通過ViewTreeObserver的addOnGlobalLayoutListener實現(xiàn):

public class SnapShotEditActivity extends AppCompatActivity {
    ...
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        ...
        imageView = (PaintableImageView) findViewById(R.id.image_view);
        ViewTreeObserver viewTreeObserver = imageView.getViewTreeObserver();
        viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                // 自適應(yīng)調(diào)整圖片空間大小,并根據(jù)其大小壓縮圖片
                autoFitImageView();

                ViewTreeObserver vto = imageView.getViewTreeObserver();
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                    vto.removeOnGlobalLayoutListener(this);
                } else {
                    vto.removeGlobalOnLayoutListener(this);
                }
            }
        });
        ...
    }
    ...
}

其中通過autoFitImageView()實現(xiàn)ImageView尺寸的自適應(yīng)調(diào)整,并根據(jù)ImageView的尺寸壓縮截圖,避免出現(xiàn)OOM。

private void autoFitImageView() {
        int imageViewHeight = imageView.getHeight(); 

        Bitmap compressedBitmap = BitmapUtils.getCompressedBitmap(SNAP_SHOT_FOLDER_PATH + snapShotPath, imageViewHeight);

        if (null != compressedBitmap) {
            LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(compressedBitmap.getWidth(), compressedBitmap.getHeight());
            layoutParams.gravity = Gravity.CENTER;
            imageView.setLayoutParams(layoutParams);
            imageView.requestLayout();
            imageView.setImageBitmap(compressedBitmap);
        }
}

在onCreate里直接調(diào)用imageView.getHeight()返回的是0,因為此時還沒完成空間的加載,放在onGlobalLayout里面可以正確的獲取寬高。

getCompressedBitmap返回一個跟ImageView寬高一樣的壓縮過的Bitmap。

public static Bitmap getCompressedBitmap(String filePath, int needHeight) {
        try {
            BitmapFactory.Options o = new BitmapFactory.Options();
            // 第一次只解碼原始長寬的值
            o.inJustDecodeBounds = true;
            try {
                BitmapFactory.decodeStream(new FileInputStream(new File(filePath)), null, o);
            } catch (FileNotFoundException e) {
                e.printStackTrace();
                return null;
            }

            BitmapFactory.Options o2 = new BitmapFactory.Options();
            // 根據(jù)原始圖片長寬和需要的長寬計算采樣比例,必須是2的倍數(shù),
            //  IMAGE_WIDTH_DEFAULT=768, IMAGE_HEIGHT_DEFAULT=1024
            int needWidth = (int) (needHeight * 1.0 / o.outHeight * o.outWidth);
            o2.inSampleSize = 2;
            // 每像素采用RGB_565的格式保存
            o2.inPreferredConfig = Bitmap.Config.RGB_565;
            // 根據(jù)壓縮參數(shù)的設(shè)置進行第二次解碼
            Bitmap b = BitmapFactory.decodeStream(new FileInputStream(new File(filePath)), null, o2);
            Bitmap scaledBitmap = Bitmap.createScaledBitmap(b, needWidth, needHeight, true);

//          b.recycle();  // b.recycle will cause prev Bitmap.createScaledBitmap null pointer exception on b occasionally
            System.gc();

            return scaledBitmap;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

這里如果直接調(diào)用Bitmap.createScaledBitmap生成指定尺寸的Bitmap有可能會因為傳入的bitmap過大導(dǎo)致OOM,所以要先壓縮一遍,裝進內(nèi)存后再調(diào)用Bitmap.createScaledBitmap生成指定大小的Bitmap。同時,之前想嘗試設(shè)定BitmapFactory.Options的outWidth/outHeight參數(shù)為指定的寬高,同時inJustDecodeBounds=false的方式來生成指定大小的bitmap,發(fā)現(xiàn)不可行。必須使用Bitmap.createScaledBitmap才能生成指定寬高的Bitmap。

這樣就實現(xiàn)了App內(nèi)截屏監(jiān)聽,展示,涂鴉,馬賽克,撤銷等操作,思路不難,不過要注意的細節(jié)不少,同時需要在不同機型上測試適配才能保證穩(wěn)定性。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,237評論 6 537
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,957評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,248評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,356評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 72,081評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,485評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,534評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,720評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,263評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,025評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,204評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,787評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,461評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,874評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,105評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,945評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,205評論 2 375

推薦閱讀更多精彩內(nèi)容