一、背景
APM 的全称叫做 Application Performance Monitor,属于应用性能监控部分。其中有一项比较重要的指标参数,叫做页面可视耗时,本文将介绍一套耗时检测方案。
二、方案
1、Activity页面加载时间
public class BaseActivity extends Activity {
public boolean isNeedLoadingTimeDetect() {
return true;
}
public int getLoadingTimeDetectType() {
return LoadingDetector.AREA_DETECT;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (isNeedLoadingTimeDetect()) {
LoadingDetector.getInstance().startWatch(this, getLoadingTimeDetectType());
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if(isNeedLoadingTimeDetect()) {
LoadingDetector.getInstance().destroyWatch(this, getLoadingTimeDetectType());
}
}
}
首先启动一个定时线程池服务ScheduledThreadPoolExecutor,每隔60ms去做屏幕decorview的检测,判断view是否是加载页面完成的状态,检测处理是在线程池,为了不影响UI线程的处理。
private void checkPageVisible(Activity activity, ViewTimeEntry entry, int detectType) {
if (entry == null) {
return;
}
ScheduledThreadPoolExecutor scheduledExecutorService = new ScheduledThreadPoolExecutor(2);
scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
boolean isVisible;
isVisible = detectType == AREA_DETECT ? checkAreaVisible(activity) : checkPixelsPageVisible(activity);
if (isVisible) {
scheduledExecutorService.shutdown();
if (entry != null) {
entry.setEndTime(System.currentTimeMillis());
showToastOnMainThread(activity, entry);
}
} else {
long currentTime = System.currentTimeMillis();
if (currentTime - entry.getStartTime() >= 10000) {
//兜底操作
scheduledExecutorService.shutdown();
entry.setEndTime(currentTime);
showToastOnMainThread(activity, entry);
}
}
}
}, 60, 60, TimeUnit.MILLISECONDS);
entry.setScheduledExecutorServic(scheduledExecutorService);
}
如果大于10s还没有加载完成页面,默认关闭线程池结束,作为一个兜底方案。
针对Activity页面加载时间检测算法,可分为两种方案,
public static final int AREA_DETECT = 1;
public static final int PIXELS_DETECT = 2;
一种是面积可见区域算法,一种是像素计算法。
a、面积可见区域算法
第一步,首先获取decorview下的所有子view,如果是viewgroup,就继续遍历所有的子view,知道得到所有view的集合。
第二步,计算所有子view的可视范围内的面积(如果超过了屏幕范围了则截取屏幕范围内的部分),去除以phone的屏幕面积,比值如果大于0.6f则当作页面加载完成。
其中需要注意,子view包含的有:textview, editview,imageview,自定义view等等。
针对这些view我们增加更细的规则:
- View 在全部或者部分在屏幕范围内,且 Visibility 必须为 View.VISIBLE
- 只针对 View 进行计算,ViewGroup 不在计算范围之列,且不是 ViewStub
- 如果是 ImageView,必须是加载图片完成后才能当作有效的view
- 如果是 TextView,必须要有文字
遍历所有子view如下:
public static List<View> getAllViews(Activity activity) {
List<View> viewList = new ArrayList<>();
ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
for (int i = 0; i < decorView.getChildCount(); i++) {
if (decorView.getChildAt(i).getVisibility() != View.VISIBLE) {
continue;
}
if (decorView.getChildAt(i) instanceof ViewGroup) {
viewList.addAll(getAllViews((ViewGroup) decorView.getChildAt(i)));
} else if (!(decorView.getChildAt(i) instanceof ViewStub)) {
viewList.add(decorView.getChildAt(i));
}
}
return viewList;
}
private static List<View> getAllViews(ViewGroup viewGroup) {
List<View> viewList = new ArrayList<>();
for (int i = 0; i < viewGroup.getChildCount(); i++) {
if (viewGroup.getChildAt(i).getVisibility() != View.VISIBLE) {
continue;
}
if (viewGroup.getChildAt(i) instanceof ViewGroup) {
viewList.addAll(getAllViews((ViewGroup) viewGroup.getChildAt(i)));
} else if (!(viewGroup.getChildAt(i) instanceof ViewStub)) {
viewList.add(viewGroup.getChildAt(i));
}
}
return viewList;
}
处理Imageview遇到了问题,如果图片没有加载出来,不能算是有效view的面积进行计算。解决方案:
public class ViewTag {
/**
* 当前状态是无效的View,但是仅仅表示当前状态,有可能变成有效,例如 ImageView
*/
public static final String APM_VIEW_VALID = "valid_view";
/**
* 当前状态是有效的View
*/
public static final String APM_VIEW_INVALID = "invalid_view";
/**
* 需要完全忽略的无用 View,这个 View 完全是计算的噪点,例如鱼骨图
*/
public static final String APM_VIEW_IGNORE = "ignore_view";
}
public static void loadImage(Context context, ImageView imageView, String url) {
imageView.setTag(ViewTag.APM_VIEW_INVALID);
Glide.with(context).load(url).apply(new RequestOptions().diskCacheStrategy(DiskCacheStrategy.NONE)).into(new SimpleTarget<Drawable>() {
@Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
imageView.setImageDrawable(resource);
imageView.setTag(ViewTag.APM_VIEW_VALID);
}
});
}
private static List<View> getAllViews(ViewGroup viewGroup) {
List<View> viewList = new ArrayList<>();
for (int i = 0; i < viewGroup.getChildCount(); i++) {
if (viewGroup.getChildAt(i) instanceof ViewGroup) {
viewList.addAll(getAllViews((ViewGroup) viewGroup.getChildAt(i)));
} else if (!(viewGroup.getChildAt(i) instanceof ViewStub)) {
viewList.add(viewGroup.getChildAt(i));
}
}
return viewList;
}
在图片加载框架中处理,如果图片加载完成,设置一个有效标志tag,遍历所有的子view时,如果是imageview且tag标志为有效view的标志才能算入到有效view的面积当中去。
如果是项目添加了骨架图,也是可以通过这种设置tag有效标志的方法来进行处理
计算面积的方法如下:
public static float calculateVisibleArea(Activity activity, List<View> viewList) {
int screenWidth = activity.getWindowManager().getDefaultDisplay().getWidth();
int screenHeight = activity.getWindowManager().getDefaultDisplay().getHeight();
float phoneArea = screenHeight * screenWidth;
float area = 0;
for (int i = 0; i < viewList.size(); i++) {
View view = viewList.get(i);
if (view instanceof ImageView && !ViewTag.APM_VIEW_VALID.equals(view.getTag())) {
continue;
}
if(view instanceof TextView || view instanceof ImageView) {
area += getViewArea(viewList.get(i), screenWidth, screenHeight);
}
}
return area / phoneArea;
}
private static float getViewArea(View view, int screenWidth, int screenHeight) {
int[] location = new int[2];
view.getLocationOnScreen(location);
return (getPos(location[0], true, screenWidth, screenHeight) - getPos(location[0] + view.getRight() - view.getLeft(), true, screenWidth, screenHeight))
* (getPos(location[1], false, screenWidth, screenHeight) - getPos(location[1] + view.getBottom() - view.getTop(), false, screenWidth, screenHeight));
}
private static int getPos(int pos, boolean isX, int screenWidth, int screenHeight) {
if (pos < 0) {
return 0;
}
if (isX) {
return Math.min(pos, screenWidth);
} else {
return Math.min(pos, screenHeight);
}
}
b、像素计算法
这种方法主要是针对非Activity页面,如H5页面,RN,Flutter页面。我们无法去获取子view的面积。我们可以通过这种像素计算的方法来处理。
public static boolean doCheckViewPixels(final View view) {
boolean isDrawFinish = false;
Bitmap bitmap;
try {
view.setDrawingCacheEnabled(true);
bitmap = view.getDrawingCache();
if (bitmap == null || bitmap.isRecycled()) {
return false;
}
try {
int[] pixelArray = collectPixel(bitmap);
//只有三种情况全是 true 的时候,才认为页面是加载成功了
if (planA(pixelArray) && planC(pixelArray) && planB(pixelArray)) {
isDrawFinish = true;
}
} catch (Exception e) {
isDrawFinish = false;
}
try {
view.setDrawingCacheEnabled(false);
} catch (Exception e) {
}
} catch (Exception e) {
return false;
}
return isDrawFinish;
}
首先去采集屏幕上的像素点,比如200个
private final static int SIZE = 200;
private final static int TOP_SIZE = 100;
private static double randomValue = 0;
/**
* 采集 bitmap 中的像素点
* 页面一分为2,上面40个点,下面20个点
*
* @return 采集到的像素数组
*/
private static int[] collectPixel(Bitmap b) {
int[] pixels = new int[SIZE];
int w = b.getWidth();
int h = b.getHeight();
int segmentLength = w * h / 2; //平均分为2段,每一段的长度
int spaceUP = segmentLength / TOP_SIZE;
int spaceDOWN = segmentLength / (SIZE - TOP_SIZE);
randomValue = Math.random();
int offset = (int) (randomValue * spaceUP);
for (int i = 0; i < TOP_SIZE; i++) {
int index = offset + spaceUP * i;
int y = index / w;
int x = index % w;
pixels[i] = b.getPixel(x, y);
}
offset = offset + segmentLength;
for (int i = 0; i < (SIZE - TOP_SIZE); i++) {
int index = offset + spaceDOWN * i;
int y = index / w;
int x = index % w;
pixels[i + TOP_SIZE] = b.getPixel(x, y);
}
return pixels;
}
获取到了像素的数据,在满足了三个条件下才能算作页面加载完成,分别是:
- 如果页面40%的像素值和loading一致,当做还在loading(其中LOADING_PIXEL_COLOR是默认加载的颜色像素值)
- 如果页面95%的像素值一样,当做还在loading
- 页面像素值种类小于等于4,当做还在loading
/**
* 页面40%的像素值和loading一致,当做还在loading
* @return 是否加载完成
*/
private static boolean planA(int[] pixels) {
int count = 0;
for (int pix : pixels) {
if (pix == LOADING_PIXEL_COLOR) {
count++;
}
}
return count < SIZE * 0.4;
}
/**
* 页面95%的像素值一样,当做还在loading
* @return 是否加载完成
*/
private static boolean planB(int[] data) {
int max = 0;
SparseIntArray sparseIntArray = new SparseIntArray();
for (int pix : data) {
int count = sparseIntArray.get(pix) + 1;
sparseIntArray.put(pix, count);
if (count > max) {
max = count;
}
}
boolean res = max < SIZE * 0.95;
Log.d(TAG, "planB: " + res + " max size " + max + " random " + randomValue);
return res;
}
/**
* 页面像素值种类小于等于4,当做还在loading
* @return 是否加载完成
*/
private static boolean planC(int[] data) {
if (data == null) {
return false;
}
Set<Integer> set = new HashSet<Integer>();
for (int pix : data) {
set.add(pix);
}
boolean res = set.size() > 4;
Log.d(TAG, "planC: " + res + " set size = " + set.size());
if (res) {
Log.d(TAG, "planC: ");
}
return res;
}
2、Fragment页面加载时间
public class BaseFragment extends Fragment {
public boolean isNeedLoadingTimeDetect() {
return true;
}
public int getLoadingTimeDetectType() {
return LoadingDetector.AREA_DETECT;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (isNeedLoadingTimeDetect()) {
LoadingDetector.getInstance().startWatch(this, getLoadingTimeDetectType());
}
}
@Override
public void onDestroy() {
super.onDestroy();
if (isNeedLoadingTimeDetect()) {
LoadingDetector.getInstance().destroyWatch(this);
}
}
}
具体处理方法同Activity一样。
3、H5、RN、Flutter页面加载时间
采用像素计算法,不赘述。
4、代码地址
https://github.com/kkloqin