wordpress 程序员 Windows 微软 apache 开源 编程 shell Python google nginx Ubuntu Android java Firefox linux 云计算 mysql centos php

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

一、概述

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

二、截圖分享

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

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

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


 
  1. MediaStore.Images.Media.INTERNAL_CONTENT_URI 
  2.  
  3. MediaStore.Images.Media.EXTERNAL_CONTENT_URI  

读取外部存储器资源,需要添加权限:


 
  1. 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 相關知識。

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


 
  1. private void initMediaContentObserver() {    // 运行在 UI 线程的 Handler, 用于运行监听器回调  
  2.     private final Handler mUiHandler = new Handler(Looper.getMainLooper());    // 创建内容观察者,包括内部存储和外部存储 
  3.     mInternalObserver = new MediaContentObserver(MediaStore.Images.Media.INTERNAL_CONTENT_URI, mUiHandler); 
  4.     mExternalObserver = new MediaContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mUiHandler);    // 注册内容观察者 
  5.     mContext.getContentResolver().registerContentObserver( 
  6.             MediaStore.Images.Media.INTERNAL_CONTENT_URI, false, mInternalObserver); 
  7.     mContext.getContentResolver().registerContentObserver( 
  8.             MediaStore.Images.Media.EXTERNAL_CONTENT_URI, false, mExternalObserver); 
  9. }/** 
  10.  * 自定义媒体内容观察者类(观察媒体数据库的改变) 
  11.  */private class MediaContentObserver extends ContentObserver {    private Uri mediaContentUri;       // 需要观察的Uri 
  12.     public MediaContentObserver(Uri contentUri, Handler handler) {        super(handler); 
  13.         mediaContentUri = contentUri; 
  14.     }    @Override 
  15.     public void onChange(boolean selfChange) {        super.onChange(selfChange);        // 处理媒体数据库反馈的数据变化 
  16.         handleMediaContentChange(mediaContentUri); 
  17.     } 
  18. }  

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

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


 
  1. private void handleMediaContentChange(Uri contentUri) { 
  2.     Cursor cursor = null;        try {            // 数据改变时查询数据库中最后加入的一条数据 
  3.             cursor = mContext.getContentResolver().query(contentUri, 
  4.                     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;        
  5.  
  6.             // cursor.getColumnIndex获取数据库列索引 
  7.             int dataIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA); 
  8.             String data = cursor.getString(dataIndex);        // 图片存储地址 
  9.  
  10.             int dateTakenIndex = cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATE_TAKEN);            long dateTaken = cursor.getLong(dateTakenIndex);  // 图片生成时间 
  11.  
  12.             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); 
  13.                 width = cursor.getInt(widthIndex);    // 获取图片高度 
  14.                 height = cursor.getInt(heightIndex);  // 获取图片宽度 
  15.             } else { 
  16.                 Point size = getImageSize(data);     // 根据路径获取图片宽和高 
  17.                 width = size.x; 
  18.                 height = size.y; 
  19.             }            // 处理获取到的第一行数据,分别判断路径是否包含关键词、时间差以及图片宽高和屏幕宽高的大小关系 
  20.             handleMediaRowData(data, dateTaken, width, height); 
  21.  
  22.         } catch (Exception e) { 
  23.             e.printStackTrace(); 
  24.         } finally {            if (cursor != null && !cursor.isClosed()) { 
  25.                 cursor.close(); 
  26.             } 
  27.         } 

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

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

二、WebView 生成長圖

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


 
  1. // WebView 生成当前屏幕大小的图片,shortImage 就是最终生成的图片Bitmap shortImage = Bitmap.createBitmap(screenWidth, screenHeight, Bitmap.Config.RGB_565); 
  2. Canvas canvas = new Canvas(shortImage);   // 画布的宽高和屏幕的宽高保持一致Paint paint = new Paint(); 
  3. canvas.drawBitmap(shortImage, screenWidth, screenHeight, paint); 
  4. mWebView.draw(canvas);  

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

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


 
  1. // WebView 生成长图,也就是超过一屏的图片,代码中的 longImage 就是最后生成的长图mWebView.measure(MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED), 
  2.                  MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); 
  3. mWebView.layout(0, 0, mWebView.getMeasuredWidth(), mWebView.getMeasuredHeight()); 
  4. mWebView.setDrawingCacheEnabled(true); 
  5. mWebView.buildDrawingCache(); 
  6. Bitmap longImage = Bitmap.createBitmap(mWebView.getMeasuredWidth(), 
  7.         mWebView.getMeasuredHeight(), Bitmap.Config.ARGB_8888); 
  8.  
  9. Canvas canvas = new Canvas(longImage);    // 画布的宽高和 WebView 的网页保持一致Paint paint = new Paint(); 
  10. canvas.drawBitmap(longImage, 0, mWebView.getMeasuredHeight(), paint); 
  11. 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 內核源代碼,最終我們找到了解決該問題的方法,下面用關鍵代碼來說明一下具體的實現方式。


 
  1. // 这里的 mWebView 就是 X5 内核的 WebView ,代码中的 longImage 就是最后生成的长图mWebView.measure(MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED), 
  2.                  MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); 
  3. mWebView.layout(0, 0, mWebView.getMeasuredWidth(), mWebView.getMeasuredHeight()); 
  4. mWebView.setDrawingCacheEnabled(true); 
  5. mWebView.buildDrawingCache(); 
  6. Bitmap longImage = Bitmap.createBitmap(mWebView.getMeasuredWidth(), 
  7.         mWebView.getMeasuredHeight() + endHeight, Bitmap.Config.ARGB_8888); 
  8. Canvas canvas = new Canvas(longImage);    // 画布的宽高和 WebView 的网页保持一致Paint paint = new Paint(); 
  9. canvas.drawBitmap(longImage, 0, mWebView.getMeasuredHeight(), paint);float scale = getResources().getDisplayMetrics().density; 
  10. x5Bitmap = Bitmap.createBitmap(mWebView.getWidth(), mWebView.getHeight(), Bitmap.Config.ARGB_8888); 
  11. Canvas x5Canvas = new Canvas(x5Bitmap); 
  12. x5Canvas.drawColor(ContextCompat.getColor(this, R.color.fragment_default_background)); 
  13. mWebView.getX5WebViewExtension().snapshotWholePage(x5Canvas, false, false);  // 少了这行代码就无法正常生成长图Matrix matrix = new Matrix(); 
  14. matrix.setScale(scale, scale); 
  15. 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 會做出什麽調整。以下是跳過系統限制的代碼片段,供參考。


 
  1. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {    try { 
  2.         Method ddfu = StrictMode.class.getDeclaredMethod("disableDeathOnFileUriExposure"); 
  3.         ddfu.invoke(null); 
  4.     } catch (Exception e) { 
  5.  
  6.     } 
  7. }  

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

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

四、小結

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

延伸阅读

评论