严选 App 迎来了2.7.1版本,可爱的 PM 往 app 中加了个截图分享的需求,具体来讲就是——如果检测到用户在 app 中有截图行为,那么弹出一个分享提示框,提示用户去分享这个截图,这货大概长这么个样子:
本文主要从三个方面来讲:
截图分享实现分为两个部分,首先是截图事件的监听。安卓系统并没有提供 api 来监听系统截图事件,也没有什么广播可以用来监听这类事件,那要怎么解决呢?我们先来看下截图源码。
两个类都在目录
framework/base/package/SystemUI/src/com/android/systemui/screenshot 下
TakeScreenshotService.java
public class TakeScreenshotService extends Service {
private static final String TAG = "TakeScreenshotService";
private static GlobalScreenshot mScreenshot;
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case 1:
final Messenger callback = msg.replyTo;
if (mScreenshot == null) {
mScreenshot = new GlobalScreenshot(TakeScreenshotService.this);
}
mScreenshot.takeScreenshot(new Runnable() {
@Override public void run() {
Message reply = Message.obtain(null, 1);
try {
callback.send(reply);
} catch (RemoteException e) {
}
}
}, msg.arg1 > 0, msg.arg2 > 0);
}
}
};
@Override
public IBinder onBind(Intent intent) {
return new Messenger(mHandler).getBinder();
}
}
GlobalScreenshot.java
@Override
protected SaveImageInBackgroundData doInBackground(SaveImageInBackgroundData... params) {
...
// By default, AsyncTask sets the worker thread to have background thread priority, so bump
// it back up so that we save a little quicker.
Process.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND);
Context context = params[0].context;
Bitmap image = params[0].image;
Resources r = context.getResources();
try {
// Create screenshot directory if it doesn't exist
mScreenshotDir.mkdirs();
// media provider uses seconds for DATE_MODIFIED and DATE_ADDED, but milliseconds
// for DATE_TAKEN
long dateSeconds = mImageTime / 1000;
// Save
OutputStream out = new FileOutputStream(mImageFilePath);
image.compress(Bitmap.CompressFormat.PNG, 100, out);
out.flush();
out.close();
// Save the screenshot to the MediaStore
ContentValues values = new ContentValues();
ContentResolver resolver = context.getContentResolver();
values.put(MediaStore.Images.ImageColumns.DATA, mImageFilePath);
values.put(MediaStore.Images.ImageColumns.TITLE, mImageFileName);
values.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, mImageFileName);
values.put(MediaStore.Images.ImageColumns.DATE_TAKEN, mImageTime);
values.put(MediaStore.Images.ImageColumns.DATE_ADDED, dateSeconds);
values.put(MediaStore.Images.ImageColumns.DATE_MODIFIED, dateSeconds);
values.put(MediaStore.Images.ImageColumns.MIME_TYPE, "image/png");
values.put(MediaStore.Images.ImageColumns.WIDTH, mImageWidth);
values.put(MediaStore.Images.ImageColumns.HEIGHT, mImageHeight);
values.put(MediaStore.Images.ImageColumns.SIZE, new File(mImageFilePath).length());
Uri uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
// Create a share intent
String subjectDate = DateFormat.getDateTimeInstance().format(new Date(mImageTime));
String subject = String.format(SCREENSHOT_SHARE_SUBJECT_TEMPLATE, subjectDate);
Intent sharingIntent = new Intent(Intent.ACTION_SEND);
sharingIntent.setType("image/png");
sharingIntent.putExtra(Intent.EXTRA_STREAM, uri);
sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
// Create a share action for the notification
final PendingIntent callback = PendingIntent.getBroadcast(context, 0,
new Intent(context, GlobalScreenshot.TargetChosenReceiver.class)
.putExtra(GlobalScreenshot.CANCEL_ID, mNotificationId),
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_ONE_SHOT);
Intent chooserIntent = Intent.createChooser(sharingIntent, null,
callback.getIntentSender());
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK
| Intent.FLAG_ACTIVITY_NEW_TASK);
mNotificationBuilder.addAction(R.drawable.ic_screenshot_share,
r.getString(com.android.internal.R.string.share),
PendingIntent.getActivity(context, 0, chooserIntent,
PendingIntent.FLAG_CANCEL_CURRENT));
// Create a delete action for the notification
final PendingIntent deleteAction = PendingIntent.getBroadcast(context, 0,
new Intent(context, GlobalScreenshot.DeleteScreenshotReceiver.class)
.putExtra(GlobalScreenshot.CANCEL_ID, mNotificationId)
.putExtra(GlobalScreenshot.SCREENSHOT_URI_ID, uri.toString()),
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_ONE_SHOT);
mNotificationBuilder.addAction(R.drawable.ic_screenshot_delete,
r.getString(com.android.internal.R.string.delete), deleteAction);
params[0].imageUri = uri;
params[0].image = null;
params[0].result = 0;
} catch (Exception e) {
// IOException/UnsupportedOperationException may be thrown if external storage is not
// mounted
params[0].clearImage();
params[0].result = 1;
}
// Recycle the bitmap data
if (image != null) {
image.recycle();
}
return params[0];
}
从代码里可以看到,截图之后做的主要事情是:
那么看到这里,我们大致可以想到两种用来监听截图事件的方式
这两种方式在网上搜索,可以搜到许多相关的文章,例如 监听截图事件的三种方式,这篇文章里还介绍了监听截图组合按键,但是这个方法有个问题就是:无法监听到通知栏中提供的截图按键。
文章写到这里,监听的实现讲的差不多了,这个需求看上去其实很简单,但真正考虑各种情况的话,问题会很多。
首先要讲下上文提到的 ContentObserver 和 FileObserver 的问题,这两个都是监听文件的变化,那么假如说用户主动把图片文件添加到 screenshot 目录里,那么也会触发 onChange 回调。这里最优的解决方式是:
private boolean isScreenShotRunning() {
ActivityManager am = (ActivityManager) AppProfile.getContext().getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.RunningServiceInfo> rs = am.getRunningServices(200);
for (ActivityManager.RunningServiceInfo ar : rs) {
if (ar.process.equals("com.android.systemui:screenshot")) {
return true;
}
}
return false;
}
即,判断后台运行的Service里,是否有进程名为 com.android.systemui:screenshot 的进程,那么这个进程名怎么来的呢?查看路径 framework/base/package/SystemUI 下的 AndroidManifest.xml 文件,具体内容如下:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
package="com.android.systemui"
android:sharedUserId="android.uid.systemui"
coreApp="true">
<application
...
>
<service android:name=".screenshot.TakeScreenshotService"
android:process=":screenshot"
android:exported="false" />
</application>
</manifest>
可以看到,TakeScreenshotService 所运行的进程名为 com.android.systemui.screenshot
这个方法我在主流的 ROM 上经过测试,目前没有发现问题,但是这个方案最终没有用在严选 Android 上,主要担心的就是某些奇葩的国产 ROM 把进程名给改了,导致这个函数判断出错。
其次是截图文件获取的问题,从之前的系统源码里我们看到,GlobalScreenShot 截图之后,做的工作首先是保存图片,然后再去修改图片数据库,所以在某些没有大改的系统里,我们在 ContentObserver 的 onChange 回调里是可以获取到图片文件的。但是后来在魅族的系统上测试时,发现在 onChange 里 获取不到图片文件,大概原因猜测就是魅族把这部分的源码修改了,先通知数据库改变,再保存文件。
要解决这个问题,大概有两种办法:
我们只要监听 CLOSE_WRITE 即可,关键代码如下:
ContentObserver 相关
private void handleMediaRowData(String data, final long dateTaken) {
if (!AppProfile.isAppForeground()) {
LogUtil.d(TAG, "App in background. screenshot event ignore");
return;
}
if (!isTimeValid(dateTaken)) {
//时间不合格
LogUtil.d(TAG, "Screen Shot File is overdue");
return;
}
//时间合格,那么再判断下是否符合路径要求
if (!checkScreenShot(data)) {
LogUtil.d(TAG, "Not screenshot event");
return;
}
ScreenShotFileObserver.startWatching(new File(data).getParent() + File.separator, data, new IScreenShotFileListener() {
@Override
public void onFileAdded(String path) {
notifyScreenShotAdded(path,dateTaken);
}
});
}
FileObserver 相关
public class ScreenShotFileObserver extends FileObserver {
...
@Override
public void onEvent(int event, String path) {
if(event==CLOSE_WRITE && mListener!=null){
mListener.onFileAdded(mPath);
}
}
public static void startWatching(String parentPath,String targetPath, IScreenShotFileListener listener) {
...
}
}
按照剧本来说,这里的代码大概是没有问题的,然后放到魅族系统上测试了一下,只监听到了 CREATE 事件,所以这个方案被迫放弃。
关键代码如下:
private void handleMediaRowData(String data, final long dateTaken) {
...
long duration = 0;
long step = 100;
//设置最大等待时间为 500ms
while (!isFileAvailable(data) && duration <= 500) {
try {
duration += step;
Thread.sleep(step);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
...
}
最终的效果图
截图图片合成主要做的工作大概就是图片的大小控制了。假如我们把全质量的截图读取到内存中,那么对于小屏幕手机来说,可能影响不大,但是对于配有2k屏的手机来说,这就是一场灾难了。并且有的ROM是带有底部导航栏的,我们需要在合成图片去掉这个导航栏。
下面上关键代码:
public static Bitmap concatBitmap(String filePath, Bitmap bitmap) {
if (bitmap == null) {
return null;
}
int navHeight = ScreenUtil.getHeightWithNav() - ScreenUtil.getHeightWithoutNav();
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
options.inPreferredConfig = Bitmap.Config.RGB_565;
BitmapFactory.decodeFile(filePath, options);
int width = options.outWidth;
int height = options.outHeight - navHeight;
int max = 1024 * 1024;
int sampleSize = 1;
while (width / sampleSize * height / sampleSize > max) {
sampleSize *= 2;
}
options.inSampleSize = sampleSize;
options.inJustDecodeBounds = false;
Bitmap srcBmp = BitmapFactory.decodeFile(filePath, options);
//先计算bitmap的宽高,因为bitmap的宽度和屏幕宽度是不一样的,需要按比例拉伸
double ratio = 1.0 * bitmap.getWidth() / srcBmp.getWidth();
int additionalHeight = (int) (bitmap.getHeight() / ratio);
Bitmap scaledBmp = Bitmap.createScaledBitmap(bitmap, srcBmp.getWidth(), additionalHeight, false);
//到这里图片拉伸完毕
//这里开始拼接,画到Canvas上
Bitmap result = Bitmap.createBitmap(srcBmp.getWidth(), srcBmp.getHeight() - navHeight / sampleSize + additionalHeight, Bitmap.Config.RGB_565);
Canvas canvas = new Canvas();
canvas.setBitmap(result);
canvas.drawBitmap(srcBmp, 0, 0, null);
//这里需要做个判断,因为一些系统是有导航栏的,所以截图时有导航栏,这里需要把导航栏遮住
//计算出导航栏高度,然后draw时往上偏移一段距离
double navRatio = 1.0 * ScreenUtil.getDisplayWidth() / srcBmp.getWidth();
canvas.drawBitmap(scaledBmp, 0, srcBmp.getHeight() - (int) (navHeight / navRatio), null);
bitmap.recycle();
return result;
}
导航栏高度计算:
/**
* 获取屏幕高度,不包括navigation
*/
public static int getHeightWithNav(Context context) {
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Display d = windowManager.getDefaultDisplay();
DisplayMetrics realDisplayMetrics = new DisplayMetrics();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
d.getRealMetrics(realDisplayMetrics);
} else {
try {
Method method = d.getClass().getDeclaredMethod("getRealMetrics");
method.setAccessible(true);
method.invoke(d, realDisplayMetrics);
} catch (NoSuchMethodException e) {
} catch (InvocationTargetException e) {
} catch (IllegalAccessException e) {
}
}
return realDisplayMetrics.heightPixels;
}
/**
* 获取屏幕高度,包括navigation
*
* @return
*/
public static int getHeightWithoutNav(Context context) {
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Display d = windowManager.getDefaultDisplay();
DisplayMetrics displayMetrics = new DisplayMetrics();
d.getMetrics(displayMetrics);
return displayMetrics.heightPixels;
}
至此本文关于截图分享相关的内容讲述完毕,要是有什么讲解出错的地方,欢迎大家指出。关于截屏的检测,如果大家有什么更好的办法,还望在评论里不吝赐教!
本文来自网易实践者社区,经作者薛贤俊授权发布。