1.4 前端埋点
构建一个数据平台,大体上包括数据采集、数据上报、数据存储、数据计算以及数据可视化展示等几个重要环节。其中,数据采集与上报是整个流程中最重要的一环,只有确保前端数据埋点的全面、准确、及时,最终产生的数据结果才是可靠、有价值的。
为了解决前端数据埋点的准确性、及时性和开发效率等问题,业内从不同角度提出了多种技术方案,这些方案大体上可以归为以下3类。
第一类是代码埋点,即在需要埋点的节点调用接口处直接上传埋点数据,友盟、百度统计等第三方数据统计服务商大都采用这种方案。
第二类是可视化埋点,即通过可视化工具配置采集节点,在前端自动解析配置并上报埋点数据,从而实现所谓的“无痕埋点”,代表方案有已经开源的Mixpanel。
第三类是“无埋点”,这种方案并不是不需要埋点,而是前端自动采集全部事件并上报埋点数据,在后端进行数据计算时过滤出有用数据,代表方案有GrowingIO。
前端埋点的技术要求很高,总结起来主要有3点。
第一是数据的准确性和及时性,数据质量的好坏将直接影响依赖埋点数据的后端策略服务、与合作伙伴结算以及运营数据报表等。
第二是埋点的效率,埋点的复杂程度往往与业务需求相关,埋点效率会影响版本迭代的速度。
第三是动态部署与修复埋点的能力,本质上这也是提升埋点效率的一种手段,并且使埋点不再依赖于App客户端重发新版本。
原有埋点主要采用手动的代码埋点方案,代码埋点方案虽然使用起来灵活,但是开发成本较高,并且一旦上线就很难修改。如果发生严重的数据问题,只能通过热修复解决。如果直接改进为可视化埋点,开发成本较高,并且也不能满足所有埋点需求;而如果改进为“无埋点”的话,带来的流量消耗和数据计算成本也是业务不能接受的。因此,在原有代码埋点方案的基础上,演化出一套轻量的、声明式的前端埋点方案是性价比最高的,并且可以在动态埋点、无痕埋点等方向做进一步的探索和实践。
1.4.1 代码埋点
由于后面的内容中要介绍声明式埋点和无痕埋点方案,而它们仍然依赖原有的代码埋点方案的底层逻辑,因此这里有必要先简单介绍代码埋点方案。在实现代码埋点时,主要关注的是数据结构的规范性、埋点接口的易用性和上报策略的可靠性等问题。
开发者需要手动在需要埋点的节点处(例如,点击事件的回调方法、列表元素的展示回调方法、页面的生命周期函数等)插入这些埋点代码,代码如下:
1. EventInfo eventInfo = new EventInfo(); 2. eventInfo.nm = EventName.MGE; // 事件类型为MGE 3. eventInfo.val_bid = "xxx"; // 事件的唯一标识 4. eventInfo.val_lab = new HashMap<>(); // 携带的业务数据 5. eventInfo.val_lab.put(Constants.Business.xx,"xxx"); 6. Statistics.getChannel("hotel").writeEvent(eventInfo);
可以看出,代码埋点方案是一种典型的命令式编程,它会严格遵循你的指令来操作,需要进入具体的业务中,因此埋点代码常常要侵入具体的业务逻辑,这使埋点代码变得很烦琐且容易出错。因此,最直接的做法是将埋点代码与具体的交互和业务逻辑解耦,也就是“声明式埋点”,从而降低埋点难度。
1.4.2 声明式埋点
声明式埋点的思路是将埋点代码与具体的交互和业务逻辑解耦,开发者只用关心需要埋点的控件,并且为这些控件声明需要的埋点数据即可,从而降低埋点的成本。
在Android中,自定义了常用的UI控件,如TextView 、 LinearLayout、 ListView 、ViewPager等,重写了事件响应方法,在这些方法内部自动填写埋点代码。重写控件的好处在于,可以拦截更多的事件,执行效率高且运行稳定。但其弊端也非常明显——移植成本比较高。
为了解决这个问题,可借鉴Android 7.0支持库的思路,即通过AppCompatDelegate代理自动替换UI控件,代码如下:
1. public class GAAppCompatDelegateV14 extends AppCompatDelegateImplV14 { 2. @Override 3. View callActivityOnCreateView(View parent, String name, Contextcontext, AttributeSet attrs){ 4. switch(name){ 5. case "TextView": 6. return new NovaTextView(context, attrs); 7. } 8. return super.callActivityOnCreateView(parent, name, context, attrs); 9. } 10. }
这样,开发者只需要在自己的Activity基类中重写getDelegate方法,将方法的返回值替换为修改过的AppCompatDelegate,这样就可以实现自动替换UI控件了:
1. @Override 2. public AppCompatDelegate getDelegate(){ 3. if(mDelegate == null){ 4. mDelegate = GAAppCompatUtil.create(this, this); 5. } 6. return mDelegate; 7. }
如果引用的第三方库中重写了UI控件,则上述方法是不生效的,也就是说需要一种替换UI控件类的父类方法。但是在运行时,没有找到可行的替换UI控件类的父类方法,因此建议尝试在编译时修改父类,并开发一个Gradle插件。事实上,这样做并不存在运行时效率的问题,只会牺牲一些编译速度。这样开发者只需要运行这个插件,就可以实现自动将UI控件的父类替换为重写的UI控件了。
在采用了声明式埋点后,只需要在控件初始化时声明一下需要的埋点即可,不必再侵入程序的各种响应函数,这样会降低埋点难度:
1.GAHelper.bindClick(view, bid, lab);
声明式埋点能够替代所有的代码埋点,并且能解决早期遇到的移植成本高等问题。但是其本质上还是一种代码埋点,只是埋点的代码量减少了,并且不再侵入业务逻辑了。如果要满足动态部署与修复埋点的需求,则需要彻底重构前端硬编码的埋点代码。
1.4.3 无痕埋点
声明式埋点之所以还需要硬编码,主要有两个原因:第一是需要声明埋点控件的唯一事件标识;第二是有的业务字段需要在前端埋点时携带,而这些字段是在运行时才可获知的值。
对于第一点,可以尝试在前后端使用一致的规则自动生成事件标识,这样后端就可以配置前端的埋点行为,从而做到自动化埋点。对于第二点,可以尝试通过某种方式将业务数据自动与埋点数据关联,这种关联可以发生在前端,也可以发生在后端。
数据埋点与采集是进行数据分析的基础。在第三方统计平台普遍提供的前端埋点解决方案中,手动埋点是最基础且最成熟的方式,但却因其技术门槛高、操作复杂、周期长等弊端为广大数据分析人员及技术人员所诟病。而解决这些问题正是后来兴起的无痕埋点技术的优势所在。
无痕埋点技术早在2013年就被Heap Analytics等公司应用在了数据分析领域,但在国内直到2016年才开始被广泛关注,并同时出现了全埋点等技术描述。
事实上,无论是无痕埋点还是全埋点,它们的核心技术基础是一致的。它们都是通过基础代码在所有页面及页面路径上的可交互事件元素上放置监听器来实现数据采集的。所以,与其说它们不需要埋点,还不如说代码帮开发者完成了处处埋点的烦琐工作。
早期有人区分两者的依据是,全埋点会将所有数据全部采集回收,而无痕埋点只会回收通过可视化界面配置的事件的数据。但事实上,随着相关功能弥合度逐渐提高,这种以功能进行区分的界限逐步消除,所谓的差异也就不准确了。因此,当下更愿意把无痕埋点或全埋点当作一种营销包装方式。
下面就来详细阐述无痕埋点。
1.问题的引入
在开发过程中,不可避免要对事件进行统计,比如,对某个界面启动次数的统计,或者对某个按钮点击次数的统计,一般大公司都会有自己的统计SDK,而其他公司则大部分会选择友盟统计等第三方平台。但是他们都需要在代码里面的每一个事件产生的地方插入统计代码,如某个事件触发onClick事件,那么就在view.setOnClickListener()的onClick方法里写入MobclickAgent.onEvent(MyApp.getInstance(),"login_click")。这时候会有人想,有没有一种办法不用这么麻烦呢?
通过上面的分析,有过AOP开发经验的读者就会想到,这就是典型的AOP应用场景。接下来介绍具体的解决方案。
2.解决方案
在第三方统计中,每一个事件都会对应一个ID,而这个ID可以由开发人员自己定义,对应的ID会有事件描述,这样就形成了一个表格,将这个表格上传到统计平台。当需要统计某个事件的时候,只需将事件的ID上报即可,而后台就会记录对应的ID事件统计。通过观察,大部分的事件统计都是onClick事件。
接下来需要解决几个问题。
● 问题一:如何在对应的事件上动态注入代码。
● 问题二:如何动态地生成一个事件的唯一ID。
● 问题三:如何将ID和事件描述对应上。
方案一:在AOP里面有一个Javassist库,可以很便利地动态修改class文件。在将java文件编译成class文件之后,可以找到所有实现android.view.View.onClickListener的类,包括匿名类,然后在它们的onClick(View v)中注入统计代码。
方案二:可能会有人认为可以直接使用View.getId(),但是这个View的ID是人为设置的,而且在不同的layout.xml中可以设置相同的ID,所以不能作为事件的唯一ID,那么这个ID就必须自己生成了。解决思路是:在事件发生之前,对当前Activity的layout的整个ViewTree进行遍历,将所有View和ViewGroup的Tag设置为组合的唯一ID,这个ID是由ID发生器与当前View的ViewParent的ID组合而成的,然后当onClick事件产生时,可以得到当前View的唯一ID。
方案三:在获得唯一ID之后,通过代码很难知道这个View具体描述的是什么,所以必须手动配置。这时可以采用很简单的办法,即在界面上一一单击想要统计的点击事件,然后将对应View的ID写入一个文件,在这个文件的对应ID上写上对应View的描述。其实还可以更直观一点,那就是当点击事件的时候会直接弹出一个对话框,在这个对话框中输入相应的描述。
有了对应的解决方案,具体如何实现呢?下面将进行介绍。
3.具体解决方案的实现
下面将从Hook LayoutInflater和统计代码的动态注入两个方面来介绍具体解决方案的实现。
(1)Hook LayoutInflater
Hook LayoutInflater是通过调用context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)来返回自定义的CustomLayoutInflater的,然后覆写inflate方法就可以得到对应的ViewTree。
通过阅读源码,getSystemService方法最终会得到android.app.SystemServiceRegistry类的静态变量SYSTEM_SERVICE_FETCHERS,代码如下:
1. private static final HashMap<String, ServiceFetcher<?>> SYSTEM_ SERVICE_FETCHERS = new HashMap<String, ServiceFetcher<?>>(); 2. 3. public static Object getSystemService(ContextImpl ctx, String name){ 4. ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name); 5. return fetcher != null ? fetcher.getService(ctx): null; 6. }
而这个静态变量是在registerService方法中进行赋值的,代码如下:
1. private static <T> void registerService(String serviceName, Class<T> serviceClass,ServiceFetcher<T> serviceFetcher){ 2. SYSTEM_SERVICE_NAMES.put(serviceClass, serviceName); 3. SYSTEM_SERVICE_FETCHERS.put(serviceName, serviceFetcher); 4. }
赋值的地方在该类的static代码块里面:
1. static { 2. ... 3. registerService(Context.LAYOUT_INFLATER_SERVICE, LayoutInflater.class, 4. new CachedServiceFetcher<LayoutInflater>(){ 5. @Override 6. public LayoutInflater createService(ContextImpl ctx){ 7. return new PhoneLayoutInflater(ctx.getOuterContext()); 8. }}); 9. ... 10. }
知道上面的这些内容后,就可以反射调用registerService,将自定义的CustomLayout-Inflater注册进去,替换掉原本的PhoneLayoutInflater,这样当系统获取LayoutInflater的时候,得到的就是CustomLayoutInflater。
由于registerService(String serviceName, Class<T> serviceClass, ServiceFetcher<T> serviceFetcher)需要ServiceFetcher的实例,而ServiceFetcher是一个接口,并且其处于SystemServiceRegistry类的内部,所以只能通过反射拿到这个接口,并且创建一个类实现它、实例化它。但是,如果通过反射得到ServiceFetcher的Class类型,而且调用接口的Class.newInstance()直接抛出异常,则无法达到目的。另一个办法就是通过动态代理来生成一个实现ServiceFetcher接口的类,代码如下:
1. public class Hooker { 2. private static final String TAG = "Hooker"; 3. public static void hookLayoutInflater()throws Exception { 4. //获取ServiceFetcher的实例ServiceFetcherImpl 5. Class<?> ServiceFetcher = Class.forName("android.app. SystemServiceRegistry$ServiceFetcher"); 6. Object ServiceFetcherImpl = Proxy.newProxyInstance(Hooker. class.getClassLoader(), 7. new Class[]{ServiceFetcher}, new ServiceFetcherHandler()); 8. //Proxy.newProxyInstance返回的对象会实现指定的接口 9. 10. //获取SystemServiceRegistry的registerService方法 11. Class<?> SystemServiceRegistry = Class.forName("android.app. SystemServiceRegistry"); 12. Method registerService = SystemServiceRegistry.getDeclaredMethod ("registerService",String.class, CustomLayoutInflater.class.getClass(), Serv iceFetcher); 13. registerService.setAccessible(true); 14. 15. //调用registerService 方法,将自定义的CustomLayoutInflater 设置到 //SystemServiceRegistry 16. registerService.invoke(SystemServiceRegistry, 17. new Object[]{Context.LAYOUT_INFLATER_SERVICE, CustomLayoutInflater.class, ServiceFetcherImpl}); 18. 19. //(测试) 20. //获取SystemServiceRegistry的SYSTEM_SERVICE_FETCHERS静态变量 21. // Field SYSTEM_SERVICE_FETCHERS = SystemServiceRegistry. 22. //getDeclaredField("SYSTEM_SERVICE_FETCHERS"); 23. // SYSTEM_SERVICE_FETCHERS.setAccessible(true); 24. // Log.e(TAG, SYSTEM_SERVICE_FETCHERS.getName()); 25. // HashMap SYSTEM_SERVICE_FETCHERS_FIELD =(HashMap)SYSTEM_SERVICE_ 26. //FETCHERS.get(SystemServiceRegistry); 27. // 28. // Set set = SYSTEM_SERVICE_FETCHERS_FIELD.keySet(); 29. // Iterator iterator = set.iterator(); 30. 31. } 32. } 33. ServiceFetcherHandler.java 34. public class ServiceFetcherHandler implements InvocationHandler{ 35. 36. @Override 37. public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { 38. //当调用ServiceFetcherImpl的getService的时候,会返回自定义的LayoutInflater 39. return new CustomLayoutInflater((Context)args[0]); 40. } 41. }
CustomLayoutInflater参考了系统自带的PhoneLayoutInflater,然后加上自己生成View ID的代码:
1. public class CustomLayoutInflater extends LayoutInflater { 2. 3. private static final String[] sClassPrefixList = { 4. "android.widget.", 5. "android.webkit." 6. }; 7. 8. public CustomLayoutInflater(Context context){ 9. super(context); 10. } 11. 12. protected CustomLayoutInflater(LayoutInflater original, Context newContext){ 13. super(original, newContext); 14. } 15. 16. @Override 17. protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException { 18. for(String prefix : sClassPrefixList){ 19. try { 20. View view = createView(name, prefix, attrs); 21. if(view != null){ 22. return view; 23. } 24. } catch(ClassNotFoundException e){ 25. 26. } 27. } 28. return super.onCreateView(name, attrs); 29. } 30. 31. public LayoutInflater cloneInContext(Context newContext){ 32. return new CustomLayoutInflater(this, newContext); 33. } 34. 35. @Override 36. public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot){ 37. View viewGroup = super.inflate(resource, root, attachToRoot); 38. View rootView = viewGroup; 39. View tempView = viewGroup; 40. //得到根View 41. while(tempView != null){ 42. rootView = viewGroup; 43. tempView =(ViewGroup)tempView.getParent(); 44. } 45. //遍历根View的所有子View 46. traversalViewGroup(rootView); 47. return viewGroup; 48. } 49. 50. private void traversalViewGroup(View rootView){ 51. if(rootView != null && rootView instanceof ViewGroup){ 52. //如果Tag的值已经存在了,那么就不用再赋值了 53. if(rootView.getTag()== null){ 54. rootView.setTag(getViewTag()); 55. } 56. ViewGroup viewGroup =(ViewGroup)rootView; 57. int childCount = viewGroup.getChildCount(); 58. for(int i = 0; i < childCount; i++){ 59. View childView = viewGroup.getChildAt(i); 60. if(childView.getTag()== null){ 61. childView.setTag(combineTag(getViewTag(), viewGroup.getTag().toString())); 62. } 63. Log.e("Hooker", "childView name = " + childView. getClass().getName()+ "id = " + childView.getTag().toString()); 64. if(childView instanceof ViewGroup){ 65. traversalViewGroup(childView); 66. } 67. } 68. } 69. } 70. 71. private String combineTag(String tag1, String tag2){ 72. return getMD5(getMD5(tag1)+ getMD5(tag2)); 73. } 74. 75. private static int VIEW_TAG = 0x10000000; 76. 77. private static String getViewTag(){ 78. return String.valueOf(VIEW_TAG++); 79. } 80. 81. /** 82. * 对字符串进行MD5加密 83. * 84. * @param str 85. * @return 86. */ 87. public static String getMD5(String str){ 88. try { 89. //生成一个MD5加密计算摘要 90. MessageDigest md = MessageDigest.getInstance("MD5"); 91. //计算md5函数 92. md.update(str.getBytes()); 93. return new BigInteger(1, md.digest()).toString(16); 94. } catch(Exception e){ 95. 96. } 97. return "null"; 98. } 99. }
最后在Application onCreate方法中调用Hooker.hookLayoutInflater方法即可,想检验这一步的正确性,可编写如下代码进行测试:
1. Button button =(Button)findViewById(R.id.button); 2. button.setOnClickListener(new View.OnClickListener(){ 3. @Override 4. public void onClick(View v){ 5. //62e419e0f3c9772391c861b6c09a2abd = v.getTag() 6. Toast.makeText(MainActivity.this, "this is abutton !, " + v.getTag().toString(), Toast.LENGTH_LONG).show(); 7. } 8. });
其实还有一种比较简单的办法,即给View设置ID,即在Activity onTouchEvent方法里获取getWindow().getDecorView(),然后遍历该View的子View。这里会花费点时间,点击事件的响应速度会慢一点。
(2)统计代码的动态注入
学习这一部分需要读者有AOP编程基础,相关核心代码如下。
新建插件类JavassistPlugin:
1. public class JavassistPlugin implements Plugin<Project> { 2. 3. void apply(Project project){ 4. def log = project.logger 5. log.error "========================"; 6. log.error "Javassist开始修改Class!"; 7. log.error "========================"; 8. log.error "========================"+ project.getClass().getName(); 9. project.android.registerTransform(new PreDexTransform(project)) 10. } 11. }
PreDexTransform的核心实现如下:
1. public class PreDexTransform extends Transform { 2. Project mProject 3. 4. public PreDexTransform(Project project){ 5. mProject = project 6. } 7. 8. //Transfrom在Task列表中的名字 9. //TransfromClassesWithPreDexForXXXX 10. @Override 11. String getName(){ 12. return "PreDex" 13. } 14. 15. @Override 16. Set<QualifiedContent.ContentType> getInputTypes(){ 17. return TransformManager.CONTENT_CLASS 18. } 19. 20. //指定Transform的作用范围 21. @Override 22. Set<? super QualifiedContent.Scope> getScopes(){ 23. return TransformManager.SCOPE_FULL_PROJECT 24. } 25. 26. @Override 27. boolean isIncremental(){ 28. return false 29. } 30. 31. @Override 32. void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, 33. TransformOutputProvider outputProvider, Boolean isIncremental)throws IOException, TransformException, InterruptedException { 34. log("transform >>>>>") 35. //Transform的输入有两种类型:目录和jar,分开遍历 36. inputs.each { TransformInput input-> 37. input.directoryInputs.each { DirectoryInput directoryInput-> 38. log("directoryInput name = " + directoryInput.name +", path = " + directoryInput.file.absolutePath) 39. 40. JavassistInject.injectDir(directoryInput.file. getAbsolutePath(), "com", mProject) 41. 42. def dest = outputProvider.getContentLocation(directoryInput. name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY) 43. 44. //将输入的目录复制到输出指定目录 45. FileUtils.copyDirectory(directoryInput.file, dest) 46. } 47. 48. input.jarInputs.each { JarInput jarInput -> 49. 50. log("jarInput name = " + jarInput.name +", path = " + jarInput.file.absolutePath) 51. 52. JavassistInject.injectDir(jarInput.file. getAbsolutePath(), "com", mProject) 53. 54. //重命名输出文件(同目录执行copyFile操作会发生冲突) 55. def jarName = jarInput.name 56. def md5Name = jarInput.file.hashCode() 57. if(jarName.endsWith(".jar")){ 58. jarName = jarName.substring(0, jarName.length()-4) 59. } 60. def dest = outputProvider.getContentLocation(jarName + md5Name, 61. jarInput.contentTypes, jarInput.scopes, Format.JAR) 62. FileUtils.copyFile(jarInput.file, dest) 63. } 64. } 65. } 66. 67. void log(String log){ 68. mProject.logger.error(log) 69. } 70. 71. }
具体的代码注入:
1. public class JavassistInject { 2. 3. public static final String JAVA_ASSIST_APP = "com.meyhuan. applicationlast.MyApp" 4. public static final String JAVA_ASSIST_MOBCLICK = "com.umeng. analytics.MobclickAgent" 5. 6. private final static ClassPool pool = ClassPool.getDefault() 7. 8. public static void injectDir(String path, String packageName, Project project){ 9. pool.appendClassPath(path) 10. String androidJarPath = project.android.bootClasspath[0]. toString() 11. log("androidJarPath: " + androidJarPath, project) 12. pool.appendClassPath(androidJarPath) 13. importClass(pool) 14. File dir = new File(path) 15. if(!dir.isDirectory()){ 16. return 17. } 18. dir.eachFileRecurse { File file-> 19. String filePath = file.absolutePath 20. log("filePath : " + filePath, project) 21. if(filePath.endsWith(".class")&& !filePath.contains('R$') 22. && !filePath.contains('R.class')&& !filePath. contains("BuildConfig.class")){ 23. log("filePath my : " + filePath, project) 24. int index = filePath.indexOf(packageName); 25. boolean isMyPackage = index != -1; 26. if(!isMyPackage){ 27. return 28. } 29. String className = JavassistUtils.getClassName (index, filePath) 30. log("className my : " + className, project) 31. CtClass c = pool.getCtClass(className) 32. log("CtClass my : " + c.getSimpleName(), project) 33. for(CtMethod method : c.getDeclaredMethods()){ 34. log("CtMethod my : " + method.getName(),project) 35. //找到onClick(View)方法 36. if(checkOnClickMethod(method)){ 37. log("checkOnClickMethod my : " + method.getName(), project) 38. injectMethod(method) 39. c.writeFile(path) 40. } 41. } 42. } 43. } 44. 45. } 46. 47. private static boolean checkOnClickMethod(CtMethod method){ 48. return method.getName().endsWith("onClick") && method. getParameterTypes().length == 1 && method.getParameterTypes()[0].getName().e quals("android.view.View"); 49. } 50. 51. private static void injectMethod(CtMethod method){ 52. method.insertAfter("System.out.println((\$1).getTag());") 53. method.insertAfter("MobclickAgent.onEvent(MyApp.getInstance(), (\$1).getTag().toString());") 54. } 55. 56. private static void log(String msg, Project project){ 57. project.logger.log(LogLevel.ERROR, msg) 58. } 59. 60. private static void importClass(ClassPool pool){ 61. pool.importPackage(JAVA_ASSIST_APP) 62. pool.importPackage(JAVA_ASSIST_MOBCLICK) 63. }
通过以上两步,代码里的OnClickListener实现类的onClick方法中就会多出第三方统计方法(如友盟统计的代码MobclickAgent.onEvent(MyApp.getInstance(), v.getTag(). toString()))。
上面只简单实现了onClick事件的统计功能,需要完善的地方还有很多,这里仅提供了一个参考方案。下面总结一下要点。
● 通过Hook LayoutInflater的方式,遍历所有的View并且将ID一一设置到Tag里面。
● 通过Javassist将统计代码注入onClick方法里,获取View的ID,并且上传统计。
● 手动将View ID与对应的View事件的描述对应起来。