Android平台监听系统截屏方案预研及相关知识点
最近有個針對系統截屏的需求,所以預研了Android平臺上捕獲系統截屏的方案。
最直接的方式就是監聽手機的系統截屏組合鍵(電源鍵+音量下鍵),但是這種方式實現難度大,且有的機型使用特殊手勢進行截屏,兼容性問題難以解決。
所以網上流行的方案是監聽系統截屏目錄下文件創建事件或者多媒體數據庫圖片資源變更通知。我對兩種方式都做了測試,多多少少都存在一些問題,現整理如下:
通過FileObserver監聽系統截屏目錄下的文件創建
FileObserver可以對一個文件或者目錄進行監聽,它是基于linux的inotify實現,可以監聽文件創建、訪問、修改等操作。
雖然文檔上說FileObserver可以實現遞歸監聽,即被監聽文件夾下所有文件和級聯子目錄的改變都會觸發監聽器。但是,真正實驗下來發現,不是這么回事!被監聽目錄的子目錄的本身改動以及子目錄下的文件改動都不會觸發監聽器。因此,要想實現遞歸監聽,必須自己遞歸實現對每個子目錄的監聽。
FileObserver可以監聽多種類型的事件:
| ACCESS | 被監聽文件被訪問 |
| MODIFY | 被監聽文件被修改 |
| ATTRIB | 被監聽文件或目錄的權限、Owner等屬性被改變 |
| CLOSE_WRITE | 被監聽的可寫文件或者目錄(已經被打開)被關閉 |
| CLOSE_NOWRITE | 被監聽的只讀文件或者目錄(已經被打開)被關閉 |
| OPEN | 被監聽文件或者目錄被打開 |
| MOVED_FROM | 文件或者子目錄從當前被監聽目錄下被移走 |
| MOVED_TO | 文件或者子目錄從其他目錄被移動到當前被監聽目錄下 |
| CREATE | 在當前被監聽目錄下,創建文件或者子目錄 |
| DELETE | 在當前被監聽目錄下刪除一個文件 |
| DELETE_SELF | 被監聽的文件或者目錄本身被刪除,此時監聽將被停止 |
| MOVE_SELF | 被監聽的文件或者目錄本身被移動 |
| ALL_EVENTS | 上面多有事件的并集 |
FileObserver是抽象類,我們需要實現onEvent方法處理具體業務邏輯。此外,創建FileObserver對象時,需要指定被監聽文件或者目錄,以及需要監聽的事件類型。
經過實際測試,發現使用FileObserver進行文件(夾)監控,有幾點需要注意:
OK,FileObserver的基本情況介紹完了,下面我們看下使用FileObserver監聽系統截圖的方案和可行性:因為我們要監聽系統截圖,因此理論上只需要監聽系統截圖目錄的CREATE事件即可?;敬a如下所示:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | //三星Note3下的系統截圖目錄 String path = "/storage/emulated/0/Pictures/Screenshots"; //小米4下的系統截圖目錄 //path = "/storage/emulated/0/DCIM/Screenshots"; //指定監聽路徑path和事件類型CREATE FileObserver fileObserver = new FileObserver(path,FileObserver.CREATE) { public void onEvent(int event, String path) { //這里最好啟動一個線程去加載系統截屏的圖片,否則會導致線程被阻塞,無法監聽到后續事件。 //此外,這里的path僅是圖片文件名,不是完整路徑 //收到CREATE事件后,立即去加載圖片是獲取不到的,需要延遲幾百毫秒才可以加載到,估計是圖片正在落地。 } }; //開始監聽 fileObserver.startWatching(); //結束監聽 fileObserver.stopWatching(); |
但是實際測試下來發現,在三星Note3上可以準確的監聽系統截圖,并可以獲取到系統截圖圖片。但是在小米4上,根本監聽不到CREATE事件(實際上,截屏圖片已經在系統截屏目錄了)。
在小米4上僅能監聽到ACCESS(被觸發多次)和OPEN事件。但是OPEN事件在三星Note3上會觸發多次,而且Android手機千奇百怪,要想找到一個系統截屏時,所有手機都會觸發一次的FileObserver事件,會很難,而且存在很大的兼容性問題。
因此,通過FileObserver監聽系統截圖存在兩個比較大的問題:
所以目前來看,通過FileObserver監聽系統截圖不靠譜。
通過ContentObserver監聽多媒體數據庫(圖片)的資源變化
我們知道:通過系統截屏生成一張圖片時,這張圖片不僅會存儲在系統截屏目錄中,還會通過MediaProvider類在多媒體數據庫中插入一條記錄,方便系統圖庫進行查詢。而且MediaProvider會將唯一標識這張圖片的URI通知到感興趣的ContentObserver。(關于多媒體數據庫下面會進行詳細介紹)
因此,我們的方案就是通過ContentObserver監聽多媒體數據庫圖片資源的變化?;敬a如下所示:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | //查詢的表字段 static final String[] PROJECTION = new String[]{ MediaStore.Images.Media.DATA,MediaStore.Images.Media.DATE_ADDED}; //根據時間降序排序 static final String SORT_ORDER = MediaStore.Images.Media.DATE_ADDED + " DESC"; //mHandler表示主線程的Handler,這樣回調函數onChange就會在主線程被調用 ContentObserver contentObserver = new ContentObserver(mHandler) { public void onChange(boolean selfChange) { super.onChange(selfChange); //從API16開始,才有兩個參數的onChange方法,所以這里要主動調用下面的onChange方法。 onChange(selfChange, null); } public void onChange(boolean selfChange, Uri uri) { //若調用父類方法就死循環了 //super.onChange(selfChange,uri); if (uri == null) { //API16以下版本 Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, PROJECTION, null, null,SORT_ORDER); if (cursor != null && cursor.moveToFirst()) { //完整路徑 String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)); //添加圖片的時間,單位秒 long dateAdded = cursor.getLong(cursor.getColumnIndex(MediaStore.Images.Media.DATE_ADDED)); long currentTime = System.currentTimeMillis() / 1000; //加個過濾條件必須是3S內的圖片,且路徑中包含截圖字樣“screenshot” if (Math.abs(currentTime - dateAdded) <= 3l && path.toLowerCase().contains("screenshot")) { //這就是系統截屏的圖片了,這里測試發現需要等待幾百MS,才能加載到圖片。因此具體實現時,最好在獨立線程,每隔100MS嘗試加載一次,做好超時處理。 Bitmap b1 = BitmapFactory.decodeFile(path); } } } else { //API16及以上版本 if (uri.toString().matches(EXTERNAL_CONTENT_URI_MATCHER + "/\\d+")) { Cursor cursor = contentResolver.query(uri, PROJECTION, null, null, null); if (cursor != null && cursor.moveToFirst()){ //完整路徑 String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)); //添加圖片的時間,單位秒 long dateAdded = cursor.getLong(cursor.getColumnIndex(MediaStore.Images.Media.DATE_ADDED)); long currentTime = System.currentTimeMillis() / 1000; if (Math.abs(currentTime - dateAdded) <= 3l && path.toLowerCase().contains("screenshot")) { //這就是系統截屏的圖片了 Bitmap b2 = MediaStore.Images.Media.getBitmap(contentResolver, uri); } } } } } } //通過ContentResolver注冊ContentObserver,監聽"content://media/external/video/media" getContentResolver().registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver); //不需要監聽的時候,一定要把原來的ContentObserver注銷掉。 getContentResolver().unregisterContentObserver(contentObserver); |
上述代碼中,我們在API16以上和以下采取了兩種不同的方案:
上述方案,經過測試,發現存在一些問題:
簡單來說,就是沒辦法完全確定觸發onChange回調的事件一定是系統截屏行為。因此,在onChange回調方法中,判斷此次回調是不是系統截屏觸發的,是個難點。但是這個問題解決不好,就會造成一定的誤差。比如:我通過相機拍攝了一張圖片,就會觸發上面的onChange回調。所以上面的代碼加了兩個過濾條件:必須是3S內的圖片,且圖片路徑中包含截圖字樣“screenshot”。但是這樣也不能確保百分之百沒有誤差。
綜上所述,不管是通過FileObserver還是ContentObserver,都不能完全準確地監控系統截屏操作。(相比于IOS直接提供了API級別的支持,Android還是很蛋疼啊…)
多媒體數據庫
Android中的多媒體數據記錄(圖片、音頻、視頻等)是存儲在DB中的,即多媒體數據庫。這個數據庫文件存儲在/data/data/com.android.providers.media/databases目錄中。如下圖所示:
其中internal.db是內部存儲數據庫文件,external.db是存儲卡數據庫文件。多媒體數據操作主要就是圍繞這兩個數據庫來進行的,這兩個數據庫的結構是完全一樣的。如下所示:
上面是存儲不同多媒體數據的表,其中video表主要存儲視頻數據;videothumbnails表主要存儲視頻縮略圖數據;audio_xx表主要存儲音頻數據,音頻數據比較復雜,又需要album相關表存儲專輯信息,artist相關表存儲歌手信息;images表主要存儲圖片數據。thumbnails表主要存儲圖片縮略圖數據。
這里我們主要看下images表結構,如下所示:
可見,images表是基于files表的視圖。其中,_data字段表示圖片的完整路徑,data_added字段表示添加圖片的時間,width和height字段分別表示圖片的寬度和高度,_display_name字段則表示圖片名稱。
下面看兩個具體案例,我們分別通過系統截屏手勢和相機獲取一張圖片,然后看下這兩種圖片在images表中的存儲。
首先是截屏獲得的圖片,其表記錄如下所示:
然后是相機拍攝出的圖片,其表記錄如下所示:
從上述兩張圖片的表數據可知:
- 圖片id確實是遞增的。
- 系統截圖和相機拍攝的圖片存儲在不同的目錄。
- 系統截圖圖片是png格式,相機拍攝圖片是jpeg格式。
- bucket_display_name字段指出了圖片的來源途徑,它是根據_data字段生成的。
- 系統截屏圖片的寬高就是屏幕的寬高,而相機拍攝圖片的寬高則和具體手機有關,但一般都大于屏幕寬高。
- 向其他字段的含義也很明確,此處不再贅述。
上面我們是通過sql語句直接查詢圖片數據,其實Android系統給我們封裝了MediaStore類,它提供了多媒體數據存儲與獲取相關API,其基本結構如下所示(詳細結構可參見源碼):
其中Images.ImageColumns類主要封裝了images表的字段。Images.Media類主要提供了查詢和插入圖片數據的API(這類API很簡單,都是通過ContentResolver和uri,呼起對應的MediaProvider完成真正的DB操作),以及可以通過getBitmap方法獲取圖片的Bitmap對象,而Images.Thumbnails類則提供了操作縮略圖的相關API。同樣的,其他的內部類(Audio、Video)分別對應音頻表和視頻表。
Images.Media.getBitmap方法很便利,其實現也很簡單,首先通過uri獲取輸入流(詳情參見源碼),然后通過BitmapFactory類解碼獲取Bitmap。如下所示:
| 1 2 3 4 5 6 | public static final Bitmap getBitmap(ContentResolver cr, Uri url)throws FileNotFoundException, IOException { InputStream input = cr.openInputStream(url); Bitmap bitmap = BitmapFactory.decodeStream(input); input.close(); return bitmap; } |
從MediaStore類的源碼可知,它提供的API都是通過ContentResolver和Uri呼起對應MediaProvider來實現的,MediaProvider才是真正實現多媒體數據庫操作的場所。關于MediaProvider,又是單獨話題了,感興趣的可以去看源碼。
MediaStore類為每一種資源分配了單獨的Uri地址,例如:視頻資源的基礎地址是MediaStore.Vedio.MediaEXTERNAL_CONTENT_URI,即content://media/external/video/media,圖片資源的基礎地址是MediaStore.Images.Media.EXTERNAL_CONTENT_URI,即content://media/external/images/media。
這些基礎地址都是數據集合類型,對應的個體數據類型則是在基礎地址后面加上圖片ID。例如:上面我們通過系統截屏獲得的圖片資源ID是233494,那么唯一標識這張圖片的uri就是content://media/external/images/media/233494,通過這個uri,就可以獲取這張圖片的所有信息了(上面getBitmap方法的第二個參數就是這種個體數據類型uri)。實際操作中,要使用哪種類型的URI,則要根據具體情況而定。
因此,獲取系統截屏圖片的Bitmap對象有兩種方式:
ContentProvider的數據更新通知機制
上面介紹的第二種方案,依賴的就是ContentProvider的數據更新通知機制。因為ContentProvider是以URI形式來組織資源的,所以當數據變更時,也是以URI形式通知感興趣的ContentObserver。
整個數據更新機制的示意圖如下所示:
其中,ContentService服務就是管理所有ContentObserver監聽器的場所,它運行在System進程,以多叉樹的形式組織所有監聽器。而MediaProvider則負責操作多媒體數據庫,并以URI的形式發出數據變更通知到ContentService服務,ContentService負責從樹形數據結構中找出對該URI感興趣的ContentObserver,然后跨進程回調ContentObserver.onChange方法。
所以這里的關鍵點就是ContentService服務中多叉樹數據結構的建立和查詢。其中多叉樹的節點是ObserverNode,如下所示:
| 1 2 3 4 5 6 7 8 9 10 11 12 | class ObserverNode{ String mName;//節點名稱 ArrayList<ObserverNode> mChildren = new ArrayList<ObserverNode>();//孩子節點 ArrayList<ObserverEntry> mObservers = new ArrayList<ObserverEntry>();//該節點上的監聽器 } class ObserverEntry{ //跨進程回調的接口 IContentObserver observer; //該參數就是注冊監聽器時的第二個參數,若為false,則表示若變化的URI是正在監聽的URI的父節點或者相同節點時,就會觸發回調。若為true,則在上述時機之上,若變化的URI是正在監聽的URI的子節點時,也會觸發回調。 boolean notifyForDescendants; } |
上面我們監聽系統截屏事件時,監聽的URI是content://media/external/images/media,且notifyForDescendents參數為true。因此,注冊之后,ContentService服務的多叉樹數據結構如下所示:
而當系統截屏圖片插入到多媒體數據庫時,MediaProvider會發出content://media/external/images/media/xxx形式的通知,該通知到達ContentService服務后,就會在上面的多叉樹數據結構中進行檢索,以找到對此URI感興趣的監聽器。
其中當查找到media節點時,就會把media節點中的notifyForDescendants屬性為true(即正在通知的URI是content://media/external/images/media的子節點)的ObserverEntry對象收集起來。最后,通過ObserverEntry對象的observer接口屬性回調到應用程序進程的ContentObserver.onChange方法,這樣整個流程就完整了。
這里在應用程序進程注冊URI時,需要特別注意,ContentService服務在組織多叉樹數據結構時,遇到/、#、?這三個特殊符號,就會停止構造子節點,因此content://media/external/images/media/#、content://media/external/images/media//#和content://media/external/images/media/#/?等URI形成的多叉樹結構都是相同的,即上面的樹形結構。(一開始我在注冊URI時,以為#號的作用和ContentProvider中#號一樣,代表所有的整型ID,坑了我很久)。
參考文檔
總結
以上是生活随笔為你收集整理的Android平台监听系统截屏方案预研及相关知识点的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 中断处理的那些事儿
- 下一篇: Algorithm, Secret ke