Android 截圖與 WebView 長圖分享經驗總結

codeGoogle發表於2019-02-26

最近在做新業務需求的同時,我們在 Android 上遇到了一些之前沒有碰到過的問題,截圖分享、 WebView 生成長圖以及長圖在各個分享渠道分享時圖片模糊甚至分享失敗等問題,在這過程中踩了很多坑,到目前為止絕大部分的問題都還算是有了比較滿意的解決方案。以下就從三個方面來總結一下過程中遇到的挑戰和最後的解決方案。

一、概述

最近在做新業務需求的同時,我們在 Android 上遇到了一些之前沒有碰到過的問題,截圖分享、 WebView 生成長圖以及長圖在各個分享渠道分享時圖片模糊甚至分享失敗等問題,在這過程中踩了很多坑,到目前為止絕大部分的問題都還算是有了比較滿意的解決方案。以下就從三個方面來總結一下過程中遇到的挑戰和最後的解決方案。

二、截圖分享

在 Android 原生系統中是沒有提供截圖的廣播或者監聽事件的,也就是說程式碼層面無法獲知使用者的截圖操作,這樣就無法滿足使用者截圖後跳出分享提示的需求。既然無法從根本上解決截圖監聽的問題,那麼就要考慮通過其他方式間接實現,目前比較成熟穩定的方案是監聽系統媒體資料庫資源的變化,具體方案原理如下:

Android 系統有一個媒體資料庫,每拍一張照片,或使用系統截圖擷取一張圖片,都會把這張圖片的詳細資訊加入到這個媒體資料庫,併發出內容改變通知,我們可以利用內容觀察者(ContentObserver)監聽媒體資料庫的變化,當資料庫有變化時,獲取最後插入的一條圖片資料,如果該圖片符合特定的規則,則認為被截圖了。

考慮到手機儲存包括內部儲存器和外部儲存器,為了增強相容性,最好同時監聽兩種儲存空間的變化,以下是需要 ContentObserver 監聽的資源 URI :

MediaStore.Images.Media.INTERNAL_CONTENT_URI
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
複製程式碼

讀取外部儲存器資源,需要新增許可權:

android.permission.READ_EXTERNAL_STORAGE
複製程式碼

注:在 Android 6.0 及以上版本需要動態申請許可權

1. 截圖判斷規則

當 ContentObserver 監聽到媒體資料庫的資料改變, 在有資料改變時獲取最後插入資料庫的一條圖片資料, 如果符合以下規則, 則認為截圖了:

時間判斷:通常截圖生成後會立馬存入系統多媒體資料庫,也就是說監聽到資料庫變化的時間與截圖生成的時間不會相差太多,這裡推薦以10秒作為閾值,當然這個也是經驗值。
尺寸判斷:截圖顧名思義取得是當前手機螢幕尺寸大小的圖片,所以圖片寬高大於螢幕寬高的肯定都不是截圖產生的。
路徑判斷:由於各手機廠家存放截圖的檔案路徑都不太一樣,國內情況可能會更嚴重,但是通常圖片儲存路徑都會包含一些常見的關鍵詞,比如 “screenshot”、 “screencapture” 、 “screencap” 、 “截圖”、 “截圖”等,每次都檢查圖片路徑資訊是否包含這些關鍵詞。
關於第3點需要補充說明一下,由於要判斷圖片檔案路徑是否包含關鍵字,所以目前僅支援中英文環境,如果需要支援其他語言,需要手動新增一些該語言的關鍵詞,否則有可能獲取不到圖片。
以上3點基本上可以保證截圖的正常監聽,當然在實際測試過程中,還會發現有些機型存在多報的情況,所以還需要做一些去重等工作,關於去重下面還會再提及。

2. 關鍵程式碼

原理都瞭解清楚了,那麼接下來就是如何實現的問題了。這裡最關鍵是媒體內容觀察者的設定,從資料庫中取出第一條資料並解析圖片資訊,然後再檢驗圖片資訊是否符合以上3條規則。

