独立窗口控件可视化埋点方案调研

可视化埋点优缺点

  • 优点:

      1、 跳过技术部署,集成简单,小白也能很快上手。
    
      2 、能够监测产品前端用户交互数据,数据量相对精确。
    
  • 缺点:

      1、 所采集的数据,属于前端浅层数据,而侧重属性的数据带不回来。
      2 、动态效果可能会遗漏。
    

可视化埋点方式一般分为2种

  • 使用后台界面配置需要埋点的位置,app下载配置文件,将需要埋点的事件上传。

  • app把所有事件上传,后台自己选择需要埋点的点。

竞品比较

[图片上传中...(image.png-d15720-1604050103114-0)]

我们都知道Android 的window类型有3种

  • 系统窗口,不需要对应任何Activity。

  • 应用窗口,对应于一个Activity。加载Activity由AMS完成,创建一个应用窗口只能在Activity内部完成。

  • 子窗口,子window不能单独存在,必须依附于特定的父window之中。

我们目前遇到的问题:就是“子窗口”不能可视化圈选!!!

我们都知道,通过WindowManager.addView接口就可以申请创建一个新的Window并添加一个View树,弹出菜单、浮动窗口等自定义的窗口都是通过这个接口显示出来:

//获得WindowManager服务

WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

//设置Window参数

WindowManager.LayoutParams params = new WindowManager.LayoutParams();

params.setTitle("标题");

//创建View树

View rootView = getLayoutInflater().inflate(R.layout.float_layout, null);

//创建Window并关联View树

windowManager.addView(rootView, params);

WindowManager其实是一个WindowManagerImpl类的实例:

public final class WindowManagerImpl implements WindowManager {

private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();

private final Context mContext;

private final Window mParentWindow;

...

@Override public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {

applyDefaultToken(params); mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);

} }

WindowManagerImpl.addView()实现会调用WindowManagerGlobal.addView(),实际的请求创建Window的逻辑也在这个类里实现:

public final class WindowManagerGlobal {

private final ArrayList<View> mViews = new ArrayList<View>();

private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>();

private final ArrayList<WindowManager.LayoutParams> mParams = new ArrayList<WindowManager.LayoutParams>();

...

public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow) {

...

ViewRootImpl root; synchronized (mLock) {

...

root = new ViewRootImpl(view.getContext(), display);

mViews.add(view); mRoots.add(root);

mParams.add(params);

try { root.setView(view, params, panelParentView);

} catch (RuntimeException e) {

throw e;

} } } }

WindowManagerGlobal 类里有这三个ArrayList列表:

  • mViews 保存着每个Window的根View
  • mRoots 保存每个根View对应的ViewRootImpl
  • mParams 保存的是每个Window的窗口配置参数WindowManager.LayoutParams

WindowManagerGlobal.addView()的实现里会为每一个Window创建一个ViewRootImpl实例,以及窗口配置参数WindowManager.LayoutParams实例,并将传递过来的根View实例保存在上面提到的三个列表里。addView()实现里最后调用的root.setView()就是通过ViewRootImpl将Window与View树关联起来,同时ViewRootImpl还有控制View树的刷新、显示以及输入事件分发的作用。

下面以Dialog为例进行说明

Dialog是一种承载Window的容器,而Window的唯一实现便是PhoneWindow,Dialog的setContentView就是将布局文件的id传给PhoneWindow,PhoneWindow通过该布局id解析然后创建一个DecorView,这是一个继承FrameLayout的ViewGroup,每个Window都有一个WindowManagerImpl,这里所说的是每个非子window类型的window,因为子window是依附于父window,父子共用一个WindowManagerImpl,普通的Dialog的WindowManagerImpl与Activity是共用的。

Dialog的Window创建过程:

  • 创建WindowDialog。和Activity类似,同样是通过PolicyManager.makeNewWindow() 来实现。

  • 初始化DecorView并将Dialog的视图添加到DecorView中去。和Activity类似,同样是通过Window.setContentView() 来实现。

  • 将DecorView添加到Window中显示。和Activity一样,都是在自身要出现在前台时才会将添加Window。

     Dialog.show() 方法:完成DecorView的显示。 
    
     WindowManager.remoteViewImmediate() 方法:当Dialog被dismiss时移除DecorView。
    

