Android平台监听系统截屏方案预研及相关知识点

最近有个针对系统截屏的需求,因此预研了Android平台上捕获系统截屏的方案。html

最直接的方式就是监听手机的系统截屏组合键(电源键+音量下键),可是这种方式实现难度大,且有的机型使用特殊手势进行截屏,兼容性问题难以解决。linux

因此网上流行的方案是监听系统截屏目录下文件建立事件或者多媒体数据库图片资源变动通知。我对两种方式都作了测试,多多少少都存在一些问题,现整理以下:android

经过FileObserver监听系统截屏目录下的文件建立

FileObserver能够对一个文件或者目录进行监听,它是基于linux的inotify实现,能够监听文件建立、访问、修改等操做。sql

虽然文档上说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对象时,须要指定被监听文件或者目录,以及须要监听的事件类型。ide

通过实际测试,发现使用FileObserver进行文件(夹)监控,有几点须要注意:函数

  1. 不要在onEvent方法中进行耗时操做,这样会致使线程被阻塞,没法监听到后续事件,最好在工做线程进行统一处理。
  2. 防止出现死循环,好比:若监听CREATE事件时,就不能在onEvent方法中在被监听目录建立文件,不然又会触发CREATE事件,致使死循环。
  3. 回调方法onEvent中的参数path,仅是文件名,不是完整路径。
  4. 在监听到CREATE事件时,须要等待几百ms,才能加载到到文件。(这点很坑,不知道有啥解决方案不?!)

OK,FileObserver的基本状况介绍完了,下面咱们看下使用FileObserver监听系统截图的方案和可行性:由于咱们要监听系统截图,所以理论上只须要监听系统截图目录的CREATE事件便可。基本代码以下所示:post

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) {
    @Override
    public void onEvent(int event, String path) {
        //这里最好启动一个线程去加载系统截屏的图片,不然会致使线程被阻塞,没法监听到后续事件。
        //此外,这里的path仅是图片文件名,不是完整路径
        //收到CREATE事件后,当即去加载图片是获取不到的,须要延迟几百毫秒才能够加载到,估计是图片正在落地。
    }
};
//开始监听
fileObserver.startWatching();
//结束监听
fileObserver.stopWatching();

可是实际测试下来发现,在三星Note3上能够准确的监听系统截图,并能够获取到系统截图图片。可是在小米4上,根本监听不到CREATE事件(实际上,截屏图片已经在系统截屏目录了)。性能

在小米4上仅能监听到ACCESS(被触发屡次)和OPEN事件。可是OPEN事件在三星Note3上会触发屡次,并且Android手机千奇百怪,要想找到一个系统截屏时,全部手机都会触发一次的FileObserver事件,会很难,并且存在很大的兼容性问题。

所以,经过FileObserver监听系统截图存在两个比较大的问题:

  1. 每一个手机上保存系统截屏图片的目录不彻底相同,好比上面三星Note3和小米4就不一样。所以,必须先得到每一个手机保存截图图片的目录,才能进行监听。
  2. 很难找到一个系统截屏时,全部手机仅会触发一次的FileObserver事件。

因此目前来看,经过FileObserver监听系统截图不靠谱。

经过ContentObserver监听多媒体数据库(图片)的资源变化

咱们知道:经过系统截屏生成一张图片时,这张图片不只会存储在系统截屏目录中,还会经过MediaProvider类在多媒体数据库中插入一条记录,方便系统图库进行查询。并且MediaProvider会将惟一标识这张图片的URI通知到感兴趣的ContentObserver。(关于多媒体数据库下面会进行详细介绍)