為了說清楚如何監聽媒體資料庫改變,先要稍微講一下 ContentObserver 的原理。 ContentObserver ——內容觀察者,目的是觀察(捕捉)特定 Uri 引起的資料庫的變化,繼而做一些相應的處理,它類似於資料庫技術中的觸發器(Trigger),當 ContentObserver 所觀察的 Uri 發生變化時,便會觸發它。當然想要觀察就必須先要註冊, Android 系統提供了 ContentResolver#registerContentObserver 方法用來註冊觀察器。此部分不熟悉的同學可以溫習一下 Android 的 ContentProvider 相關知識。

接下來直接用程式碼說明整個註冊和觸發流程,程式碼如下:

private void initMediaContentObserver() {
    // 執行在 UI 執行緒的 Handler, 用於執行監聽器回撥 
    private final Handler mUiHandler = new Handler(Looper.getMainLooper());
    // 建立內容觀察者,包括內部儲存和外部儲存
    mInternalObserver = new MediaContentObserver(MediaStore.Images.Media.INTERNAL_CONTENT_URI, mUiHandler);
    mExternalObserver = new MediaContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mUiHandler);
    // 註冊內容觀察者
    mContext.getContentResolver().registerContentObserver(
            MediaStore.Images.Media.INTERNAL_CONTENT_URI, false, mInternalObserver);
    mContext.getContentResolver().registerContentObserver(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI, false, mExternalObserver);
}
 
/**
 * 自定義媒體內容觀察者類(觀察媒體資料庫的改變)
 */
private class MediaContentObserver extends ContentObserver {
    private Uri mediaContentUri;       // 需要觀察的Uri
    public MediaContentObserver(Uri contentUri, Handler handler) {
        super(handler);
        mediaContentUri = contentUri;
    }
    @Override
    public void onChange(boolean selfChange) {
        super.onChange(selfChange);
        // 處理媒體資料庫反饋的資料變化
        handleMediaContentChange(mediaContentUri);
    }
}
複製程式碼

有註冊就需要在 Activity 銷燬時取消註冊,所以還需要封裝一個解除註冊的方法供外部呼叫, Android 系統提供 ContentResolver#unregisterContentObserver 方法來取消註冊,程式碼比較簡單,這裡就不再展示了。

監聽器設定和註冊完成後,一旦使用者操作了截圖動作,系統就會執行 ContentObserver#onChange 回撥方法,在這個方法中我們可以根據 Uri 獲取並解析資料。這裡展示一下具體的資料解析過程,上述提到的規則判斷比較簡單,就不再展示了。

private void handleMediaContentChange(Uri contentUri) {
	Cursor cursor = null;
        try {
            // 資料改變時查詢資料庫中最後加入的一條資料
            cursor = mContext.getContentResolver().query(contentUri,
                    Build.VERSION.SDK_INT < 16 ? MEDIA_PROJECTIONS : MEDIA_PROJECTIONS_API_16,
                    null, null, MediaStore.Images.ImageColumns.DATE_ADDED + " desc limit 1");
            if (cursor == null)  return;
            if (!cursor.moveToFirst()) return;       
            // cursor.getColumnIndex獲取資料庫列索引
            int dataIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA);
            String data = cursor.getString(dataIndex);        // 圖片儲存地址

            int dateTakenIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATE_TAKEN);
            long dateTaken = cursor.getLong(dateTakenIndex);  // 圖片生成時間

            int width = 0;
            int height = 0;
            if (Build.VERSION.SDK_INT >= 16) {
                int widthIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.WIDTH);
                int heightIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.HEIGHT);
                width = cursor.getInt(widthIndex);    // 獲取圖片高度
                height = cursor.getInt(heightIndex);  // 獲取圖片寬度
            } else {
                Point size = getImageSize(data);     // 根據路徑獲取圖片寬和高
                width = size.x;
                height = size.y;
            }
            // 處理獲取到的第一行資料,分別判斷路徑是否包含關鍵詞、時間差以及圖片寬高和螢幕寬高的大小關係
            handleMediaRowData(data, dateTaken, width, height);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (cursor != null && !cursor.isClosed()) {
                cursor.close();
            }
        }
}
複製程式碼

