前言
12月中旬产品提出了一个需求,截屏分享的功能。我想这个需求网上已经一大堆文章了。所以这里我就大致说一下。
解决方案
1、FileObserver监听截图文件目录数据改变。
2、ContentProvider监听数据的改变。
FileObserver
不熟悉FileObserver的同学请点击这里,采用FileObserver方式
则需要根据厂商所在的截屏文件文件夹路径进行适配,这点就有点烦哦。所以最终我选择ContentProvider的方式监听文件数据的变动。
ContentObserver
ContentProvider用于将应用数据共享出去,ContentObserver 内容观察者用于获取共享数据,使用它即可监听到数据的变更。
创建内容观察者对象
public class CaptureFileObserver extends ContentObserver {
private final Uri mContentUri;
private final CaptureCallback mCaptureCallback;
public CaptureFileObserver(Uri contentUri, CaptureCallback captureCallback, Handler handler) {
super(handler);
mCaptureCallback = captureCallback;
mContentUri = contentUri;
}
@Override
public void onChange(boolean selfChange, Uri uri) {
super.onChange(selfChange, uri);
// 触发了截屏 注意这里会多次回调
if (mCaptureCallback != null){
mCaptureCallback.onMediaFileChanged(mContentUri);
}
}
/**
* 内容观察者回调事件
*/
public interface CaptureCallback {
void onMediaFileChanged(Uri contentUri);
}
}
当数据发生变化之后,将会回调onChange()方法通知我们数据发生了变化。
注册内容观察者
public abstract class MediaFileBaseObserver implements CaptureFileObserver.CaptureCallback {
protected Context mContext;
private final Handler mHandler = new Handler(Looper.getMainLooper());
/**
* 获取截屏事件回调
*/
protected CaptureCallback mCaptureCallback;
private final CaptureFileObserver mCaptureInternalFileObserver;
private final CaptureFileObserver mCaptureExternalFileObserver;
private final Uri[] mContentUris = {Media.INTERNAL_CONTENT_URI, Media.EXTERNAL_CONTENT_URI};
protected final ContentResolver mContentResolver;
protected long mStartListenTime;
public MediaFileBaseObserver(Context context) {
mContext = context;
mContentResolver = mContext.getContentResolver();
// 内部外部媒体文件的监听
mCaptureInternalFileObserver = new CaptureFileObserver(mContentUris[0], this, mHandler);
mCaptureExternalFileObserver = new CaptureFileObserver(mContentUris[1], this, mHandler);
}
/**
* 开始进行捕捉截屏监听
*/
public void registerCaptureListener(){
// 记录开始监听的时间 算是一个图片是否是截屏的一个指标
mStartListenTime = System.currentTimeMillis();
// 注意 第二个boolean参数 要设置为true 不然有些机型由于多媒体文件层级不同 导致变化监听不到 所以设置后代文件夹发生了文件改变也要进行通知
mContentResolver.registerContentObserver(mContentUris[0],true, mCaptureInternalFileObserver);
mContentResolver.registerContentObserver(mContentUris[1],true, mCaptureExternalFileObserver);
}
/**
* 解除绑定
*/
public void unregisterCaptureListener(){
mContentResolver.unregisterContentObserver(mCaptureInternalFileObserver);
mContentResolver.unregisterContentObserver(mCaptureExternalFileObserver);
}
/**
* 设置回调监听
* @param captureCallback 回调
*/
public void setCaptureCallbackListener(CaptureCallback captureCallback){
mCaptureCallback = captureCallback;
}
@Override
public void onMediaFileChanged(Uri contentUri) {
acquireTargetFile(contentUri);
}
/**
* 获取目标的文件
* @param contentUri 内容URI
*/
abstract void acquireTargetFile(Uri contentUri);
}
这里我们对外部存储图片文件夹和内部存储图片文件夹进行注册监听。若发生了文件变化,则从这两个路径中拿所有的图片文件路径,并且进行按照图片的添加顺序进行降序排序并且限制数量为1,也就是说取第一张图片。
内部存储
content://media/internal/images/media
外部存储
content://media/external/images/media
public class MediaImageObserver extends MediaFileBaseObserver {
private static final String TAG = MediaImageObserver.class.getSimpleName();
@SuppressLint("StaticFieldLeak")
private static volatile MediaImageObserver mInstance = null;
private static final String[] MEDIA_STORE_IMAGE = {
MediaStore.Images.ImageColumns.DATA,
// 时间 这里不能用 Date_ADD 因为是秒级 按时间筛选不准确
MediaStore.Images.ImageColumns.DATE_TAKEN,
// 宽
MediaStore.Images.ImageColumns.WIDTH
};
// 截屏关键词 随时补充
private static final String[] KEYWORDS = {
"screenshot", "screen_shot", "screen-shot", "screen shot",
"screencapture", "screen_capture", "screen-capture", "screen capture",
"screencap", "screen_cap", "screen-cap", "screen cap", "Screenshot","截屏"
};
// 按照日期插入的顺序取第一条
private final static String QUERY_ORDER_SQL = ImageColumns.DATE_ADDED + " DESC LIMIT 1";
private final Point mPoint;
public static MediaFileBaseObserver getInstance(Application application) {
if (mInstance == null) {
synchronized (MediaFileBaseObserver.class) {
if (mInstance == null) {
mInstance = new MediaImageObserver(application.getApplicationContext());
}
}
}
return mInstance;
}
public MediaImageObserver(Context context) {
super(context);
mPoint = ScreenUtil.getRealScreenSize(context);
}
@Override
void acquireTargetFile(Uri contentUri) {
Cursor cursor = null;
try {
if (VERSION.SDK_INT >= VERSION_CODES.Q) {
Bundle bundle = new Bundle();
// 按照文件时间
bundle.putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, new String[]{FileColumns.DATE_TAKEN});
// 降序
bundle.putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, ContentResolver.QUERY_SORT_DIRECTION_DESCENDING);
// 取第一张
bundle.putInt(ContentResolver.QUERY_ARG_LIMIT, 1);
cursor = mContentResolver.query(contentUri, MEDIA_STORE_IMAGE, bundle,null);
} else {
// 查找
cursor = mContentResolver.query(contentUri, MEDIA_STORE_IMAGE, null, null, QUERY_ORDER_SQL);
}
findImagePathByCursor(cursor);
} catch (Exception e) {
if (e.getMessage() != null) {
Log.e(TAG, e.getMessage());
} else {
e.printStackTrace();
}
}finally {
if (cursor != null && !cursor.isClosed()){
cursor.close();
}
}
}
private void findImagePathByCursor(Cursor cursor) {
if (cursor == null) {
return;
}
if (!cursor.moveToFirst()){
Log.d(TAG,"Cannot find newest image file");
return;
}
// 获取 文件索引
int imageColumnIndexData = cursor.getColumnIndex(ImageColumns.DATA);
int imageCreateDateIndexData = cursor.getColumnIndex(ImageColumns.DATE_TAKEN);
int imageWidthColumnIndexData = cursor.getColumnIndex(ImageColumns.WIDTH);
String imagePath = cursor.getString(imageColumnIndexData);
int imageWidth = cursor.getInt(imageWidthColumnIndexData);
long imageCreateDate = cursor.getLong(imageCreateDateIndexData);
// 时间判断 判断截屏时间 与 截屏图片实际生成时间的差
if (imageCreateDate < mStartListenTime) {
return;
}
// 这里只判断width 长截屏无法判断
if (mPoint != null && mPoint.x != imageWidth){
return;
}
// path 为空
if (TextUtils.isEmpty(imagePath)){
return;
}
// 判断关键词
String lowerCasePath = imagePath.toLowerCase();
// 关键词比对
for (String keyword : KEYWORDS) {
if (lowerCasePath.contains(keyword)){
if (mCaptureCallback != null) {
mCaptureCallback.capture(imagePath);
}
break;
}
}
}
}
代码很简单,不过有个坑在于当我们采用以下的查询方法的时候,在编译版本30,Android 11机型下,会报一个异常。
private final static String QUERY_ORDER_SQL = ImageColumns.DATE_ADDED + " DESC LIMIT 1";
mContentResolver.query(contentUri, MEDIA_STORE_IMAGE, null, null, QUERY_ORDER_SQL);
SQL 异常.png
费了一番查找最终找到,若在Android 11 版本后进行共享数据的查询,需要使用ContentReslover#query()方法参数为Bundle的方法,查看官方文档,将查询条件使用Bundle组装并跨进程传输。详细问题解决方案
总结
截屏分享Android原生并没有提供相关的Api,让我们获取,但是解决办法还是有的,就是通过ContentObserver进行对内外存储文件的变动的监听,之后根据ContentResolver进行Query查询,并进行排序筛选,在进行二次一系列的条件筛选,最终找到我们那张截图的图片。
补充 2021/02/05
问题1
在应用到实际项目中时,发现当应用退出到后台时,用户截取图片的时候,会将非该应用的截图响应到自己的应用中,并触发分享,这导致分享不合乎逻辑。
解决办法在感知到文件系统发生变化时,判断一下当前应用是否处于前台即可。
/**
* 判断app是否在后台啊
*
* @return 0 在后台 1 在前台 2 不存在
*/
public static int isBackground(Context context) {
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
List appProcesses = activityManager.getRunningAppProcesses();
for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) {
if (appProcess.processName.equals(context.getPackageName())) {
if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_CACHED) {
return 2;
} else if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
return 1;
}
}
}
return 2;
}
问题2
在某些低端机型,比如红米6等,由于使用数据库查询cursor 比较慢,导致分享回调有延迟,用户可能跳转到其他的界面了,才展示弹窗,影响用户体验,因此这边做了一个等待延迟条件,判断当前时间与最终截图回调时间做对比,设定一个阈值拦截。
问题3
截屏黑名单,有些界面涉及到用户敏感信息,所以就不触发用户截屏。
@Override
protected void onCreate(Bundle savedInstanceState) {
// 禁掉截屏
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
super.onCreate(savedInstanceState);
}