可不可以通过父窗口获取到子窗口对象呢?

通过使用 Layout Inspector 可以查看到整个显示的布局逻辑:

[图片上传失败...(image-f3bb86-1604050221655)]

从上图可以看到:页面布局 和 Dialog 两个视图在布局中的层次结构,也可以看出它们分别属于两个DecorView。可以猜想应该不能通过父窗口获取到子窗口对象,那我们怎么获取”独立窗口“的rootview树对象呢?

Layout Inspector是和 Android studio进行数据交互的,可以很好的获取数据,我们的项目目前只是在 Application.ActivityLifecycleCallbacks 接口回调中拿到 Activity对象数据,可以通过Activity对象数据来获取页面视图层次结构,那如何获取Dialog视图对象呢?好像不好拿到这个Dialog对象。

经过思考和参考文章《MixPanel -Android端埋点技术研究》(链接:https://www.imooc.com/article/38108) 和 “神策开源SDK” 里面的提示,我们可能可以通过反射获取WindowManagerGlobalmViews对象中存储的Window的根View的集合,在获取到集合后,遍历集合获取想要的视图对象。

通过上面对技术路线的探索和求证,接下来的重点应该就是如何反射获取这个WindowManagerGlobal的 mViews对象的集合了。神策SDK中的ViewSnapshot.java里面一段代码,

mMainThreadHandler = new Handler(Looper.getMainLooper());
mRootViewFinder = new RootViewFinder();
...
final FutureTask<List<RootViewInfo>> infoFuture =new FutureTask<List<RootViewInfo>>(mRootViewFinder);
mMainThreadHandler.post(infoFuture);
...
infoList = infoFuture.get(1, TimeUnit.SECONDS);

我仔细查看了一下源码,是神策的小伙伴对视图反射的具体实现。

写了一个Demo进行断点测试,可以发现Demo中返回4个Decorview对象(如下图):

[图片上传失败...(image-af91e-1604049927397)]

上面views集合中第4个DecorView的rootView视图转成Bitmap视图可以看到就是Dialog弹框:

[图片上传失败...(image-f561f9-1604049927397)]

说明我们是可以通过反射操作拿到Dialog视图的。我们都知道,Window是分层的,每个window都有对应的z-ordered,层级大的会覆盖层级小的Window上面。在三大类Window中,应用Window的层级范围是 199,子Window的层级范围是10001999,系统Window的层级范围是2000~2999,这些层级对应着 WindowManager.LayoutParams 的type参数。所以我们遍历views集合时可以通过这个属性来判断属于哪种类型的Window。

《Android开发艺术探索》第八章介绍WindowManger.LayoutParams的type参数的时候,有这样一句话:子Window不能单独存在,它需要附属在特定的父Window中,比如常见的一些Dialog就是一个子Window。看到这里的时候,我是理解成Dialog属于子Window的。但是获取Dialog的WindowManger.LayoutParams的type参数是2,发现Dialog 的类型是TYPE_APPLICATION,属于应用窗口类型。所以大家使用时不要弄错了。

同样我们也可以通过反射拿到Dialog的Window的窗口配置参数WindowManager.LayoutParams如下:

[图片上传失败...(image-9f162e-1604049927397)]

拿到了很多参数,但是没有找参数可以确定view在屏幕中具体位置!那我们该如何生成参数数据给后台,让后台的同学设置圈选显示区域呢?

断点测试中,可以看到view自有属性参数可以确定view的大小等信息也可以获取在屏幕中偏移大小,解析viewTree 成json文件传递给后台应该可以满足显示的需求

[图片上传失败...(image-9a4821-1604049927397)]

[图片上传失败...(image-164c8e-1604049927397)]

目前实现的思路

  • 思路1:①获取当前页面"主窗口view"的viewTree结构和“独立窗口控件”的viewTree结构(或者获取整个屏幕截图的bitmap,传递给后台)

                   ②“主窗口view”和“独立窗口控件”合成一个bitmap视图传递给后台
    
                   ③解析“独立窗口控件”viewTree,结合获取在屏幕上位置,生成json数据传递给后台
    
                   ④获取当前页面点击控件的viewTree路径,生成唯一Id(id的产生规则: viewId+Activity+ Tag )
    
  • 思路2:

                获取当前页面点击控件的viewTree路径,生成唯一Id(id的产生规则:  viewId+Activity+ 点击控件view路径)。这个点击控件的view路径获取还没找到实现方式,目前只能作为一个想法先探索一下
    

思路1实现细化:

将“Dialog视图”和“主窗口视图”合成一个视图

bitmap = mergeViewLayers(views, info);
Bitmap mergeViewLayers(View[] views, RootViewInfo info) {
    int width = info.rootView.getWidth();
    int height = info.rootView.getHeight();
    if (width == 0 || height == 0) {
        return null;
    }
    Bitmap fullScreenBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
    info.rootView.draw(new Canvas(fullScreenBitmap));
    SoftWareCanvas canvas = new SoftWareCanvas(fullScreenBitmap);
    int[] windowOffset = new int[2];
    boolean skipOther;
    if (ViewUtil.getMainWindowCount(views) > 1) {
        skipOther = true;
    } else {
        skipOther = false;
    }
    WindowHelper.init();
    ViewUtil.invalidateLayerTypeView(views);
    for (View view : views) {
        if (!(view.getVisibility() != View.VISIBLE || view.getWidth() == 0 || view.getHeight() == 0 || !ViewUtil.isWindowNeedTraverse(view, WindowHelper.getWindowPrefix(view), skipOther))) {
            canvas.save();
            if (!WindowHelper.isMainWindow(view)) {
                view.getLocationOnScreen(windowOffset);
                canvas.translate((float) windowOffset[0], (float) windowOffset[1]);
                if (WindowHelper.isDialogOrPopupWindow(view)) {
                    Paint mMaskPaint = new Paint();
                    mMaskPaint.setColor(0xA0000000);
                    canvas.drawRect(-(float) windowOffset[0], -(float) windowOffset[1], canvas.getWidth(), canvas.getHeight(), mMaskPaint);
                }
            }
            view.draw(canvas);
            canvas.restore();
            canvas.destroy();
        }
    }
    return fullScreenBitmap;
}

如下图,我们可看到合成的视图就是我们需要的bitmap:</pre>

[图片上传失败...(image-573f7f-1604049927397)]

对于“获取整个屏幕截图的bitmap”这个逻辑的实现,本来想直接调用如下代码实现:

getWindow().getDecorView().setDrawingCacheEnabled(true);
Bitmap bmp = getWindow().getDecorView().getDrawingCache();

但是经过测试发现,这个方法也只能获取Dialog下面主窗口的视图的 Bitmap,应该是由于Dialog和“主窗口的视图” 在不同的Window,我们通过getWindow().getDecorView()拿到的只是“主视图窗口”的层次结构。系统自带的截图能够截取到有Dialog的视图,但是有个弊端,就是系统截屏的操作有个动画过程,而且无法获取到存储的路径。

我们查看Android源码中TakeScreenshotService服务(源码地址:https://www.androidos.net.cn/android/9.0.0_r8/xref/frameworks/base/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotService.java):

[图片上传失败...(image-67c16d-1604049927396)]

可以从截图中看到,它是先创建了一个GlobalScreenshot类(源码地址:https://www.androidos.net.cn/android/9.0.0_r8/xref/frameworks/base/packages/SystemUI/src/com/android/systemui/screenshot/GlobalScreenshot.java),然后再调用了takeScreenshot方法,那么我们继续查看GlobalScreenshot中takeScreenshot方法:

[图片上传失败...(image-e39a20-1604049927396)]

可以看到,截屏其实只要调用SurfaceControl中的screenshot方法就可以获取到屏幕的图像数据了。但SurfaceControl是隐藏类,无法直接被我们导入使用的,此时就需要用到java的反射机制,通过反射去调用该隐藏类的截图方法,但是使用反射机制,如果系统的API或者方法发生改变将导致无法使用。很不幸,SurfaceControl中的screenshot方法在不同的Android版本中有些出入(比如Android 8.0中的源码和9.0就不同),所以调用就要判断版本,不然可能会出现无法使用的问题。

写了一个调用的方法如下:

 public Bitmap screenshot(int width, int height, int rotation) throws Exception
    {
        String surfaceClassName;
        if (Build.VERSION.SDK_INT <= 17) {
            surfaceClassName = "android.view.Surface";
        } else {
            surfaceClassName = "android.view.SurfaceControl";
        }

        Class localClass = Class.forName(surfaceClassName);
        Class[] arrayOfClass;
        if (Build.VERSION.SDK_INT < 28) {
            arrayOfClass = new Class[2];
            arrayOfClass[0] = Integer.TYPE;
            arrayOfClass[1] = Integer.TYPE;
        } else {
            arrayOfClass = new Class[4];
            arrayOfClass[0] = Rect.class;
            arrayOfClass[1] = Integer.TYPE;
            arrayOfClass[2] = Integer.TYPE;
            arrayOfClass[3] = Integer.TYPE;
        }

        Method localMethod = localClass.getDeclaredMethod("screenshot", arrayOfClass);
        Object[] arrayOfObject;
        if (Build.VERSION.SDK_INT < 28) {
            arrayOfObject = new Object[2];
            arrayOfObject[0] = Integer.valueOf(width);
            arrayOfObject[1] = Integer.valueOf(height);
        } else {
            arrayOfObject = new Object[4];
            arrayOfObject[0] =  new Rect();
            arrayOfObject[1] = Integer.valueOf(height);
            arrayOfObject[2] = Integer.valueOf(width);
            arrayOfObject[3] =0;
        }

        Bitmap b = (Bitmap)localMethod.invoke(null, arrayOfObject);
//        Bitmap b = (Bitmap) Class.forName(surfaceClassName).getDeclaredMethod("screenshot", new Class[]{Rect.class,int.class,int.class,int.class}).invoke(null, new Object[]{new Rect(),width, height,0});
//        Bitmap b = (Bitmap) Class.forName(surfaceClassName).getDeclaredMethod("screenshot", new Class[]{int.class,int.class}).invoke(null, new Object[]{ height,width});
        if (b == null) {
            System.out.println("screenshot fail");
            return null;
        }

        if (rotation == 0) {
            return b;
        }

        Matrix m = new Matrix();
        if (rotation == 1) {
            m.postRotate(-90.0f);
        } else if (rotation == 2) {
            m.postRotate(-180.0f);
        } else if (rotation == 3) {
            m.postRotate(-270.0f);
        }

        return Bitmap.createBitmap(b, 0, 0, width, height, m, false);
    }

但是发现调用,返回的Bitmap一直是null,尝试了很久都不能获取Bitmap对象,我就搜索了一下看看有没有其他小伙伴遇到这样的问题,发现他们也遇到的相同的问题,给的解释是“非系统应用是不能用的,即使调用了也会返回 null ”,那么这个方法是一个对于系统应用非常合适的截图方案,如果我们的应用都有系统授权的应该是可以使用的。要不然就不用上面的方法,直接调用系统的截图方法获取一个截图

对于生成唯一Id:

①对于页面每个按钮只会弹出独有的Dialog的情况,可以通过 viewId+Activity 来确定

②对于多个按钮点击都弹出同一个Dialog的情况,目前认为可以让业务在点击事件处设置Dialog不同的Tag值来确定,对业务有一定的侵入性。

AlertDialog.Builder customizeDialog =
                new AlertDialog.Builder(DialogActivity.this);
        final View dialogView = LayoutInflater.from(DialogActivity.this)
                .inflate(R.layout.dialog_custom, null);
        customizeDialog.setTitle("我是一个自定义Dialog");
        customizeDialog.setView(dialogView);
        AlertDialog dialog= customizeDialog.create();
        View decorView=  dialog.getWindow().getDecorView();
        decorView.setTag("ceshi");
//        customizeDialog.show();
        dialog.show();

这里要注意:如果设置Tag作为识别标志,在AlertDialog中要使用dialog.show(),不要使用customizeDialog.show(),不然你是获取不到Tag的,因为AlertDialog.builder对象底层实现是要重新创建Dialog对象的,底层代码如下:

public AlertDialog show() {
    final AlertDialog dialog = create();
    dialog.show();
    return dialog;
}

对于Dialog Fragment类型的Dialog也可以设置Tag:

Fragment_Dialog fragment_dialog=new Fragment_Dialog();
FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
fragment_dialog.show(ft,"df");
getSupportFragmentManager().executePendingTransactions();
Dialog dialog=fragment_dialog.getDialog();
dialog.getWindow().getDecorView().setTag("tag");

``
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容