有些手機 ROM 截圖一次會發出多次內容改變的通知,因此需要做去重操作,去重也不復雜,可以用列表快取最近十幾條圖片地址資料,每次獲取到新的圖片地址,都會先判斷快取中是否存在相同的圖片地址,如果當前的圖片地址已經存在列表中,則直接過濾掉即可,否則新增到快取中。如此就可以保證截圖監聽事件既不遺漏也不重複。

以上就是手機截圖的核心原理和關鍵程式碼,如果需要分享截圖圖片也很簡單, data 即為圖片的儲存地址,轉換成 Bitmap 即可完成分享。

二、WebView 生成長圖

介紹 web 長圖之前,先來說一下單屏圖片的生成方案,和手機截圖不同的是生成的圖片不會顯示頂部的狀態列、標題欄以及底部的選單欄,可以滿足不同的業務需求。

// WebView 生成當前螢幕大小的圖片,shortImage 就是最終生成的圖片

Bitmap shortImage = Bitmap.createBitmap(screenWidth, screenHeight, Bitmap.Config.RGB_565);
Canvas canvas = new Canvas(shortImage);   // 畫布的寬高和螢幕的寬高保持一致
Paint paint = new Paint();
canvas.drawBitmap(shortImage, screenWidth, screenHeight, paint);
mWebView.draw(canvas);
複製程式碼

有的時候我們需要將一個長 Web 網頁生成圖片分享出去,相似的例子就是手機端的各種便籤應用,當便籤內容超出一屏時,就需要將所有的內容生成一張長圖對外分享出去。

WebView 和其他 View 一樣,系統都提供了 draw 方法,可以直接將 View 的內容渲染到畫布上,有了畫布我們就可以在上面繪製其他各種各種的內容,比如底部新增 Logo 圖片,畫紅線框等等。關於 WebView 生成長圖網上已經有很多現成的方案和程式碼,以下程式碼是經測試過的穩定版本,供參考。

// WebView 生成長圖,也就是超過一屏的圖片,程式碼中的 longImage 就是最後生成的長圖

mWebView.measure(MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED),
                 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
mWebView.layout(0, 0, mWebView.getMeasuredWidth(), mWebView.getMeasuredHeight());
mWebView.setDrawingCacheEnabled(true);
mWebView.buildDrawingCache();
Bitmap longImage = Bitmap.createBitmap(mWebView.getMeasuredWidth(),
        mWebView.getMeasuredHeight(), Bitmap.Config.ARGB_8888);

Canvas canvas = new Canvas(longImage);	// 畫布的寬高和 WebView 的網頁保持一致
Paint paint = new Paint();
canvas.drawBitmap(longImage, 0, mWebView.getMeasuredHeight(), paint);
mWebView.draw(canvas);
複製程式碼

Android 為了提高滾動等各方面的繪製速度,可以為每一個 View 建立一個快取,使用 View#buildDrawingCache 為自己的 View 建立相應的快取, 這個 cache 就是一個 bitmap 物件。利用這個功能可以對整個螢幕檢視進行截圖並生成 Bitmap ,也可以獲得指定的 View 的 Bitmap 物件。這裡由於還要在原有的圖片上繪製 Logo ,所以直接使用了 WebView 的 draw 方法了。

由於我們的 H5 頁面大部分都是執行在微信的 X5 瀏覽器中,所以為了減少前端的適配工作,我們將騰訊的 X5 瀏覽器核心引入了 Android 工程中,代替系統原生的 WebView 核心,關於 X5 核心的引入後續還會有專門的文章介紹,敬請期待。

這裡需要說明一下如何在 X5 核心下生成 Web 長圖,上面程式碼展示的系統原生 WebView 生成圖片的方案,但是在 X5 環境下上述程式碼就失效了,經過踩坑以及檢視 X5 核心原始碼,最終我們找到了解決該問題的方法,下面用關鍵程式碼來說明一下具體的實現方式。

// 這裡的 mWebView 就是 X5 核心的 WebView ,程式碼中的 longImage 就是最後生成的長圖

 // 這裡的 mWebView 就是 X5 核心的 WebView ,程式碼中的 longImage 就是最後生成的長圖
mWebView.measure(MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED),
                 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
