可视化埋点优缺点
-
优点:
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” 里面的提示,我们可能可以通过反射获取WindowManagerGlobal
的 mViews对象中存储的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");
``