DoraemonKit是滴滴开源的研发助手组件,目前支持iOS和Android两个平台。经过接入DoraemonKit组件,能够方便支持以下所示的多种调试工具:java
本文是DoraemonKit之Android版本技术实现系列文章的第一篇,主要介绍各个视觉工具的技术实现细节。android
取色器工具能够经过颜色吸管获取屏幕任意位置的像素值,因此实现的关键就是如何获取像素点。获取像素点的第一步是获取屏幕截图,获取屏幕截图在Android平台主要有如下几种方式:git
对比三种实现方式,方式一只能获取当前Window内DocorView的内容,不能获取状态栏或者脱离应用自己,且开启DrawingCache会增长应用内存占用;方式二中FrameBuffer不能直接读取,须要得到系统Root权限,且兼容性差;方式三可脱离应用自己获取应用外截屏,截图取自系统Binder不占用应用内存,只需请求录屏权限。github
getDrawingCache函数 | 读取系统FrameBuffer | MediaProjectionManager类 | |
---|---|---|---|
实现复杂度 | 简单 | 复杂 | 较简单 |
须要权限 | 无 | Root权限 | 录屏权限 |
适用性 | 只能截取应用内 | 应用内外都支持 | 应用内外都支持 |
性能影响 | 大 | 小 | 小 |
经过对比,DoraemonKit选择方式三做为取色器的实现方案。canvas
private boolean requestCaptureScreen() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
return false;
}
MediaProjectionManager mediaProjectionManager = (MediaProjectionManager) getContext().getSystemService(Context.MEDIA_PROJECTION_SERVICE);
if (mediaProjectionManager == null) {
return false;
}
startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), RequestCode.CAPTURE_SCREEN);
return true;
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == RequestCode.CAPTURE_SCREEN && resultCode == Activity.RESULT_OK) {
showColorPicker(data);
...
} else {
...
}
}
复制代码
经过createScreenCaptureIntent()方法能够获取请求系统录屏权限的Intent,而后调用startActivityForResult,系统会自动弹出权限授予弹窗。若是授予权限则在onActivityResult中获得系统回调成功,且返回录屏的resultData。api
public void init(Context context, Bundle bundle) {
mMediaProjectionManager = (MediaProjectionManager) context.getSystemService(Context.MEDIA_PROJECTION_SERVICE);
if (mMediaProjectionManager != null) {
Intent intent = new Intent();
intent.putExtras(bundle);
mMediaProjection = mMediaProjectionManager.getMediaProjection(Activity.RESULT_OK, intent);
}
int width = UIUtils.getWidthPixels(context);
int height = UIUtils.getRealHeightPixels(context);
int dpi = UIUtils.getDensityDpi(context);
mImageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
mMediaProjection.createVirtualDisplay("ScreenCapture",
width, height, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
mImageReader.getSurface(), null, null);
}
复制代码
经过onActivityResult中返回的resultData就能够建立系统录屏服务MediaProjection,而后建立ImageReader并与MediaProjection的Surface进行绑定,以后就能够经过ImageReader获取屏幕截图了。bash
public void capture() {
if (isCapturing) {
return;
}
isCapturing = true;
Image image = mImageReader.acquireLatestImage();
if (image == null) {
return;
}
int width = image.getWidth();
int height = image.getHeight();
Image.Plane[] planes = image.getPlanes();
ByteBuffer buffer = planes[0].getBuffer();
int pixelStride = planes[0].getPixelStride();
int rowStride = planes[0].getRowStride();
int rowPaddingStride = rowStride - pixelStride * width;
int rowPadding = rowPaddingStride / pixelStride;
Bitmap recordBitmap = Bitmap.createBitmap(width + rowPadding , height, Bitmap.Config.ARGB_8888);
recordBitmap.copyPixelsFromBuffer(buffer);
mBitmap = Bitmap.createBitmap(recordBitmap, 0, 0, width, height);
image.close();
isCapturing = false;
}
复制代码
调用ImageReader的acquireLatestImage能够获取当前屏幕的截图,而后将Image对象转为Bitmap对象就能够方便地进行显示和获取像素点了。app
public static int getPixel(Bitmap bitmap, int x, int y) {
if (bitmap == null) {
return -1;
}
if (x < 0 || x > bitmap.getWidth()) {
return -1;
}
if (y < 0 || y > bitmap.getHeight()) {
return -1;
}
return bitmap.getPixel(x, y);
}
复制代码
根据浮标的坐标在Bitmap上就能够很方便地获取像素点的值了。ide
控件检查的功能是经过浮标选取目标View,而后获取目标View的相关信息,因此如何获取这个View的引用就是实现这一功能的关键。函数
app.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityResumed(Activity activity) {
...
for (ActivityLifecycleListener listener : sListeners) {
listener.onActivityResumed(activity);
}
}
}
复制代码
经过注册监听能够在Activity进入Resumed状态时得到通知,这样在浮标移动时DoraemonKit就能够持有最前台的Activity。
private View traverseViews(View view, int x, int y) {
int[] location = new int[2];
view.getLocationInWindow(location);
int left = location[0];
int top = location[1];
int right = left + view.getWidth();
int bottom = top + view.getHeight();
if (view instanceof ViewGroup) {
int childCount = ((ViewGroup) view).getChildCount();
if (childCount != 0) {
for (int index = childCount - 1; index >= 0; index--) {
View v = traverseViews(((ViewGroup) view).getChildAt(index), x, y);
if (v != null) {
return v;
}
}
}
if (left < x && x < right && top < y && y < bottom) {
return view;
} else {
return null;
}
} else {
LogHelper.d(TAG, "class: " + view.getClass() + ", left: " + left
+ ", right: " + right + ", top: " + top + ", bottom: " + bottom);
if (left < x && x < right && top < y && y < bottom) {
return view;
} else {
return null;
}
}
}
复制代码
由于View是以Tree的结构组织的,因此经过遍历当前Activity的ViewTree就能够获取到目标View。以DocorView做为根,递归调用同时判断浮标坐标是否在View的范围便可以获得目标View。由于View可能存在覆盖关系,因此须要使用深度优先遍历才能得到最顶端的View。
对齐标尺的实现比较简单,只须要根据浮标坐标绘制水平标尺线和竖直标尺线便可,实现效果以下图。
DoraemonKit最开始实现布局边界,是经过遍历ViewTree在悬浮Window上绘制边框线的方式,但这种方式有一个问题就是若是Activity包含多个层级的时候,全部层级View的边框都会被绘制在最顶层,致使显示十分混乱,在复杂界面上基本是不可用的。
在调研了几种方式以后,DoraemonKit使用了替换View的Background的方式。View的Background是Drawable类型的,而LayerDrawable这种Drawable是能够包含一组Drawable的,因此取出View的原始Background后与绘制边框的Drawable放进同一个LayerDrawable中,就能够实现带边框的背景。
private void traverseChild(View view) {
if (view instanceof ViewGroup) {
replaceDrawable(view);
int childCount = ((ViewGroup) view).getChildCount();
if (childCount != 0) {
for (int index = 0; index < childCount; index++) {
traverseChild(((ViewGroup) view).getChildAt(index));
}
}
} else {
replaceDrawable(view);
}
}
private void replaceDrawable(View view) {
LayerDrawable newDrawable;
if (view.getBackground() != null) {
Drawable oldDrawable = view.getBackground();
if (oldDrawable instanceof LayerDrawable) {
for (int i = 0; i < ((LayerDrawable) oldDrawable).getNumberOfLayers(); i++) {
if (((LayerDrawable) oldDrawable).getDrawable(i) instanceof ViewBorderDrawable) {
// already replace
return;
}
}
newDrawable = new LayerDrawable(new Drawable[] {
oldDrawable,
new ViewBorderDrawable(view)
});
} else {
newDrawable = new LayerDrawable(new Drawable[] {
oldDrawable,
new ViewBorderDrawable(view)
});
}
} else {
newDrawable = new LayerDrawable(new Drawable[] {
new ViewBorderDrawable(view)
});
}
view.setBackground(newDrawable);
}
复制代码
这种方式的好处就是实现简单,且兼容性很好,不会出现显示异常,包括多个层级的覆盖也能正常显示,能实时地伴随组件隐藏和显示,但也存在必定的侵入性,会对View的绘制形成必定的开销。
布局层级功能能够很方便地查看当前页面的Layout层级,是一个3D可视化的效果,能够多个角度旋转查看,这个功能是依赖jakewharton的scalpel项目实现的,这个项目的核心只有一个源码文件ScalpelFrameLayout,经过把但愿查看层级的页面根View加到这个Layout中就能够实现布局层级功能。这个Layout的实现原理是重写了Layout的draw方法,经过Camera进行了3D变换,从新进行了排布绘制。
@Override public void draw(@SuppressWarnings("NullableProblems") Canvas canvas) {
if (!enabled) {
super.draw(canvas);
return;
}
getLocationInWindow(location);
float x = location[0];
float y = location[1];
int saveCount = canvas.save();
float cx = getWidth() / 2f;
float cy = getHeight() / 2f;
camera.save();
camera.rotate(rotationX, rotationY, 0);
camera.getMatrix(matrix);
camera.restore();
matrix.preTranslate(-cx, -cy);
matrix.postTranslate(cx, cy);
canvas.concat(matrix);
canvas.scale(zoom, zoom, cx, cy);
if (!layeredViewQueue.isEmpty()) {
throw new AssertionError("View queue is not empty.");
}
// We don't want to be rendered so seed the queue with our children.
for (int i = 0, count = getChildCount(); i < count; i++) {
LayeredView layeredView = layeredViewPool.obtain();
layeredView.set(getChildAt(i), 0);
layeredViewQueue.add(layeredView);
}
while (!layeredViewQueue.isEmpty()) {
LayeredView layeredView = layeredViewQueue.removeFirst();
View view = layeredView.view;
int layer = layeredView.layer;
// Restore the object to the pool for use later.
layeredView.clear();
layeredViewPool.restore(layeredView);
// Hide any visible children.
if (view instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) view;
visibilities.clear();
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
View child = viewGroup.getChildAt(i);
//noinspection ConstantConditions
if (child.getVisibility() == VISIBLE) {
visibilities.set(i);
child.setVisibility(INVISIBLE);
}
}
}
int viewSaveCount = canvas.save();
// Scale the layer index translation by the rotation amount.
float translateShowX = rotationY / ROTATION_MAX;
float translateShowY = rotationX / ROTATION_MAX;
float tx = layer * spacing * density * translateShowX;
float ty = layer * spacing * density * translateShowY;
canvas.translate(tx, -ty);
view.getLocationInWindow(location);
canvas.translate(location[0] - x, location[1] - y);
viewBoundsRect.set(0, 0, view.getWidth(), view.getHeight());
canvas.drawRect(viewBoundsRect, viewBorderPaint);
if (drawViews) {
if (!(view instanceof SurfaceView)) {
view.draw(canvas);
}
}
if (drawIds) {
int id = view.getId();
if (id != NO_ID) {
canvas.drawText(nameForId(id), textOffset, textSize, viewBorderPaint);
}
}
canvas.restoreToCount(viewSaveCount);
// Restore any hidden children and queue them for later drawing.
if (view instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) view;
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
if (visibilities.get(i)) {
View child = viewGroup.getChildAt(i);
//noinspection ConstantConditions
child.setVisibility(VISIBLE);
LayeredView childLayeredView = layeredViewPool.obtain();
childLayeredView.set(child, layer + 1);
layeredViewQueue.add(childLayeredView);
}
}
}
}
canvas.restoreToCount(saveCount);
}
复制代码
布局层级在添加SurfaceView等特殊绘制的View时可能出现绘制问题,出现黑白屏闪烁问题,须要屏蔽这些特殊View的绘制。
取色器组件的实现主要经过系统录屏api,从截图Bitmap中取得像素点。
控件检查功能经过遍历ViewTree实现,须要注册全局Activity的生命周期监听。
对齐标尺功能直接经过浮标的屏幕坐标绘制水平和垂直标尺。
布局边界功能经过替换View的Background实现,由包含原始Background的LayerDrawable替换原有Background。
布局层级主要是使用开源项目scalpel实现,对原有ViewTree进行3D变换,从新进行绘制。
经过这篇文章主要是但愿你们可以对DoraemonKit视觉工具的技术实现有一个了解,若是有好的想法也能够参与到DoraemonKit开源项目的建设中来,在项目页面提交Issues或者提交Pull Requests,相信DoraemonKit项目在你们的努力下会愈来愈完善。
DoraemonKit项目地址:github.com/didi/Doraem…,以为不错的话就给项目点个star吧。