严选Android-截图分享功能实现与踩坑分享

达芬奇密码2018-07-03 14:37

前言

严选 App 迎来了2.7.1版本,可爱的 PM 往 app 中加了个截图分享的需求,具体来讲就是——如果检测到用户在 app 中有截图行为,那么弹出一个分享提示框,提示用户去分享这个截图,这货大概长这么个样子:

本文主要从三个方面来讲:

  • 截图检测的具体实现
  • 截图检测踩到的坑
  • 截图合成

1、截图分享实现

截图分享实现分为两个部分,首先是截图事件的监听。安卓系统并没有提供 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];
    }

从代码里可以看到,截图之后做的主要事情是:

  • 保存截屏 Bitmap 到本地文件
  • 把图片记录插入到 ContentProvider 中
  • 发送一个截图相关的 Notification

那么看到这里,我们大致可以想到两种用来监听截图事件的方式

  • 监听 ContentProvider 数据改变
  • 监听截图文件目录数据改变

这两种方式在网上搜索,可以搜到许多相关的文章,例如 监听截图事件的三种方式,这篇文章里还介绍了监听截图组合按键,但是这个方法有个问题就是:无法监听到通知栏中提供的截图按键。

文章写到这里,监听的实现讲的差不多了,这个需求看上去其实很简单,但真正考虑各种情况的话,问题会很多。

2、截图检测遇到的问题

首先要讲下上文提到的 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 里 获取不到图片文件,大概原因猜测就是魅族把这部分的源码修改了,先通知数据库改变,再保存文件。

要解决这个问题,大概有两种办法:

1. FileObserver 监听文件写完成事件


我们只要监听 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 事件,所以这个方案被迫放弃。

2.轮询+延迟

关键代码如下:

    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();
            }
        }
        ...
    }

3、图片合成

最终的效果图

截图图片合成主要做的工作大概就是图片的大小控制了。假如我们把全质量的截图读取到内存中,那么对于小屏幕手机来说,可能影响不大,但是对于配有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;
    }

总结

至此本文关于截图分享相关的内容讲述完毕,要是有什么讲解出错的地方,欢迎大家指出。关于截屏的检测,如果大家有什么更好的办法,还望在评论里不吝赐教!

本文来自网易实践者社区,经作者薛贤俊授权发布。