mWebView.layout(0, 0, mWebView.getMeasuredWidth(), mWebView.getMeasuredHeight());
mWebView.setDrawingCacheEnabled(true);
mWebView.buildDrawingCache();
Bitmap longImage = Bitmap.createBitmap(mWebView.getMeasuredWidth(),
        mWebView.getMeasuredHeight() + endHeight, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(longImage);	// 畫布的寬高和 WebView 的網頁保持一致
Paint paint = new Paint();
canvas.drawBitmap(longImage, 0, mWebView.getMeasuredHeight(), paint);
float scale = getResources().getDisplayMetrics().density;
x5Bitmap = Bitmap.createBitmap(mWebView.getWidth(), mWebView.getHeight(), Bitmap.Config.ARGB_8888);
Canvas x5Canvas = new Canvas(x5Bitmap);
x5Canvas.drawColor(ContextCompat.getColor(this, R.color.fragment_default_background));
mWebView.getX5WebViewExtension().snapshotWholePage(x5Canvas, false, false);  // 少了這行程式碼就無法正常生成長圖
Matrix matrix = new Matrix();
matrix.setScale(scale, scale);
longCanvas.drawBitmap(x5Bitmap, matrix, paint);
複製程式碼

注:X5 核心生成的長圖清晰度比原生 WebView 要差一些,目前還沒有太好的解決方案。

三、長圖分享

一般我們向各個社交平臺上傳送的圖片都比較小,最大也就是手機螢幕大小的圖片,再大的就不多見了。但是也有例外,比如微博的長圖、錘子便籤的長圖等等,如果直接將這些圖片通過微信分享 SDK 或者微博分享 SDK 分享出去,就會發現圖片基本上都是模糊的,但是將圖片傳送給 iPhone 手機就可以正常檢視,我們只能哀嘆 Android 版微信不給力。

微信 SDK 不給力,但是產品體驗還是不能丟,怎麼辦呢?辦法還是有的,我們都知道除了各個社交平臺自己的分享 SDK ,系統提供了原生分享方案,本質上就是社交平臺把目標 Activity 對外暴露了出來,然後第三方 App 就可以根據事先定義好的 Intent 跳轉規則喚起社交平臺,同時完成資料傳輸和展示。

好像問題可以完美解決了,但是還是有坑需要接著踩。在 Android 7.0 及以上的版本系統限制了 Intent 傳輸 file:// 開頭的資料,這也就限制了系統原生分享單圖,怎麼辦呢?兩種方案,一種是在 7.0 及以上版本上使用微信等分享 SDK ,接受分享圖片模糊的現狀,另一種是通過反射跳過系統對以 file:// 開標頭檔案在 Intent 中傳輸的限制,但是這種方式會有風險,畢竟我們不知道未來 Android 會做出什麼調整。以下是跳過系統限制的程式碼片段,供參考。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    try {
        Method ddfu = StrictMode.class.getDeclaredMethod("disableDeathOnFileUriExposure");
        ddfu.invoke(null);
    } catch (Exception e) {
    }
}
複製程式碼

至此基本上可以滿足任意圖片大小的分享了。此外經過驗證還發現微信分享 Android 版 SDK 對縮圖和分享圖的大小都有限制,官方給的指導意見是縮圖小於 32K ,分享圖片小於 10M 即可正常分享,但是試驗下來這兩個值都是理論上限,不要太接近這個上限,如果圖片太大,縮圖和分享圖都會出現模糊的情況,甚至無法正常分享,當然對於通過系統分享的話就不存在這個限制,圖片也比較清晰。

除了圖片大小有限制,縮圖的尺寸也是有限制的,這一點官方文件並沒有給出,試驗結果顯示圖片尺寸小於等於120×120是比較安全的範圍,分享都沒有問題。

四、小結

截圖監聽、 WebView 生成長圖以及長圖分享都是我們團隊之前未曾遇到過的業務需求,在滿足產品業務需求的同時,也踩了很多坑,積累了一些經驗,特此總結。

關於更多

NDK專案實戰—高仿360手機助手之解除安裝監聽

最新2017(Android)面試題級答案(精選版)

相信自己,沒有做不到的,只有想不到的

微信公眾號:終端研發部

技術+職場

相關文章