所以,咱们的方案就是经过ContentObserver监听多媒体数据库图片资源的变化。基本代码以下所示:

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) {
    @Override
    public void onChange(boolean selfChange) {
        super.onChange(selfChange);
        //从API16开始,才有两个参数的onChange方法,因此这里要主动调用下面的onChange方法。
        onChange(selfChange, null);
    }

    @Override
    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以上和如下采起了两种不一样的方案:

  1. 方案1:API16如下,由于回调中没有URI,因此只能到多媒体数据库中去查询,而后取出最新的那一条记录,理论上就是系统截屏的图片了。
  2. 方案2:API16及以上,由于回调中有惟一标识图片的URI,因此能够经过MediaStore.Images.Media和URI,直接获取截屏图片。这种方式既简单,又准确!

上述方案,通过测试,发现存在一些问题:

  1. 方案1中,若收到onChange回调,当即去获取图片,是加载不到的,必须等几百毫秒,推测应该是图片还没彻底落地。可是这个等待的时间应该跟机器性能有关,所以很难肯定一个固定值(和FileObserver存在相同的问题)。
  2. 不只向多媒体数据库中插入一条图片数据会触发onChange回调,更新和删除图片数据,也会触发onChange回调。
  3. 若咱们主动经过MediaProvider向多媒体数据库插入、更新、删除一条图片数据,也会触发onChange回调。

简单来讲,就是没办法彻底肯定触发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表结构,以下所示:
image表结构
可见,images表是基于files表的视图。其中,_data字段表示图片的完整路径,data_added字段表示添加图片的时间,widthheight字段分别表示图片的宽度和高度,_display_name字段则表示图片名称。

下面看两个具体案例,咱们分别经过系统截屏手势和相机获取一张图片,而后看下这两种图片在images表中的存储。
首先是截屏得到的图片,其表记录以下所示:
截图表数据

而后是相机拍摄出的图片,其表记录以下所示:
拍照数据

从上述两张图片的表数据可知:

  • 图片id确实是递增的。
  • 系统截图和相机拍摄的图片存储在不一样的目录。
  • 系统截图图片是png格式,相机拍摄图片是jpeg格式。
  • bucket_display_name字段指出了图片的来源途径,它是根据_data字段生成的。
  • 系统截屏图片的宽高就是屏幕的宽高,而相机拍摄图片的宽高则和具体手机有关,但通常都大于屏幕宽高。
  • 向其余字段的含义也很明确,此处再也不赘述。

上面咱们是经过sql语句直接查询图片数据,其实Android系统给咱们封装了MediaStore类,它提供了多媒体数据存储与获取相关API,其基本结构以下所示(详细结构可参见源码):
MediaStore

其中Images.ImageColumns类主要封装了images表的字段。Images.Media类主要提供了查询和插入图片数据的API(这类API很简单,都是经过ContentResolveruri,呼起对应的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都是经过ContentResolverUri呼起对应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对象有两种方式:

  1. 假如知道了图片的惟一标识URI,那么经过MediaStore.Images.Media.getBitmap方法就能够获取了。
  2. 假如不知道URI,而知道图片的本地地址(SD卡地址),那么只能经过BitmapFactory类的decodeXXX方法来搞定了。

ContentProvider的数据更新通知机制

上面介绍的第二种方案,依赖的就是ContentProvider的数据更新通知机制。由于ContentProvider是以URI形式来组织资源的,因此当数据变动时,也是以URI形式通知感兴趣的ContentObserver。

整个数据更新机制的示意图以下所示:
ContentProvider的数据更新通知机制

其中,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服务的多叉树数据结构以下所示:
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,坑了我好久)。

参考文档

  1. 深刻理解MediaScanner
  2. Android应用程序组件Content Provider的共享数据更新通知机制分析
  3. Detect only screenshot with FileObserver Android
原文地址:
http://ltlovezh.com/2016/06/12/Android%E5%B9%B3%E5%8F%B0%E7%9B%91%E5%90%AC%E7%B3%BB%E7%BB%9F%E6%88%AA%E5%B1%8F%E6%96%B9%E6%A1%88%E9%A2%84%E7%A0%94%E5%8F%8A%E7%9B%B8%E5%85%B3%E7%9F%A5%E8%AF%86%E7%82%B9/