-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathatom.xml
2718 lines (2520 loc) · 756 KB
/
atom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>网易杭州前端技术部</title>
<link href="/atom.xml" rel="self"/>
<link href="http://NEYouFan.github.io/"/>
<updated>2017-07-12T02:16:11.000Z</updated>
<id>http://NEYouFan.github.io/</id>
<author>
<name>网易杭州前端技术部</name>
</author>
<generator uri="http://hexo.io/">Hexo</generator>
<entry>
<title>网易HubbleData之Android无埋点实践</title>
<link href="http://NEYouFan.github.io/2017/07/11/android/%E7%BD%91%E6%98%93HubbleData%E4%B9%8BAndroid%E6%97%A0%E5%9F%8B%E7%82%B9%E5%AE%9E%E8%B7%B5/"/>
<id>http://NEYouFan.github.io/2017/07/11/android/网易HubbleData之Android无埋点实践/</id>
<published>2017-07-10T16:00:00.000Z</published>
<updated>2017-07-12T02:16:11.000Z</updated>
<content type="html"><![CDATA[<p>网易HubbleData的Android SDK在代码埋点整体架构的基础上新增了无埋点功能,本文主要针对网易HubbleData在Android SDK中的无埋点实践进行分享。重点死磕无埋点两大核心技术:1. View的唯一ID;2. 无埋点实现(代理监听方案和gradle插件方案)。<a id="more"></a></p>
<h1 id="1-背景"><a href="#1-背景" class="headerlink" title="1 背景"></a>1 背景</h1><p>网易HubbleData是一个洞察用户行为的数据分析系统,提供一套完整的数据解决方案。一个典型的数据平台,对于数据的处理,是由如下的5个步骤组成的:</p>
<p> <img src="http://nos.netease.com/knowledge/be37a6ab-ce8b-4205-9c59-3ceeebf2d68a" alt="1-data_process"> </p>
<p>其中,第一个步骤,也即数据采集是最核心的问题。网易HubbleData支持全端数据采集,包括iOS、Android、JS、JAVA等多个平台。本文主要讨论Android平台的数据采集方案。业内各家公司从不同角度,提出了多种技术方案,这些方案大体上可以归为三类:</p>
<p>(1) 代码埋点:在某个事件发生时调用SDK里面相应的接口发送埋点数据,百度统计、友盟、TalkingData、Sensors Analytics等第三方数据统计服务商大都采用这种方案。</p>
<ul>
<li>优点:使用者控制精准,自由地选择什么时候发送数据。</li>
<li>缺点:开发及测试代价大;需要等待APP更新。</li>
</ul>
<p>(2) 可视化埋点:通过可视化工具配置采集节点,在Android端自动解析配置并上报埋点数据,从而实现所谓的自动埋点,代表方案是已经开源的Mixpanel。</p>
<ul>
<li>优点:解放开发人员,解决了代码埋点代价大的问题;通过服务端配置埋点,解决等待APP更新的问题。</li>
<li>缺点:覆盖功能有限,只能配置一些公共属性;埋点只能从当前时刻开始,无法“回溯”。</li>
</ul>
<p>(3) 无埋点:它并不是真正的不需要埋点,而是Android端自动采集全部事件并上报埋点数据,在后端数据计算时过滤出有用数据,代表方案是国内的GrowingIO。</p>
<ul>
<li>优点:解放开发人员,解决了代码埋点代价大的问题;解决了等待APP更新和数据“回溯”的问题;可以自动获取很多启发性的信息。</li>
<li>缺点:覆盖的功能有限,不能灵活地自定义属性;给网络传输和耗电等性能带来更大的负载。</li>
</ul>
<p>网易HubbleData的Android SDK早已有之,公司内部诸如考拉、易信、LOFTER、美学、漫画等多款产品都已接入使用。原有Android SDK采用手动代码埋点的方案,主要关注的是事件模型、埋点接口、上报策略等问题。整体架构如下图所示:</p>
<p> <img src="http://nos.netease.com/knowledge/31af8344-3ec0-4c0d-a732-e846d975576b" alt="2-code_structrue"> </p>
<p>代码埋点虽然使用起来灵活,但是开发成本较高,并且一旦上线就很难修改。参考业界先进方案并结合网易公司内部产品的埋点需求,网易HubbleData的Android SDK在代码埋点整体架构的基础上新增了无埋点功能,本文主要针对网易HubbleData在Android SDK中无埋点实践进行简单分享。</p>
<h1 id="2-无埋点关键技术"><a href="#2-无埋点关键技术" class="headerlink" title="2 无埋点关键技术"></a>2 无埋点关键技术</h1><h2 id="2-1-View的唯一ID"><a href="#2-1-View的唯一ID" class="headerlink" title="2.1 View的唯一ID"></a>2.1 View的唯一ID</h2><h3 id="2-1-1-如何唯一地标识一个View?"><a href="#2-1-1-如何唯一地标识一个View?" class="headerlink" title="2.1.1 如何唯一地标识一个View?"></a>2.1.1 如何唯一地标识一个View?</h3><p>SDK内部在自动收集控件数据时,需要将界面上的任何一个View与其他View区分开来。这就需要为界面上的每一个控件分配一个唯一的ViewID。此ViewID除了具有区分性,还需要具有一致性,即同一个View无论界面布局如何动态变化,或者说多次进入同一页面,此ViewID理论上保持不变。</p>
<p>View中可以找到的特征信息:</p>
<ul>
<li><p>Id: 静态整数。在编译期,aapt会生成R类,其中包含所有资源ID。</p>
</li>
<li><p>Resource Id:开发者操作控件的唯一标识。一般由开发者在布局文件中指定android:id,通过findViewById找到View。</p>
</li>
<li><p>Class Name:View所属的Class,例如TextView、LinearLayout、ListView、ViewPager等。</p>
</li>
</ul>
<p>这些特征信息中的Id如果能够使用,是可以直接用作ViewID的,但是,从aapt生成id的原则来看,不同版本相同的resource Id对应的整数Id 是有可能不一样的,所以没有办法使用Id来唯一标识。</p>
<p>Resource Id是开发者定义的View标识,对于有Resource Id 的View可以说具备了唯一标识,那么没有Resource Id的View,我们考虑通过一个index属性来区分,index属性可以取每个控件所属父组件的index(也即每个控件是其父控件的第几个孩子),并逐级向上遍历找到根节点,最后形成一个View Path即可用来唯一地标识这个View。</p>
<h3 id="2-1-2-ViewID构造"><a href="#2-1-2-ViewID构造" class="headerlink" title="2.1.2 ViewID构造"></a>2.1.2 ViewID构造</h3><p>通过上述分析,我们得到一条View Path:获取每个控件自身的ID、类名、Resource Id以及位于所属父组件的Index等特征信息,并逐级向上遍历找到根节点。</p>
<p>并结合该View所在的页面信息,我们得到ViewID的构造形式如下:</p>
<pre><code>sha-256(page : path)
</code></pre><ul>
<li>page: ActivityName</li>
<li>path: view在控件树中的全路径,按照如下形式进行拼接,其中index为当前view所属父组件的index,id为编写布局文件时的android:id属性值,有则拼接,且index固定为0,无则不拼接。</li>
</ul>
<figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div></pre></td><td class="code"><pre><div class="line">parent1[index]#id/parent2[index]#id/.../view[index]#id</div></pre></td></tr></table></figure>
<p>简单示例如下:</p>
<p> <img src="http://nos.netease.com/knowledge/eceb7098-e3d9-48fb-9599-18cb5e38a7b4" alt="3-view_path_example"> </p>
<h3 id="2-1-3-ViewID优化"><a href="#2-1-3-ViewID优化" class="headerlink" title="2.1.3 ViewID优化"></a>2.1.3 ViewID优化</h3><p>考虑到在实际布局中有可能存在一些动态插入、删除的控件,或者说控件被复用,都可能引起View Path的变化,从而导致ViewID不唯一。为了保证ViewID的一致性,我们从以下几个方面着手,对ViewID进行了一定程度地优化。</p>
<h4 id="1-Index"><a href="#1-Index" class="headerlink" title="(1) Index"></a>(1) Index</h4><p> <img src="http://nos.netease.com/knowledge/86bdfd06-3400-4572-83fa-e1d86ed3c78c" alt="3-viewPath_example_bad.png"> </p>
<p>如上图所示,当页面布局发生动态变化时,比如说删除一个子view,其他子view所属父组件的index也可能会改变,为此,我们对view所属父组件的index进行改造,通过如下算法对index赋值:</p>
<ul>
<li><p>每个ViewGroup下的所有View作为一个数组,从0开始;</p>
</li>
<li><p>每个ViewGroup下的所有View先按照Class分类,然后再把每个类型中的数据按照数组的方式,从0开始;</p>
</li>
<li><p>每个ViewGroup下的所有View先按照Class分类,再确认是否有Resource Id,如果存在,则index为0,否则index为所属Class类型数组下的序号。</p>
</li>
</ul>
<p>该优化处理对所有View适用。优化后效果如下:即动态改变一些控件后,只会影响同类型的控件,其他类型控件的index不受影响,也即ViewID不受影响。</p>
<p><img src="http://nos.netease.com/knowledge/02847b06-f3e9-4faf-ab2e-f5b946b5cbd8" alt="4-index_remove_example"> </p>
<h4 id="2-可复用View"><a href="#2-可复用View" class="headerlink" title="(2) 可复用View"></a>(2) 可复用View</h4><p>先来看一个应用场景:</p>
<p> <img src="http://nos.netease.com/knowledge/4a2d99d8-6f56-4e4a-9a15-c4f8790aaeee" alt="5-recycleView"> </p>
<p>如图所示,当ListView上滑时,屏幕下方即将显示的<元素6>其实复用了屏幕上方即将滑出的<元素0>,也就是说<元素6>与<元素0>的index均为0,在这种情况下,我们无法通过前述index的定义来区分这两个列表Item。</p>
<p> <img src="http://nos.netease.com/knowledge/0b6371a3-a3eb-4351-9982-0f5c73121501" alt="7-example_position_versus_index"> </p>
<p>所幸,针对这种情况,我们可以用position的取值进行区分,也就是令index = position。</p>
<p>通过实践发现,发生上述复用情形的View主要有以下几类:AdapterView、RecyclerView和ViewPager,其api都提供了获取position的接口。</p>
<p>a. AdapterView </p>
<p>AdapterView的派生类均可通过<code>getPositionForView</code>获取position。</p>
<figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div></pre></td><td class="code"><pre><div class="line">index = position = ((AdapterView) group).getPositionForView(child);</div></pre></td></tr></table></figure>
<p>作为AdapterView的派生类之一,ExpandableListView因为涉及到groupPosition和childPosition,因此需要特殊处理。在构造ViewID时,将能够采集到的position信息都添加到View Path中,具体策略如下:</p>
<ul>
<li><p>先将ExpandableListView作为普通AdapterView计算position</p>
</li>
<li><p>列表Item为header元素,View Path中添加[header:position]</p>
</li>
<li><p>列表Item为footer元素,footer的index需要额外计算,计算公式如下,View Path中添加[footer:footerIndex]</p>
</li>
</ul>
<figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div></pre></td><td class="code"><pre><div class="line">// Calculates the footer index among footers; </div><div class="line">// For instance, there are five footers, so the footer index ranges from zero to four.</div><div class="line">// The first footer index is zero.</div><div class="line">footerIndex = position - (expandableListView.getCount() - expandableListView.getFooterViewsCount());</div></pre></td></tr></table></figure>
<ul>
<li>列表Item为组元素,View Path中添加[group:groupPosition]</li>
<li>列表Item为组内元素,View Path中添加[group:groupPosition,child:childPosition]</li>
</ul>
<p>涉及到的api接口如下:</p>
<pre><code>((AdapterView) expandableListView).getPositionForView();
public long getExpandableListPosition(int flatListPosition);
public static int getPackedPositionType(long packedPosition);
public static int getPackedPositionGroup(long packedPosition);
public static int getPackedPositionChild(long packedPosition);
</code></pre><p>示例如下:</p>
<p> <img src="http://nos.netease.com/knowledge/b6731088-9b7f-4ccd-ad7c-5ec7f3adfa49" alt="6-ExpandListView"> </p>
<p> b. V7-RecyclerView</p>
<p>RecyclerView的情形比较简单,可通过调用<code>getChildPosition</code>和<code>getChildAdapterPosition</code>获取position。</p>
<pre><code>@Deprecated
public int getChildPosition(View child);
public int getChildAdapterPosition(View child);
</code></pre><p> c. V4 - ViewPager</p>
<p>V4 - ViewPager可通过调用<code>getCurrentItem</code>获取position。</p>
<pre><code>public int getCurrentItem();
</code></pre><h4 id="3-Fragment节点"><a href="#3-Fragment节点" class="headerlink" title="(3) Fragment节点"></a>(3) Fragment节点</h4><p>主流App的主页均是采用如图所示的Tab切换Fragment的设计。在这种情形下,如果主页内嵌的Fragment采用“懒加载”方案,则底部Tab的点击顺序决定了该Tab对应Fragment的初始化顺序,从而导致Fragment所属父组件的index动态变化。</p>
<p> <img src="http://nos.netease.com/knowledge/f3b49136-2a39-4f59-9ef3-7811a6cfd294" alt="8-mainpage_frags_init_orders_uedc"> </p>
<p>也就是说,Fragment初始化顺序影响ViewID。而前述Index优化方案并不能解决这一问题。</p>
<h5 id="Fragment节点特殊处理"><a href="#Fragment节点特殊处理" class="headerlink" title="Fragment节点特殊处理"></a>Fragment节点特殊处理</h5><p>针对Fragment初始化顺序影响ViewID的问题,我们采用的解决方案是:</p>
<p>如果能够获取到Fragment实例的类名,则使用Fragment实例的类名替换View Path中的Fragment,并设置[index]为特殊标记[-]。例如:使用控件篇Tab对应的Fragment实例ControlSetFragment以及特殊标记[-]替换原View Path中的Fragment[3]</p>
<p> <img src="http://nos.netease.com/knowledge/186a8bc3-9ea9-45ff-bcdf-1a3e8428d20a" alt="10-fragment_classname_replace"> </p>
<h5 id="如何获取Fragment实例?"><a href="#如何获取Fragment实例?" class="headerlink" title="如何获取Fragment实例?"></a>如何获取Fragment实例?</h5><p>采用代码埋点或后续即将讲到的插件埋点,在Fragment各实例类中重载下面的几个方法,并在各方法中插入SDK提供的方法调用,从而实现Fragment生命周期监听:</p>
<pre><code>@Override
public void onResume() {
super.onResume();
DATracker.getInstance().onFragmentResume(this);
}
@Override
public void onPause() {
super.onPause();
DATracker.getInstance().onFragmentPause(this);
}
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser);
DATracker.getInstance().setFragmentUserVisibleHint(this, isVisibleToUser);
}
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
DATracker.getInstance().onFragmentHiddenChanged(this, hidden);
}
</code></pre><p>通过上述调用,当Fragment生命周期变化时,SDK能够记录当前活跃的所有Fragment。当某个活跃的Fragment上的控件被点击了,SDK构造该控件的ViewID时,会自动将该Fragment实例的类名写入View Path。</p>
<h5 id="V4-ViewPager内嵌Fragment"><a href="#V4-ViewPager内嵌Fragment" class="headerlink" title="V4 - ViewPager内嵌Fragment"></a>V4 - ViewPager内嵌Fragment</h5><p>这里要说明的是,ViewPager内嵌的View不仅是可复用的,同时,由于其“懒加载”、“预加载”机制,其内嵌View的加载顺序也是动态的。特别地,当ViewPager内嵌Fragment时,按照前述对Fragment节点的处理,我们会使用Fragment实例的类名替换View Path中的Fragment,并设置[index]为特殊标记[-]。之所以将[index]设置为特殊标记[-],是因为Fragment动态加载导致index不可靠,而ViewPager中内嵌的Fragment却可以调用ViewPager的getCurrentItem拿到position作为index,这种情况下,是可以将index的值添加到View Path中的。</p>
<p> <img src="http://nos.netease.com/knowledge/2574db11-5468-4f5d-910a-313cd4b251a1" alt="11-viewpager_positon_0_7_uedc"> </p>
<h2 id="2-2-无埋点实现"><a href="#2-2-无埋点实现" class="headerlink" title="2.2 无埋点实现"></a>2.2 无埋点实现</h2><p>通过前述方案,我们可以使用ViewID唯一地标识屏幕上的控件。那么,比如一个Button,当这个Button被点击了,SDK又是如何捕捉到这一点击事件,并且拿到Button实例的呢,也就是如何实现自动埋点的呢?这里,我们提供了两种方案。</p>
<h3 id="2-2-1-代理监听"><a href="#2-2-1-代理监听" class="headerlink" title="2.2.1 代理监听"></a>2.2.1 代理监听</h3><h4 id="原理"><a href="#原理" class="headerlink" title="原理"></a>原理</h4><p>在应用程序中,辅助功能事件是用户与可视界面组件交互的消息。这些消息是由辅助功能服务处理。辅助功能服务使用在这些事件中的信息产生附加的反馈和提示。Android 4.0(API14)及更高版本上,辅助功能方法属于View类的一部分,也是View.AccessibilityDelegate的一部分。其中可用于实现无埋点的方法如下:</p>
<pre><code>sendAccessibilityEvent()
</code></pre><p>当用户在一个视图上操作时调用此方法。事件按照用户操作类型分类,涵盖以下事件类型:</p>
<ul>
<li>TYPE_VIEW_CLICKED</li>
<li>TYPE_VIEW_LONG_CLICKED</li>
<li>TYPE_VIEW_FOCUSED</li>
<li>TYPE_VIEW_SELECTED</li>
<li>TYPE_VIEW_HOVER_ENTER</li>
<li>TYPE_VIEW_SCROLLED</li>
<li>TYPE_VIEW_TEXT_CHANGED</li>
<li>…</li>
</ul>
<p>采用辅助功能事件实现无埋点,简单来讲,就是给View设置AccessibilityDelegate,当View产生了click,long_click等事件时,会在响应原有的Listener方法后,发送消息给AccessibilityDelegate,然后在sendAccessibilityEvent方法下搜集自动埋点事件。</p>
<pre><code>private class TrackingAccessibilityDelegate extends View.AccessibilityDelegate {
public TrackingAccessibilityDelegate(ViewNode viewNode, View.AccessibilityDelegate realDelegate) {
mViewNode = viewNode;
mRealDelegate = realDelegate;
}
public View.AccessibilityDelegate getRealDelegate() {
return mRealDelegate;
}
@Override
public void sendAccessibilityEvent(View host, int eventType) {
if (eventType == mEventType && host == mViewNode.getView()) {
...
// 自动埋点
fireEvent(mViewNode, type);// sends tracking data
}
// 响应原AccessibilityDelegate
if (null != mRealDelegate) {
mRealDelegate.sendAccessibilityEvent(host, eventType);
}
}
private View.AccessibilityDelegate mRealDelegate;
private ViewNode mViewNode;
}
</code></pre><h4 id="设置代理的时机"><a href="#设置代理的时机" class="headerlink" title="设置代理的时机"></a>设置代理的时机</h4><p>实现Application.ActivityLifecycleCallbacks,用来监听Activity生命周期,当监听到某个Activity进入onResumed状态时,通过以下方式获取RootView:</p>
<pre><code>mViewRoot = this.mActivity.getWindow().getDecorView().getRootView()
</code></pre><p>从RootView出发深度优先遍历控件树,为满足特定条件的View设置代理监听。</p>
<h4 id="界面动态变化怎么办?"><a href="#界面动态变化怎么办?" class="headerlink" title="界面动态变化怎么办?"></a>界面动态变化怎么办?</h4><p>实现ViewTreeObserver.OnGlobalLayoutListener,用来监听界面变化。当监听到界面变化时,重新遍历控件树,为满足特定条件的View设置代理监听,已经设置过代理的View不再重复设置。</p>
<p>界面的监测操作需要放在界面主线程中,起初我们担心这样会对应用本身的界面交互产生影响,所幸,经过实际测试,这样实现是可行的,界面交互感知不到任何影响。</p>
<h4 id="监控哪些View"><a href="#监控哪些View" class="headerlink" title="监控哪些View?"></a>监控哪些View?</h4><ul>
<li><p>AutoCompleteTextView(搜索框)</p>
<p> 添加 TextWatcher 监听文本变化,2s 后延时发送文本输入结果</p>
</li>
<li><p>AbsListView(列表)</p>
<p> OnItemClickListener 存在 - 对原有OnItemClickListener作一层包装,在响应原有的Listener方法后,搜集自动埋点事件。</p>
</li>
<li><p>一般View</p>
<p> hasOnClickListeners 或 isClickable 返回 true - 设置AccessibilityDelegate</p>
</li>
</ul>
<h3 id="2-2-2-gradle插件"><a href="#2-2-2-gradle插件" class="headerlink" title="2.2.2 gradle插件"></a>2.2.2 gradle插件</h3><h4 id="原理-1"><a href="#原理-1" class="headerlink" title="原理"></a>原理</h4><p>试想一下我们代码埋点的过程:首先定位到事件响应函数,例如Button的onClick函数,然后在该事件响应函数中调用SDK数据搜集接口。下面,我们介绍使用gradle插件自动在目标响应函数中插入SDK数据搜集代码,达到自动埋点的目的。</p>
<p>我们的gradle插件采用 Android gradle 插件提供的最新的Transform API,在Apk编译环节中、class打包成dex之前,插入了中间环节,调用 ASM API对class文件的字节码进行扫描,当扫描到目标事件响应函数时,在函数头部或尾部插入SDK数据搜集代码。</p>
<p> <img src="http://nos.netease.com/knowledge/dceac756-b0f7-4943-99ce-fc78e81aa2c7" alt="12-gradle_plugin_theory"> </p>
<h4 id="监控哪些View-1"><a href="#监控哪些View-1" class="headerlink" title="监控哪些View?"></a>监控哪些View?</h4><p>我们在目标View的事件响应函数中插入SDK数据搜集代码,即可实现对该类型View的监控。例如,在Button的点击事件响应函数onClick中插入SDK数据搜集代码后,当Button被点击,便会执行到onClick中的SDK数据搜集代码,从而实现Button点击事件的自动搜集。</p>
<p>目标事件响应函数(方法):</p>
<ul>
<li>onClick(Landroid/view/View;)V</li>
<li>onClick(Landroid/content/DialogInterface;I)V</li>
<li>onItemClick(Landroid/widget/AdapterView;Landroid/view/View;IJ)V</li>
<li>onItemSelected(Landroid/widget/AdapterView;Landroid/view/View;IJ)V</li>
<li>onGroupClick(Landroid/widget/ExpandableListView;Landroid/view/View;IJ)Z</li>
<li>onChildClick(Landroid/widget/ExpandableListView;Landroid/view/View;IIJ)Z</li>
<li>onRatingChanged(Landroid/widget/RatingBar;FZ)V</li>
<li>onStopTrackingTouch(Landroid/widget/SeekBar;)V</li>
<li>onCheckedChanged(Landroid/widget/CompoundButton;Z)V</li>
<li>onCheckedChanged(Landroid/widget/RadioGroup;I)V</li>
<li>…</li>
</ul>
<p>具体实现:</p>
<ul>
<li>对app中指定包进行扫描,筛选出实现了目标接口的类,在目标方法中添加数据采集代码。</li>
</ul>
<blockquote>
<p>例如,筛选出实现了<code>android/view/View$OnClickListener</code>接口的类,然后在<code>onClick(Landroid/view/View;)V</code>方法中注入采集数据的代码。</p>
</blockquote>
<p>目标效果:</p>
<pre><code>public class MainActivity extends AppCompatActivity implements OnClickListener,
android.content.DialogInterface.OnClickListener,
OnItemClickListener,
OnItemSelectedListener,
OnRatingBarChangeListener,
OnSeekBarChangeListener,
OnCheckedChangeListener,
android.widget.RadioGroup.OnCheckedChangeListener,
OnGroupClickListener, OnChildClickListener {
public void onClick(View var1) {
PluginAgent.onClick(var1);
}
public void onClick(DialogInterface var1, int var2) {
PluginAgent.onClick(this, var1, var2);
}
public void onItemClick(AdapterView<?> var1, View var2, int var3, long var4) {
PluginAgent.onItemClick(this, var1, var2, var3, var4);
}
...
}
</code></pre><h4 id="Fragment生命周期追踪"><a href="#Fragment生命周期追踪" class="headerlink" title="Fragment生命周期追踪"></a>Fragment生命周期追踪</h4><p>在ViewID优化中,我们讲到Fragment节点的优化时,提到可通过重写Fragment的几个与生命周期相关的函数监听Fragment生命周期。这个过程除了使用代码埋点,也可借助插件自动完成:扫描class文件,定位Fragment的几个与生命周期相关的函数,自动插入代码。</p>
<p>目标函数(方法):</p>
<ul>
<li>onResume()V</li>
<li>onPause()V</li>
<li>setUserVisibleHint(Z)V</li>
<li>onHiddenChanged(Z)V</li>
</ul>
<p>具体实现:</p>
<ul>
<li>对app中指定包进行扫描,筛选出所有父类为下列其中之一的子类。以下是Fragment及系统内置的几个常见的Fragment派生类。</li>
</ul>
<pre><code>android/app/Fragment
android/app/DialogFragment
android/app/ListFragment
android/support/v4/app/Fragment
android/support/v4/app/DialogFragment
android/support/v4/app/ListFragment
</code></pre><ul>
<li>对这些Fragment子类的<code>onResumed</code>,<code>onPaused</code>,<code>onHiddenChanged</code>,<code>setFragmentUserVisibleHint</code>方法的字节码进行修改,添加数据采集代码。</li>
</ul>
<p>目标效果:</p>
<pre><code>public class BaseFragment extends Fragment {
public BaseFragment() {
}
public void onResume() {
super.onResume();
PluginAgent.onFragmentResume(this);
}
public void onHiddenChanged(boolean var1) {
super.onHiddenChanged(var1);
PluginAgent.onFragmentHiddenChanged(this);
}
public void onPause() {
super.onPause();
PluginAgent.onFragmentPause(this);
}
public void setUserVisibleHint(boolean var1) {
super.setUserVisibleHint(var1);
PluginAgent.setFragmentUserVisibleHint(this, var1);
}
}
</code></pre><h3 id="2-2-3-代理监听-vs-gradle插件"><a href="#2-2-3-代理监听-vs-gradle插件" class="headerlink" title="2.2.3 代理监听 vs gradle插件"></a>2.2.3 代理监听 vs gradle插件</h3><p>插件埋点方案,发生在编译期,当目标事件响应函数被执行时,才会触发我们插入的代码主动搜集事件。除了消耗一点编译速度,应用运行期间基本不受影响。</p>
<p>代理监听方案,由于事先并不清楚用户会触发哪些交互事件,所以需要为所有可交互的View设置代理,涉及到控件树遍历,因此性能略逊于gradle插件方案。但好在控件树遍历消耗的时间是毫秒级的,不会影响界面交互。</p>
<p>下面总结一下这两种方案的优缺点。</p>
<h4 id="1-代理监听方案"><a href="#1-代理监听方案" class="headerlink" title="(1) 代理监听方案"></a>(1) 代理监听方案</h4><p>缺点:</p>
<ul>
<li>遍历,被动等待被触发</li>
<li>拦截弹窗比较困难</li>
<li>Fragment生命周期需手动拦截</li>
</ul>
<p>优点:</p>
<ul>
<li>对于可点击但又未设置点击监听器的View,可设置监听器</li>
</ul>
<h4 id="2-gradle插件方案"><a href="#2-gradle插件方案" class="headerlink" title="(2) gradle插件方案"></a>(2) gradle插件方案</h4><p>优点:</p>
<ul>
<li>无需遍历,主动触发事件</li>
<li>主动拦截弹窗(待扩展)</li>
</ul>
<p>缺点:</p>
<ul>
<li>目前只支持Gradle1.5+构建工具</li>
</ul>
<h1 id="3-总结与展望"><a href="#3-总结与展望" class="headerlink" title="3 总结与展望"></a>3 总结与展望</h1><p>以上就是网易HubbleData在Android端的无埋点实践中总结的重点难点。还有一些边边角角的点就不一一细述了。</p>
<p>当然,我们的无埋点方案也并不完美,还有一些未解决的问题。例如,ViewID的构造及优化方案并不能适用于所有情况;通过无埋点搜集的数据也仅限控件的一些固有属性,并没有搜集到更有价值的业务数据…</p>
<p>网易HubbleData也将持续跟进业界先进埋点技术,及时升级埋点方案。后续针对比较有意思的技术点,也会继续整理出来分享给大家。</p>
<p>如果对该项目感兴趣,可以联系 [email protected] ,欢迎一起研究。</p>
]]></content>
<summary type="html">
<p>&#x7F51;&#x6613;HubbleData&#x7684;Android SDK&#x5728;&#x4EE3;&#x7801;&#x57CB;&#x70B9;&#x6574;&#x4F53;&#x67B6;&#x6784;&#x7684;&#x57FA;&#x7840;&#x4E0A;&#x65B0;&#x589E;&#x4E86;&#x65E0;&#x57CB;&#x70B9;&#x529F;&#x80FD;&#xFF0C;&#x672C;&#x6587;&#x4E3B;&#x8981;&#x9488;&#x5BF9;&#x7F51;&#x6613;HubbleData&#x5728;Android SDK&#x4E2D;&#x7684;&#x65E0;&#x57CB;&#x70B9;&#x5B9E;&#x8DF5;&#x8FDB;&#x884C;&#x5206;&#x4EAB;&#x3002;&#x91CD;&#x70B9;&#x6B7B;&#x78D5;&#x65E0;&#x57CB;&#x70B9;&#x4E24;&#x5927;&#x6838;&#x5FC3;&#x6280;&#x672F;&#xFF1A;1. View&#x7684;&#x552F;&#x4E00;ID&#xFF1B;2. &#x65E0;&#x57CB;&#x70B9;&#x5B9E;&#x73B0;&#xFF08;&#x4EE3;&#x7406;&#x76D1;&#x542C;&#x65B9;&#x6848;&#x548C;gradle&#x63D2;&#x4EF6;&#x65B9;&#x6848;&#xFF09;&#x3002;</p>
</summary>
<category term="android" scheme="http://NEYouFan.github.io/categories/android/"/>
<category term="埋点" scheme="http://NEYouFan.github.io/tags/%E5%9F%8B%E7%82%B9/"/>
</entry>
<entry>
<title>网易HubbleData无埋点SDK在iOS端的设计与实现</title>
<link href="http://NEYouFan.github.io/2017/04/19/ios/%E7%BD%91%E6%98%93HubbleData%E6%97%A0%E5%9F%8B%E7%82%B9SDK%E5%9C%A8iOS%E7%AB%AF%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0/"/>
<id>http://NEYouFan.github.io/2017/04/19/ios/网易HubbleData无埋点SDK在iOS端的设计与实现/</id>
<published>2017-04-18T16:00:00.000Z</published>
<updated>2017-04-19T08:46:39.000Z</updated>
<content type="html"><![CDATA[<p>网易HubbleData是一款探索用户行为的数据分析系统。本文主要介绍无埋点SDK在iOS端的设计与实现,分享在无埋点开发过程中的一些关键技术的,包括事件唯一ID的设计与无埋点的实现。<a id="more"></a></p>
<h3 id="0-引言"><a href="#0-引言" class="headerlink" title="0 引言 "></a><h2 id="0">0 引言 </h2></h3><p>最近在负责公司的HubbleData的埋点SDK的开发任务,产品的雏形其实在几年前就已经有了,公司内部的诸如考拉、易信、LOFTER、美学、漫画等多款产品都已接入使用。</p>
<p>下图给出HubbleData SDK某个应用的部分分析的展示页面:</p>
<p><strong>(1)概览示意图</strong> </p>
<center> <img src="http://ofwsr8cl0.bkt.clouddn.com/%E6%A6%82%E8%A7%88.jpg?imageView/2/w/600" alt="事件"></center>
<p><strong>(2)事件分析示意图</strong> </p>
<center> <img src="http://ofwsr8cl0.bkt.clouddn.com/%E4%BA%8B%E4%BB%B6.jpg?imageView/2/w/600" alt="事件"></center>
<p><strong>(3)实时分析示意图</strong></p>
<center> <img src="http://ofwsr8cl0.bkt.clouddn.com/%E5%AE%9E%E6%97%B6.jpg?imageView/2/w/600" alt="事件"></center>
<p>此外HubbleData平台还具备留存分析、漏斗分析、粘性分析、数据看板等多种功能,方便相关负责人员对产品用户行为进行进一步的探索分析。</p>
<p>老版本的SDK的设计是代码埋点实现的,虽然对于一些较为成熟的产品,代码埋点完全能够达到产品方的需求,但是对于一些新起步或者需频繁变更的需求的新产品等,考虑到其维护的成本大,代价高等缺点,HubbleData无埋点SDK的设计就显得尤为重要了。</p>
<p>本人主要负责iOS端无埋点以及可视化圈选的工作,文章主要系统讲解一下HubbleData无埋点SDK在iOS端的设计与实现和一些相关问题的解决,后续将针对整个埋点的实现流程与可视化圈选等内容再作分享。</p>
<h3 id="一、埋点简介"><a href="#一、埋点简介" class="headerlink" title="一、埋点简介"></a><h2 id="1">一、埋点简介</h2></h3><h4 id="1-1-三种埋点的实现方式简介"><a href="#1-1-三种埋点的实现方式简介" class="headerlink" title="1.1 三种埋点的实现方式简介"></a><h2 id="1.1">1.1 三种埋点的实现方式简介</h2></h4><p>埋点的方式分为三类:代码埋点、可视化埋点和无埋点。这里简单的介绍一下三种埋点方式:</p>
<p>(1) 代码埋点即是在代码的关键部位植入所要收集数据的N行代码,需要挖开产品本身,深入了解产品的业务逻辑及项目结构,下面代码模拟展示的即是点击提交订单的时候HubbleData SDK代码埋点;</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div><div class="line">12</div><div class="line">13</div></pre></td><td class="code"><pre><div class="line">- (<span class="keyword">void</span>)buyButtonClick:(<span class="keyword">id</span>)sender {</div><div class="line"> <span class="comment">//处理用户的业务逻辑</span></div><div class="line"> [<span class="keyword">self</span> handleOrder];</div><div class="line"> </div><div class="line"> <span class="comment">//用户自定义埋点属性</span></div><div class="line"> <span class="built_in">NSDictionary</span> *properties = @{<span class="string">@"商品名称"</span> : <span class="keyword">self</span>.productName,</div><div class="line"> <span class="string">@"商品价格"</span> : <span class="keyword">self</span>.productPrice,</div><div class="line"> <span class="string">@"商品类别"</span> : <span class="keyword">self</span>.productType,</div><div class="line"> <span class="string">@"购买时间"</span> : <span class="keyword">self</span>.productSaleTime};</div><div class="line"> </div><div class="line"> <span class="comment">//代码埋点(事件名称:EventID 事件属性:properties)</span></div><div class="line"> [[DATracker sharedTracker] trackEvent:<span class="string">@"EventID"</span> withAttributes:properties];</div><div class="line">}</div></pre></td></tr></table></figure>
<p>(2) 可视化埋点即用可视化交互的方式圈选出所要采集数据的控件,当用户行为产生时,即可收集到相应的埋点数据。相比于前面的代码埋点而言,可视化埋点能够解决代码埋点代价大成本高的问题,但是无法灵活的自定义埋点属性。</p>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/111.jpg?imageView/2/w/700" alt="可视化埋点流程"></center>
<p>(3) 无埋点也叫全埋点,即不需要用户主动埋点,可以收集用户所有的操作行为,同样采用可视化圈选,用户能够拿到所想采集的埋点数据,能够解决可视化圈选中数据不可回溯的问题。下图给出了无埋点数据收集的简单流程。</p>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/a001fa1c49889ec62e6aab38ea46d97e.jpg?imageView/2/w/550" alt="无埋点数据收集流程"></center>
<p>HubbleData SDK的设计主要是代码埋点结合无埋点的数据采集方式,其中也涉及到可视化埋点中的屏幕序列化及事件绑定机制,本文主要介绍一下无埋点的设计与实现。</p>
<h4 id="1-2-无埋点SDK设计详细流程"><a href="#1-2-无埋点SDK设计详细流程" class="headerlink" title="1.2 无埋点SDK设计详细流程"></a><h2 id="1.2">1.2 无埋点SDK设计详细流程</h2></h4><p>下图给出HubbleData无埋点SDK在iOS端的设计实现:</p>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/d0e3226a902872499b6f6a9b2daf0dce.jpg?imageView/2/w/600" alt="无埋点数据收集流程"></center>
<p>从上图可以看出,HubbleData的无埋点是在代码埋点的基础上实现的,所处无埋点的难点也就集中在以下三个方面:</p>
<pre><code>(1)自动获取埋点的EventID
(2)自动获取埋点的时机
(3)自动获取埋点需采集的属性
</code></pre><p>本文主要就这三个方面进行分析,第二部分主要讲一下事件唯一ID的确定,第三部分主要讲一下无埋点的采集的实现,主要是各种事件发生采集的时机以及待采集的属性的配置。</p>
<p>HubbleData SDK还涉及到许多其他功能,包括屏幕序列可视化、代码埋点、精准渠道追踪等,这里不再介绍,后面会陆续分享相关的技术实现。</p>
<h3 id="二、事件唯一ID的确定"><a href="#二、事件唯一ID的确定" class="headerlink" title="二、事件唯一ID的确定"></a><h2 id="2">二、事件唯一ID的确定</h2></h3><p>为了实现在可视化圈选时候的事件的唯一性,每一个无埋点的事件采集都必须有且仅有一个唯一的标识符来区分不同的事件。不同于代码埋点,用户可以自定义的配置自己所需的EventID,无埋点过程中,需要SDK自己配置每一个采集事件的EventID,通过可视化圈选的操作,筛选出相应的EventID所对应的数据信息。HubbleData采用的是构造view唯一标识字符串的方式去唯一的标识这样的一个事件,主要由view的层级结构path路径、该view的所在页面类名以及view所带的一些自身固定属性等构成,并通过SHA256编码来获取唯一的EventID。</p>
<p>下面将整体系统介绍一些事件唯一ID的生成过程。</p>
<h4 id="2-1-控件的层级结构path构造"><a href="#2-1-控件的层级结构path构造" class="headerlink" title="2.1 控件的层级结构path构造"></a><h2 id="2.1">2.1 控件的层级结构path构造</h2></h4><h5 id="2-1-1-普通view的层级结构path构造"><a href="#2-1-1-普通view的层级结构path构造" class="headerlink" title="2.1.1 普通view的层级结构path构造"></a><h2 id="2.1.1">2.1.1 普通view的层级结构path构造</h2></h5><p>层级结构path主要是基于页面的控件树构造而成,每个view都有superview与subviews的属性,将每一个view的superview作为树的父节点,将其subviews作为子节点,这样就能把整个app上的所有view组成一棵庞大的控件树,其中树的顶层是UIWindow,然后是每一个view节点依次向下展开。下图给出一个简单的控件树的结构图。</p>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/6ff5a9c6bf8a37837c94b7e36077f658.jpg?imageView/2/w/500" alt="空间树结构"></center>
<p>下面会详细介绍一下HubbleData的唯一标识路径的构造方式。</p>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/5987b2308a7e81e23f58f2c73c0b9013.jpg?imageView/2/w/360" alt="不同类"> <img src="http://ofwsr8cl0.bkt.clouddn.com/76cea2c617f7f694a1451072946b75e9.jpg?imageView/2/w/365" alt="同类"></center>
<p>像上图1所示,如果一个view的subviews中都是不同类型的,比如像下图图1所示的控件树那样,可以唯一标识UILabel和UIButton控件为:</p>
<pre><code>UIView_UILabel
UIView_UIButton
</code></pre><p>但是真正的页面是不会像理想中的所有控件都是不同类型的,可以说这种极端情况基本不存在,如果还是按照上述的方法来构造路径的话,两个UILabel都会被标识成UIView_UILabel,这显然无法区分两个控件。因此仅仅是每个控件节点的路径名称是无法唯一标识这个控件的,这里HubbleData加入了此控件节点在父视图中的<strong>index</strong>。比如上图2,可以将两个UILabel标识为:</p>
<pre><code>UIView(0)_UILabel(0)
UIView(0)_UILabel(1)
</code></pre><p>这里假设父视图是index为0的一个节点,这样就可以完全的区分出两个控件了。</p>
<p>那么剩下的问题就是每个UIView index索引值的确定。</p>
<p>每个UIView都有subviews属性,每一个子视图都有一个被addsubView的次序,其实要拿的这个index就是子视图被add的次序,那么该怎么拿到这个次序呢,在苹果的官方说明文档中,岁UIView的subviews属性,是这么介绍的:</p>
<pre><code>@property(nonatomic, readonly, copy) NSArray *subviews
You can use this property to retrieve the subviews associated with your custom view hierarchies.
The order of the subviews in the array reflects their visible order on the screen.
</code></pre><p>即每一个子视图在这个subviews数组中的索引就是HubbleData要拿的index。</p>
<p>针对复杂的视图形式,如下图所示,按照上述的层级结构路径构造方法得到的唯一层级路径为:</p>
<pre><code>UIView(0)_UILabel(0)
UIView(0)_UIButton(1)
UIView(0)_UIButton(2)
</code></pre><center><img src="http://ofwsr8cl0.bkt.clouddn.com/fe80e4df8e91d7161ce09ef825964398.jpg?imageView/2/w/500" alt="混合"></center>
<p>从上述的分析可知,按照上述介绍的方法进行view的唯一层级路径标识,对大部分的页面来说已经足够,但是对于一些更为灵活点的页面,由于一些业务需求等原因,开发人员经常会调用removeFromSuperview, insertSubview:atIndex:, insertSubview: belowSubview:等函数,都会极大的影响整个页面的subviews的索引值,比如现在我将上图所示的UILabel移动到两个UIButton的后边,那么得到的唯一层级路径为:</p>
<pre><code>UIView(0)_UIButton(0)
UIView(0)_UIButton(1)
UIView(0)_UILabel(2)
</code></pre><center><img src="http://ofwsr8cl0.bkt.clouddn.com/8104c8074bcc05f53046e14d57312f88.jpg?imageView/2/w/500" alt="混合"></center>
<p>可以发现,唯一层级路径已经被改变,但是整个页面却没有发生变化,不仅会产生新的事件(比如UIButton(0),UILabel(2)),连UIButton(1)事件的采集也会出错,即使是不同的事件,却得到了不同的eventID,所以需要提高构造的层级结构路径的稳健型。</p>
<p>正像刚刚提到的,不同类型的UIView不需要做index的区分,那么在获取这个index的时候,不是简单的从subviews这个数组中获取其对应的索引值,而是进行一个简单的同类归并再取索引值,一个很简单的处理。</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div></pre></td><td class="code"><pre><div class="line"><span class="keyword">for</span> (<span class="built_in">UIView</span> *view <span class="keyword">in</span> subviews) {</div><div class="line"> <span class="keyword">if</span> ([<span class="built_in">NSStringFromClass</span>([subview <span class="keyword">class</span>]) isEqualToString:<span class="built_in">NSStringFromClass</span>(<span class="keyword">class</span>)]) { <span class="comment">//class为待筛选的类</span></div><div class="line"> [array addObject:view];</div><div class="line"> }</div><div class="line">}</div></pre></td></tr></table></figure>
<p>这样就可以取得array中的index作为其真正的索引值,得到的层级结构路径为:</p>
<pre><code>UIView(0)_UILabel(0)
UIView(0)_UIButton(0)
UIView(0)_UIButton(1)
</code></pre><p>此时无论UIlabel的位置放在何处,都不会改变这个路径的构造形式,大大增加了稳健型。其实也能发现,这仅仅只能提高稳健型,并不能从根本上解决这个问题,比如若我把两个UIButton的顺序调换了,或者删除了第一个,此时依然会得到一些不准确的层级路径。此问题会后续解决,会逐步引入误差容量和相似度这个概念,即只要在误差范围内,则会进行进一步的匹配,具体的解决方案本篇不在介绍。</p>
<h5 id="2-1-2-几种特殊情况的处理"><a href="#2-1-2-几种特殊情况的处理" class="headerlink" title="2.1.2 几种特殊情况的处理"></a><h2 id="2.1.2">2.1.2 几种特殊情况的处理</h2></h5><p>2.1.1主要讲的是一些普通view的层级结构的path构造方式,但是有一些特殊情况需要特别的考虑处理:</p>
<ul>
<li><strong>UITableViewCell</strong></li>
</ul>
<p>由于UITableViewCell具有可复用的机制,当一个页面中在持续滚动的时候,cell在不断的复用,如果还使用2.1.1中介绍的方法来获取index索引值话,那么会引起整个页面无埋点数据采集的混乱。</p>
<p>当获取当前UITableViewCell的index时,可以使用indexPath参数进行替换,这个参数可准确的获取section和row的值,唯一的对应每一个cell。唯一层级路径的形式可以自定义配置,HubbleData的设置方式为:类名+(section: row:),下面给出一个示例:</p>
<pre><code>MyTableViewCell(section:0 row:7)
</code></pre><ul>
<li><strong>UICollectionViewCell</strong></li>
</ul>
<p>UICollectionViewCell的path生成原理同UITableViewCell,HubbleData的设置方式为:类名+(section:item:),下面给出一个示例:</p>
<pre><code>MyCollectionViewCell(section:0 item:7)
</code></pre><ul>
<li><strong>UIControl</strong></li>
</ul>
<p>其实UIButton也算是一种普通view的一种,大多数情况下,使用上述的层级结构path以及页面类名的组合能够唯一的确定当前UIControl的唯一标识符,但是有一种特殊的情况,当作为UINavigationItem时会出现特殊情况,下面的所给出的两个例子。</p>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/bar1.jpg?imageView/2/w/400" alt="bar1"></center>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/bar2.jpg?imageView/2/w/400" alt="bar2"></center>
<p>当点击第一个NavigationBar的右侧的按钮时,得到的层级路径为:</p>
<pre><code>...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton(1)
</code></pre><p>分析可知,左侧的设置按钮的索引为0,所以右侧的按钮索引为1。同时获取的当前页面为:UINavigationController。</p>
<p>当点击第二个页面的同一个类型的按钮时,即同样标有数字7的item时,此时得到的层级路径为:</p>
<pre><code>...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton(2)
</code></pre><p>可以发现此时的按钮的索引变成了2,已经不同于上述第一个NavigationBar的同一个按钮的层级路径了,经过分析,索引值为1的按钮是最右侧的表格的那个item,经过验证可以得到其层级路径:</p>
<pre><code>...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton(1)
</code></pre><p>获取的页面为:UINavigationController。</p>
<p>其实这种页面很常见,由于页面的切换,NavigationBar上的一些按钮的位置可能顺序会打乱,导致同一个功能的NavigationItem已经无法确定标识唯一,即使是获取了当前按钮所在的页面也无法区分,因为获取的都是UINavigationController。从上面的分析可以看出,这种情况甚至会导致严重错乱的数据采集。</p>
<p>其实仔细分析一下,如果分析得出该UIControl是在UINavigationBar上,则无需设置其相应的index值,即上述的所有navigationItem的层级结构路径都为:</p>
<pre><code>...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton
</code></pre><p>即都不做区分。</p>
<p>HubbleData采用增加一种新的属性来区分各个item,其实很明显可以看出来,这个item的执行的action肯定是不同的,所以取其action属性来区分,最终的区分形式如下:</p>
<pre><code>path(...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton)&actions(button1Click:)
path(...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton)&actions(button2Click:)
</code></pre><p>这样,HubbleData就可以准确的区分不同的item了,同时实现同一种功能的item,由于其action相同,所以也会准确的标识其唯一性。</p>
<ul>
<li><strong>UIAlertController</strong></li>
</ul>
<p>由于不同的UIAlertController在选择确定、取消等选项时,选取的进行唯一层级路径判定的view需要进行一定的处理,同时为了保证不同的UIAlertController处于同一位置的选项的埋点EventID不同,这里在构造唯一标志字符串的时候还要加入该UIAlertController的message和title信息。3.5小节中会进行相关无埋点采集的介绍。</p>
<ul>
<li><strong>viewController的嵌套</strong></li>
</ul>
<p>一般情况下,普通的view只需按照一般的层次路径收集index即可,但是当存在pageViewController时,如下图所示分别给出了一个横向滚动(以公司考拉app为例)和纵向滚动(以公司严选app为例)的app的截图的示例:</p>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/kaola.jpeg?imageView/2/w/250" alt="不同类"> <img src="http://ofwsr8cl0.bkt.clouddn.com/yanxuan.jpeg?imageView/2/w/250" alt="不同类"></center>
<p>其实可以看出,pageViewController会应用到各种各样app中,所以这类app在使用过程中的无埋点问题尤其要考虑。</p>
<h6 id="1-各个子页面的controller不同?"><a href="#1-各个子页面的controller不同?" class="headerlink" title="(1) 各个子页面的controller不同?"></a>(1) 各个子页面的controller不同?</h6><p>如果pageViewController中的各个子页面不同,虽然后续2.2节HubbleData会加入页面controller的信息来区分这些不同的子页面,但是可能会由于每个子页面加入的顺序不同,导致每次app进来的时候同一个页面的事件会获取不同的EventID,举例来说明一下,如上图1所示,比如前四个子页面是ViewController1, ViewController2, ViewController3, ViewController4,这类pageViewController除非设置四个子页面同时预加载出来,那么此时的获取的层级路径为:</p>
<pre><code>ViewController1对应路径为:superview(0)_subControllerView(0)
ViewController2对应路径为:superview(0)_subControllerView(1)
ViewController3对应路径为:superview(0)_subControllerView(2)
ViewController4对应路径为:superview(0)_subControllerView(3)
</code></pre><p>但是app基本都不会预加载出所有页面,对于用户不感兴趣的页面完全没必要一次性全部加载处理,只有当用户选择了该条目时,该对应的子页面才会加载出来,如果现在用户点击的顺序是ViewController1,ViewController3,ViewController4,ViewController2,由于addChildViewController或者addSubView的顺序的改变,那么此时获取的层级路径为:</p>
<pre><code>ViewController1对应路径为:superview(0)_subControllerView(0)
ViewController2对应路径为:superview(0)_subControllerView(3)
ViewController3对应路径为:superview(0)_subControllerView(1)
ViewController4对应路径为:superview(0)_subControllerView(2)
</code></pre><p>可以发现,index值变了,层级路径不唯一了,那么无埋点采集的EventID可能会由于用户选择页面顺序的不同而不同,造成埋点数据的混乱。</p>
<p>HubbleData对于此类页面的处理是,遇到此类页面,即不用index标注,所以会统一的标识成:</p>
<pre><code>ViewController1对应路径为:superview(0)_subControllerView
ViewController2对应路径为:superview(0)_subControllerView
ViewController3对应路径为:superview(0)_subControllerView
ViewController4对应路径为:superview(0)_subControllerView
</code></pre><p>后续可以通过不同的页面的controller的类名获取其不同的唯一标识字符串。</p>
<h6 id="2-各个子页面的controller相同?"><a href="#2-各个子页面的controller相同?" class="headerlink" title="(2) 各个子页面的controller相同?"></a>(2) 各个子页面的controller相同?</h6><p>其实做过此类页面的基本应该都熟悉,很多情况下子页面都是共用的,只不过是填入的model不同而已,那么遇到这种情况,如果是按照问题1的解决思路,即使按照2.2拿到了当前页面的controller,那么还是无法区分出这些页面,所以还是需要设置新的具有辨识度的index。</p>
<p>其实通过pageViewController可以发现,用户可以通过左右滑动或者上下滑动来切换子页面,说明所有的子页面都是嵌入在一个scrollView之中,那么就可以从这个scrollView入手,重新确定index。下面给出HubbleData解决这个问题的方法。</p>
<p>一开始想使用当前scrollView的contentOffset整除此pageViewController的页面宽度和高度所得到的值作为区分子页面的index,但是考虑到可能contentOffset的连续变化以及子页面横跨pageViewController整数倍宽度的边界时,可能会导致获取的index不唯一的情况,所以后来使用该子页面的起始位置整除pageViewController的相应地宽度和高度得到相应地index。具体的实现如下,其中controller为当前的页面:</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div></pre></td><td class="code"><pre><div class="line"><span class="keyword">if</span> (view == controller.view || view == controller.view.superview) {</div><div class="line"> <span class="built_in">NSInteger</span> index_x = view.center.x / [view superview].frame.size.width;</div><div class="line"> <span class="built_in">NSInteger</span> index_y = view.center.y / [view superview].frame.size.height;</div><div class="line"> <span class="built_in">NSString</span> *path = [<span class="built_in">NSString</span> stringWithFormat:<span class="string">@"%@(indexx:%ld indexy:%ld)"</span>, </div><div class="line"> <span class="built_in">NSStringFromClass</span>([view <span class="keyword">class</span>]), index_x, index_y];</div><div class="line"> }</div></pre></td></tr></table></figure>
<p>所以同样针对上述(1)所给出的四个ViewController1,优化后的到的唯一的标识为:</p>
<pre><code>ViewController1对应路径为:superview(0)_subControllerView(indexx:0 indexy:0)
ViewController2对应路径为:superview(0)_subControllerView(indexx:1 indexy:0)
ViewController3对应路径为:superview(0)_subControllerView(indexx:2 indexy:0)
ViewController4对应路径为:superview(0)_subControllerView(indexx:3 indexy:0)
</code></pre><p>这样即使各个子页面的controller相同,也能通过优化后的index来区分各个不同的子页面。当然这种只是针对嵌套scrollView的子页面的情况,不过能解决大部分的该类问题,对于一些其他的特殊情况等,需详细分析页面布局进行分析。</p>
<h4 id="2-2-当前页面controller的获取"><a href="#2-2-当前页面controller的获取" class="headerlink" title="2.2 当前页面controller的获取"></a><h2 id="2.2">2.2 当前页面controller的获取</h2></h4><p>看上去,大多数情况下2.1的view的层级结构path已经基本确定view的唯一标识字符串,但是普遍存在这么一种情况,当同一个页面跳转两个不同的页面时,假如这两个不同的页面上都取第一个按钮的层级路径,得到的简化后的结果都如下所示:</p>
<pre><code>.../UINavigationTransitionView(0)/UIViewControllerWrapperView(0)/UIView(0)/UIButton(0)
</code></pre><p>是无法进行这两个页面上的按钮区分的,其实页面的类名是区分的一个最直接的方式。HubbleData是按照下面的方法获取某个view所在的controller的类名的。</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div></pre></td><td class="code"><pre><div class="line">+ (<span class="built_in">NSString</span> *)currentController:(<span class="built_in">UIView</span> *)view {</div><div class="line"> <span class="built_in">NSString</span> *result = <span class="string">@""</span>;</div><div class="line"> <span class="built_in">UIResponder</span> *responder = [view nextResponder];</div><div class="line"> <span class="keyword">while</span> (responder && ![responder isKindOfClass:[<span class="built_in">UIViewController</span> <span class="keyword">class</span>]]) {</div><div class="line"> responder = [responder nextResponder];</div><div class="line"> }</div><div class="line"> <span class="keyword">if</span> (responder) {</div><div class="line"> result = <span class="built_in">NSStringFromClass</span>([responder <span class="keyword">class</span>]);</div><div class="line"> }</div><div class="line"> <span class="keyword">return</span> result;</div><div class="line">}</div></pre></td></tr></table></figure>
<p>将view的层级路径结合当前页面的名称,已经能够解决掉大部分的唯一标识字符串的问题了。</p>
<p>这里需要注意的一点是,当页面类型一样,只是填充的model不同时,比如浏览商品详情时,所进入的页面都是一个,只是model不同,目前HubbleData对这种情况暂时未做处理。后续可参考文章3.2节UIViewController的无埋点采集,对一些页面,用户可以自定义诸如screenTitle的字段,定义该页面的名称,比如screenTitle包含产品唯一ID时,此时将该字段加入唯一标识字符串中即可区分。目前这块还未做相关处理,这里只是提供一个简单的解决思路。</p>
<h3 id="三、无埋点的采集的实现"><a href="#三、无埋点的采集的实现" class="headerlink" title="三、无埋点的采集的实现"></a><h2 id="3">三、无埋点的采集的实现</h2></h3><h4 id="3-1-AOP-简介"><a href="#3-1-AOP-简介" class="headerlink" title="3.1 AOP 简介"></a><h2 id="3.1">3.1 AOP 简介</h2></h4><p>下面讲一下无埋点的具体实现,用到的主要是AOP(Aspect-Oriented-Programming),面向切面编程,面对的是处理过程中的某个步骤和方法。在运行时,动态的将代码插入到类的制定方法、指定位置上的编程思想就是面向切面编程。熟悉iOS Runtime的应该很清楚,相关的介绍文章也很多,这里不再过多的赘述。</p>
<p>HubbleData无埋点的实现主要就是借助AOP,hook对应类的方法,并在原实现代码的基础上插入自己定义的埋点的代码,当该类的被hook的函数执行时,就能实现无埋点数据采集的功能。下面给出HubbleData里面Method Swizzling的一个简单的实现。</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div><div class="line">12</div><div class="line">13</div><div class="line">14</div><div class="line">15</div><div class="line">16</div><div class="line">17</div><div class="line">18</div></pre></td><td class="code"><pre><div class="line">+ (<span class="keyword">void</span>)swizzleSelector:(SEL)selector onClass:(Class)<span class="keyword">class</span> withBlock:(swizzleBlock)block {</div><div class="line"> Method method = class_getInstanceMethod(<span class="keyword">class</span>, selector);</div><div class="line"> IMP originalMethod = method_getImplementation(aMethod);<span class="comment">//原函数的实现</span></div><div class="line"> IMP newMethod = (IMP)new_swizzledMethod;<span class="comment">//新函数的实现</span></div><div class="line"> ........ <span class="comment">//省去一些预操作的处理</span></div><div class="line"> method_setImplementation(method, newMethod);<span class="comment">//设置method指向newMethod的实现</span></div><div class="line"> ........ <span class="comment">//省去originalMethod、block等参数的处理操作</span></div><div class="line">}</div><div class="line"></div><div class="line"><span class="keyword">static</span> <span class="keyword">void</span> new_swizzledMethod(<span class="keyword">id</span> <span class="keyword">self</span>, SEL _cmd) { </div><div class="line"> ........ <span class="comment">//省去originalMethod、block等参数的处理操作</span></div><div class="line"> ((<span class="keyword">void</span>(*)(<span class="keyword">id</span>, SEL))originalMethod)(<span class="keyword">self</span>, _cmd); <span class="comment">//先执行原有的函数</span></div><div class="line"> </div><div class="line"> swizzleBlock block;</div><div class="line"> <span class="keyword">while</span> ((block = [blocks nextObject])) { <span class="comment">//再执行block</span></div><div class="line"> block(<span class="keyword">self</span>, _cmd);</div><div class="line"> }</div><div class="line">}</div></pre></td></tr></table></figure>
<p>上述代码只是给出了一个简单的实现的逻辑结构,new_swizzledMethod也只是selector没有参数的情况(除去self和_cmd),真正在埋点的处理过程需要考虑的情况比较多。</p>
<h4 id="3-2-UIViewController的无埋点采集"><a href="#3-2-UIViewController的无埋点采集" class="headerlink" title="3.2 UIViewController的无埋点采集"></a><h2 id="3.2">3.2 UIViewController的无埋点采集</h2></h4><p>主要是收集页面的生命周期,这里HubbleData采用的是hook UIViewController的viewWillAppear方法,按照3.1给出的方式:</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div></pre></td><td class="code"><pre><div class="line">[DASwizzler swizzleBoolSelector:<span class="keyword">@selector</span>(viewWillAppear:)</div><div class="line"> onClass:[<span class="built_in">UIViewController</span> <span class="keyword">class</span>]</div><div class="line"> withBlock:executeAppearBlock];</div></pre></td></tr></table></figure>
<p>当viewWillAppear函数执行时,插入埋点的代码。HubbleData的设计方法为:</p>
<p>EventID设置为固定的da_screen,即不会通过EventID来区分各个页面的信息,HubbleData将各个页面的区分信息放在了properties中,其中properties的设置为:</p>
<pre><code>(1) $screenName 为当前页面的名称;
(2) $screenTitle 为当前页面的title,可为空;
</code></pre><p>同时HubbleData SDK提供了一个protocol <dascreenautotracker></dascreenautotracker></p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div><div class="line">12</div></pre></td><td class="code"><pre><div class="line"><span class="class"><span class="keyword">@protocol</span> <span class="title">DAScreenAutoTracker</span></span></div><div class="line"><span class="keyword">@required</span></div><div class="line"><span class="comment">//返回当前页面的Title</span></div><div class="line">-(<span class="built_in">NSString</span> *)screenTitle;</div><div class="line"></div><div class="line"><span class="keyword">@optional</span></div><div class="line"><span class="comment">//实现该Protocol的Controller对象可以通过接口向自动采集的事件中加入属性</span></div><div class="line">-(<span class="built_in">NSDictionary</span> *)trackProperties;</div><div class="line"><span class="comment">//返回当前页面的Url</span></div><div class="line">-(<span class="built_in">NSString</span> *)screenUrl;</div><div class="line"></div><div class="line"><span class="keyword">@end</span></div></pre></td></tr></table></figure>
<p>即用户可以通过实现该protocol,HubbleData SDK会将screenTitle返回的值作为页面的名称,trackProperties返回的属性加入对应页面的da_screen事件的属性中,作为用户访问该页面时的事件属性,screenUrl返回的字符串作为页面的Url,用于做一些页面之间相互跳转的分析等。</p>
<p>同时增加了白名单设置,有一些UIViewController的信息用户不想采集,可以通过设置白名单的方式,将一些不想采集的UIViewController过滤掉,比如说SFBrowserRemoteViewController,UIInputWindowController等系统自带的一些。</p>
<p>最后会调用trackEvent记录该采集的事件,同上述介绍的代码埋点一样,调用的方法如下:</p>
<pre><code>[[DATracker sharedTracker] trackScreenEvent:@“da_screen” withAttributes:properties];
</code></pre><p>其中properties即为上述要采集的一些属性。</p>
<h4 id="3-3-UIControl的无埋点采集"><a href="#3-3-UIControl的无埋点采集" class="headerlink" title="3.3 UIControl的无埋点采集"></a><h2 id="3.3">3.3 UIControl的无埋点采集</h2></h4><p>针对UIControl,HubbleData采用的是hook UIControl的sendAction:to:forEvent:方法。由官方文档可知,在UIControl执行对应的action时都会首先调用sendAction:to:forEvent:方法,实现如下:</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div></pre></td><td class="code"><pre><div class="line">[DASwizzler swizzleSendActionSelector:<span class="keyword">@selector</span>(sendAction:to:forEvent:)</div><div class="line"> onClass:[<span class="built_in">UIControl</span> <span class="keyword">class</span>]</div><div class="line"> withBlock:executeBlock];</div></pre></td></tr></table></figure>
<p>考虑到UIControl的子类较多,所以HubbleData选取了其中使用较多的几种进行了特殊的分析:主要是UITextField、UIButton和UISwitch,其余的暂时未做特殊分析。具体的埋点的采集设计为:</p>
<p>无论是哪种UIControl,EventID均采用的是第三部分介绍的唯一标识字符串的SHA256编码值,但是相关采集properties有所差别。</p>
<h5 id="3-3-1-UITextField"><a href="#3-3-1-UITextField" class="headerlink" title="3.3.1 UITextField"></a><h2 id="3.3.1">3.3.1 UITextField</h2></h5><p>UITextField是UIControl的一个子类,由于UITextField涉及到用户的隐私比较多,比如用户名、密码、聊天文本等,所以HubbleData不会对此类的UITextField进行埋点的采集。</p>
<p>HubbleData主要采集的是UISearchBar中的UITextField,即UISearchBarTextField,并获取搜索的文本内容,这对于一些电商类的App来说,能够较好的分析用户感兴趣的商品等,这是作为HubbleData SDK无埋点的一个需求。</p>
<p>hook住sendAction:to:forEvent:后,如果对UISearchBarTextField的所有actions都进行hook的话,那么_searchFieldBeginEditing、_searchFieldEndEditing等所有的action发生的时候都会进行数据的采集,会采集到很多无用的信息,导致采集的数据混乱。HubbleData SDK只有当_searchFieldEndEditing action发生时才会进行埋点,收集的properties为:</p>
<pre><code>(1) type 为UIControl采集的事件类型,这里设置为searchBarEvent;
(2) page 为当前页面的名称,用于前端显示用;
(3) searchText 为_searchFieldEndEditing发生时采集到搜索框的搜索文字(此字段不为空);
</code></pre><p>这样就能对搜索框进行无埋点采集,并能收集搜索的文本内容。此方法只是在_searchFieldEndEditing发生时采集数据,有可能该action执行时并未尽兴真正的搜索操作,可能会与业务数据库的数据有出入,但是也能够较为准确的分析用户感兴趣的搜索内容。</p>
<h5 id="3-3-2-UIButton"><a href="#3-3-2-UIButton" class="headerlink" title="3.3.2 UIButton"></a><h2 id="3.3.2">3.3.2 UIButton</h2></h5><p>UIControl中使用最多最常见的是UIButton,因此对UIButton的采集非常重要。在使用UIButton的时候可以随意的设置其title等属性来表示业务逻辑的不同状态。这里可以举一个简单的例子:基本app的登录页面,在用户名和密码都未输入时、都输入时以及登录中各个状态,登录按钮的title、titleColor等属性可能都是不同的,即每一种button的样式都代表着一种样式,但是得到的EventID是相同的。针对此种情况,HubbleData会加入title、titleColor作为属性值,以方便后台进行进一步的分析。</p>
<p>当按钮的两种状态只是两种不同的背景图片时,比如微博或者微信的点赞等,其实是变换了一种背景图片,针对对这种情况处理,HubbleData则会获取图片的imageName作为其中一个属性。</p>
<pre><code>(1) type 为UIControl采集的事件类型,这里设置为buttonEvent;
(2) page 为当前页面的名称,用于前端显示用;
(3) title 为当前按钮的title;
(4) titleColor 为当前title的color,会转换成字符串的形式,rgba(r, g, b, alpha);
(5) imageName 为当前按钮的背景图片的name;
(6) frame 为UIButton的frame,用于分析同类元素,会转换成字符串的形式,rect(x, y, width, height);
</code></pre><p>可以看出,HubbleData还采集了该view的frame信息,主要是用来分析同类元素用的,下图给出一个简单的示例:</p>
<center><img src="http://ofwsr8cl0.bkt.clouddn.com/%E5%90%8C%E7%B1%BB%E5%85%83%E7%B4%A0.jpg?imageView/2/w/300" alt="bar1"></center>
<p>目前有六个已关注的产品,当想统计用户所有点赞的事件时,由于每个点赞的按钮都处于一个UITableViewCell中,在前面介绍的获取层级唯一路径UITableViewCell时的特殊处理,由于每个按钮所在的cell的row不同,所以获得的每个按钮的事件的唯一EventID都是不同的,这样后端在分析的时候,无法归类同类元素。当HubbleData给出frame时,后端可以根据frame归类出同一类按钮的事件,具体的归类策略这里不再介绍。</p>
<h5 id="3-3-3-UISwitch"><a href="#3-3-3-UISwitch" class="headerlink" title="3.3.3 UISwitch"></a><h2 id="3.3.3">3.3.3 UISwitch</h2></h5><p>类似于UIButton,只不过这里要采集switchState,即当前的开关状态,具体的采集属性为:</p>
<pre><code>(1) type 为UIControl采集的事件类型,这里设置为switchEvent;
(2) page 为当前页面的名称,用于前端显示用;
(3) switchState 为switch的开关状态;
</code></pre><h5 id="3-3-4-其余UIControl"><a href="#3-3-4-其余UIControl" class="headerlink" title="3.3.4 其余UIControl"></a><h2 id="3.3.4">3.3.4 其余UIControl</h2></h5><p>其余的只是采集type,page属性,目前未做过多的处理。</p>
<h4 id="3-4-UITableView和UICollectionView的无埋点采集"><a href="#3-4-UITableView和UICollectionView的无埋点采集" class="headerlink" title="3.4 UITableView和UICollectionView的无埋点采集"></a><h2 id="3.4">3.4 UITableView和UICollectionView的无埋点采集</h2></h4><p>针对UITableView和UICollectionView,HubbleData采用的是先hook UITableView和UICoolectionView的setDelegate:方法,然后找到对应的delegate,然后再hook delegate类中的tableView:didSelectRowAtIndexPath:方法和UICollectionView的collectionView:didSelectItemAtIndexPath:方法。这里以UITableView为例:</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div><div class="line">12</div><div class="line">13</div></pre></td><td class="code"><pre><div class="line"><span class="comment">//先hook setDelegate:方法</span></div><div class="line">[DASwizzler swizzleSelector:<span class="keyword">@selector</span>(setDelegate:)</div><div class="line"> onClass:[<span class="built_in">UITableView</span> <span class="keyword">class</span>]</div><div class="line"> withBlock:executeSetDelegateBlock];</div><div class="line"></div><div class="line"><span class="comment">//再hook delegate的tableView:didSelectRowAtIndexPath:方法</span></div><div class="line"><span class="keyword">void</span> (^executeSetDelegateBlock)(<span class="keyword">id</span>, SEL, <span class="keyword">id</span>) = ^(<span class="keyword">id</span> view, SEL command, <span class="keyword">id</span><<span class="built_in">UITableViewDelegate</span>> delegate) {</div><div class="line"> <span class="keyword">if</span> ([delegate respondsToSelector:<span class="keyword">@selector</span>(tableView:didSelectRowAtIndexPath:)]) {</div><div class="line"> [DASwizzler swizzleSelector:<span class="keyword">@selector</span>(tableView:didSelectRowAtIndexPath:)</div><div class="line"> onClass:[delegate <span class="keyword">class</span>]</div><div class="line"> withBlock:executeBlock];</div><div class="line"> }</div><div class="line"> };</div></pre></td></tr></table></figure>
<p>EventID按照上述介绍的方法获取,只不过这里要注意的是,获取的并不是UITableView的唯一标识字符串而是对应的点击的cell的唯一标识字符串。采集的properties为:</p>
<pre><code>(1) type 为UITableView采集的事件类型,这里设置为tableViewSelectEvent;
(2) page 为当前页面的名称,用于前端显示用;
(3) section 为点击的cell所在的section;
(4) row 为点击的cell所在的row;
</code></pre><h4 id="3-5-UIGestureRecognizer的无埋点采集"><a href="#3-5-UIGestureRecognizer的无埋点采集" class="headerlink" title="3.5 UIGestureRecognizer的无埋点采集"></a><h2 id="3.5">3.5 UIGestureRecognizer的无埋点采集</h2></h4><p>在iOS开发中,经常会使用一些手势来处理一些点击的操作,所以也有必要对UIGestureRecognizer进行hook。HubbleData 并不是直接针对UIGestureRecognizer这个类进行hook,而是hook UIView类的addGestureRecognizer:方法,实现如下:</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div></pre></td><td class="code"><pre><div class="line"><span class="keyword">void</span> (^executeBlock)(<span class="keyword">id</span>, SEL, <span class="keyword">id</span>) = ^(<span class="keyword">id</span> target, SEL command, <span class="keyword">id</span> arg) {</div><div class="line"> <span class="keyword">if</span> ([arg isKindOfClass:[<span class="built_in">UITapGestureRecognizer</span> <span class="keyword">class</span>]] ||</div><div class="line"> [arg isKindOfClass:[<span class="built_in">UILongPressGestureRecognizer</span> <span class="keyword">class</span>]]) {</div><div class="line"> [arg addTarget:<span class="keyword">self</span> action:<span class="keyword">@selector</span>(da_autoEventAction:)];<span class="comment">//在本类下添加一个action的实现</span></div><div class="line"> ...........</div><div class="line"> }</div><div class="line">};</div><div class="line"></div><div class="line">[DASwizzler swizzleSelector:<span class="keyword">@selector</span>(addGestureRecognizer:)</div><div class="line"> onClass:[<span class="built_in">UIView</span> <span class="keyword">class</span>]</div><div class="line"> withBlock:executeBlock];</div></pre></td></tr></table></figure>
<p>通过hook addGestureRecognizer:方法,可以得到该UIView所添加的UIGestureRecognizer,这里只对UITapGestureRecognizer和UILongPressGestureRecognizer进行处理,其他的手势暂未做处理。得到相应的UIGestureRecognizer,添加一个action,当该手势执行的时候,同样会执行该action,在action中执行埋点的操作。</p>
<p>这里获取的是UIGestureRecognizer所在的UIView的唯一标识标识字符串编码作为EventID,采集的属性为:</p>
<pre><code>(1) type 为UIGestureRecognizer采集的事件类型,这里设置为gestureTapEvent;
(2) page 为当前页面的名称,用于前端显示用;
</code></pre><p><strong>UIAlertController的特殊处理</strong></p>
<p>这里需要对UIAlertController做一个详细的说明,因为UIAlertController在点击诸如取消、确定的选项按钮时,也会进行手势的埋点采集,但是在iOS9和iOS10上略微有些区别。</p>
<p>这里先以iOS9为例,其target是作用在_UIAlertControllerView这个系统的私有类上的,如果直接对这个_UIAlertControllerView进行唯一标识字符串的构造,则取消和确定选项得到的EventID是相同的,这样将无法准确的分析出用户的选择,所以必须以每个选项view作为单独的唯一标识字符串进行分析才能准确区分。通过获取_UIAlertControllerView的_actionViews变量,就能得到各个选项的view,这里要做一个简单的点击坐标获取,判断所点击的区域位于的actionView,具体实现如下:</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div><div class="line">12</div><div class="line">13</div></pre></td><td class="code"><pre><div class="line"><span class="keyword">if</span> ([<span class="built_in">NSStringFromClass</span>([view <span class="keyword">class</span>]) isEqualToString:<span class="string">@"_UIAlertControllerView"</span>]) {</div><div class="line"> Ivar ivar = class_getInstanceVariable([view <span class="keyword">class</span>], <span class="string">"_actionViews"</span>);</div><div class="line"> <span class="built_in">NSMutableArray</span> *actionviews = object_getIvar(view, ivar);</div><div class="line"> <span class="keyword">for</span> (<span class="built_in">UIView</span> *actionview <span class="keyword">in</span> actionviews) {</div><div class="line"> <span class="built_in">CGPoint</span> point = [gesture locationInView:actionview];</div><div class="line"> <span class="keyword">if</span> ([<span class="built_in">NSStringFromClass</span>([actionview <span class="keyword">class</span>]) isEqualToString:<span class="string">@"_UIAlertControllerActionView"</span>] &&</div><div class="line"> point.x > <span class="number">0</span> && point.x < <span class="built_in">CGRectGetWidth</span>(actionview.bounds) &&</div><div class="line"> point.y > <span class="number">0</span> && point.y < <span class="built_in">CGRectGetHeight</span>(actionview.bounds) &&</div><div class="line"> gesture.state == <span class="built_in">UIGestureRecognizerStateBegan</span>) {</div><div class="line"> ......... <span class="comment">//进行埋点操作</span></div><div class="line"> }</div><div class="line"> }</div><div class="line">}</div></pre></td></tr></table></figure>
<p>这里在条件判断时设定gesture.state == UIGestureRecognizerStateBegan,是由于UILongPressGestureRecognizer会连续两次调用action,因此这里需要加入事件的状态进行区分,防止进行两次相同的数据采集。</p>
<p>iOS10下的UIAlertController的内部实现做了一些改动,其target变换成在_UIAlertControllerInterfaceActionGroupView这个系统的私有类上的,然后需要进行一定的处理,获取UIInterfaceActionSelectionTrackingController的_representationViews变量,遍历得到各个选项的view,具体实现如下:</p>
<figure class="highlight objc"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div><div class="line">10</div><div class="line">11</div><div class="line">12</div><div class="line">13</div><div class="line">14</div><div class="line">15</div><div class="line">16</div><div class="line">17</div><div class="line">18</div></pre></td><td class="code"><pre><div class="line"><span class="keyword">if</span> ([<span class="built_in">NSStringFromClass</span>([view <span class="keyword">class</span>]) isEqualToString:<span class="string">@"_UIAlertControllerInterfaceActionGroupView"</span>]) {</div><div class="line"> <span class="built_in">NSMutableArray</span> *targets = [gesture valueForKey:<span class="string">@"_targets"</span>];</div><div class="line"> <span class="keyword">id</span> targetContainer = targets[<span class="number">0</span>];</div><div class="line"> <span class="keyword">id</span> targetOfGesture = [targetContainer valueForKey:<span class="string">@"_target"</span>];</div><div class="line"> <span class="keyword">if</span> ([targetOfGesture isKindOfClass:[<span class="built_in">NSClassFromString</span>(<span class="string">@"UIInterfaceActionSelectionTrackingController"</span>) <span class="keyword">class</span>]]) {</div><div class="line"> Ivar ivar = class_getInstanceVariable([targetOfGesture <span class="keyword">class</span>], <span class="string">"_representationViews"</span>);</div><div class="line"> <span class="built_in">NSMutableArray</span> *representationViews = object_getIvar(targetOfGesture, ivar);</div><div class="line"> <span class="keyword">for</span> (<span class="built_in">UIView</span> *representationView <span class="keyword">in</span> representationViews) {</div><div class="line"> <span class="built_in">CGPoint</span> point = [gesture locationInView:representationView];</div><div class="line"> <span class="keyword">if</span> ([<span class="built_in">NSStringFromClass</span>([representationView <span class="keyword">class</span>]) isEqualToString:<span class="string">@"_UIInterfaceActionCustomViewRepresentationView"</span>] &&</div><div class="line"> point.x > <span class="number">0</span> && point.x < <span class="built_in">CGRectGetWidth</span>(representationView.bounds) &&</div><div class="line"> point.y > <span class="number">0</span> && point.y < <span class="built_in">CGRectGetHeight</span>(representationView.bounds) &&</div><div class="line"> gesture.state == <span class="built_in">UIGestureRecognizerStateBegan</span>) {</div><div class="line"> ......... <span class="comment">//进行埋点操作</span></div><div class="line"> }</div><div class="line"> }</div><div class="line"> }</div><div class="line">}</div></pre></td></tr></table></figure>
<p>通过上述的分析可以发现,这样虽然能区分同一个UIAlertController的不同的操作选项,但是可能无法区分出不同UIAlertController的处于同一位置的选项,所以这里还要加入UIAlertController额外的属性信息来区分。</p>
<p>前面也有提过,可以很容易的想到UIAlertController的message和title能够较好的进行区分,所以在原有的层级路径和当前页面的基础上,还要加上message和title以构成唯一标识字符串。给出一个样例:</p>
<pre><code>path(UIWindow(0)__UIAlertControllerView(0)_UIView(0)_UIView(0)_UIView(0)_UICollectionView(0)__UIAlertControllerCollectionViewCell(section:0 item:0)_UIView(0)__UIAlertControllerActionView(0))&controller(UIAlertController)&message(确认退出群聊吗?)&title(退群)
</code></pre><h3 id="四、总结"><a href="#四、总结" class="headerlink" title="四、总结"></a><h2 id="4">四、总结</h2></h3><p>文章主要介绍了HubbleData无埋点SDk在iOS端的设计与实现,涉及的主要内容:事件唯一ID的确定和部分无埋点的实现,当然在无埋点SDK的设计开发中还遇到了各种各样的问题。鉴于文章的篇幅已经较长,一些问题的解决以及关键技术的实现,比如精准渠道追踪、hook冲突解决、代码埋点的实现、屏幕序列化以及可视化圈选部分的内容,本篇文章不再介绍,将会在后续文章中继续介绍。</p>
<p>鉴于当前无埋点SDK仍在不断改进和完善中,待相关功能完善,将考虑开源HubbleData的无埋点SDK设计。由于一些原因,文章介绍的很多东西可能仍有待改进,欢迎大家一起探讨提高。</p>
]]></content>
<summary type="html">
<p>&#x7F51;&#x6613;HubbleData&#x662F;&#x4E00;&#x6B3E;&#x63A2;&#x7D22;&#x7528;&#x6237;&#x884C;&#x4E3A;&#x7684;&#x6570;&#x636E;&#x5206;&#x6790;&#x7CFB;&#x7EDF;&#x3002;&#x672C;&#x6587;&#x4E3B;&#x8981;&#x4ECB;&#x7ECD;&#x65E0;&#x57CB;&#x70B9;SDK&#x5728;iOS&#x7AEF;&#x7684;&#x8BBE;&#x8BA1;&#x4E0E;&#x5B9E;&#x73B0;&#xFF0C;&#x5206;&#x4EAB;&#x5728;&#x65E0;&#x57CB;&#x70B9;&#x5F00;&#x53D1;&#x8FC7;&#x7A0B;&#x4E2D;&#x7684;&#x4E00;&#x4E9B;&#x5173;&#x952E;&#x6280;&#x672F;&#x7684;&#xFF0C;&#x5305;&#x62EC;&#x4E8B;&#x4EF6;&#x552F;&#x4E00;ID&#x7684;&#x8BBE;&#x8BA1;&#x4E0E;&#x65E0;&#x57CB;&#x70B9;&#x7684;&#x5B9E;&#x73B0;&#x3002;</p>
</summary>
<category term="ios" scheme="http://NEYouFan.github.io/categories/ios/"/>
<category term="埋点" scheme="http://NEYouFan.github.io/tags/%E5%9F%8B%E7%82%B9/"/>
</entry>
<entry>
<title>网易NAPM Andorid SDK实现原理</title>
<link href="http://NEYouFan.github.io/2017/03/10/android/NAPM%20Android%20SDK/"/>
<id>http://NEYouFan.github.io/2017/03/10/android/NAPM Android SDK/</id>
<published>2017-03-09T16:00:00.000Z</published>
<updated>2017-03-10T05:38:50.000Z</updated>
<content type="html"><![CDATA[<p>NAPM 是网易的应用性能管理平台,采用非侵入的方式获取应用性能数据,可以实时展示多个维度的分析结果。本文主要给大家分享一下Android端SDK的实现原理。<a id="more"></a></p>
<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>APM(Application Performance Management),应用性能管理,主要是为了解决应用上线之后,性能问题难以发现、难以定位的问题,通过接入APM,可以实时了解应用在运行过程中的性能表现,快速定位和修复问题。</p>
<p>目前国内外有不少的应用性能管理平台,例如国外的 New Relic、AppDynamics,国内的听云、OneAPM,国内各大公司也都有自己的性能监控体系。</p>
<p>我们也开发了自己的平台 <strong>NAPM</strong> 供公司内部的产品使用,移动端目前主要采集了网络性能、交互性能和数据(数据库、JSON、Image)处理性能数据,网络性能目前主要采集了Http请求过程中的一些性能指标,比如响应时间、首包时间、DNS时间等,同时再结合机型、版本、地理位置、运营商、网络环境等多个维度,就可以使用户方便地了解应用在各种状态下的性能表现,从而及时发现问题,做出适当的调整,达到优化用户体验的目的。</p>
<p>下图是NAPM平台某个应用的多维分析展示界面</p>
<p> <img src="http://nos.netease.com/knowledge/7ab9e601-6dd5-4bf4-a646-f0d0347b2a02?imageView&thumbnail=500x0" alt="Alt pic"> </p>
<p>接下来主要给大家分享一下网易NAPM Android端SDK的实现原理。</p>
<h2 id="Android-APM基本原理"><a href="#Android-APM基本原理" class="headerlink" title="Android APM基本原理"></a>Android APM基本原理</h2><p>简单来说,一个APM平台的工作流程大致如下:在各端(移动端、前端、后端)采集性能数据,然后上传到后端进行建模、存储,由平台进行分析、挖掘,最后通过可视化的方式展示给用户。</p>
<p>移动端SDK实际上只是一个数据采集系统,负责收集并上传终端上产生的性能数据,大致可以划分为三个模块,最底层是数据采集模块,负责采集各种性能数据,采集到的数据经过简单的处理之后存储在内存或者数据库中,最上层是数据的消费模块,通常会将采集到的数据上传到后台,供平台存储、分析和展示,同时我们也支持将采集到的性能数据交给用户处理,方便用户挖掘有用信息。</p>
<p><img src="http://nos.netease.com/knowledge/28287b35-fb63-407c-972c-09237de6c680" alt="Alt pic"> </p>
<p>这里我们使用到了数据库,主要是因为存在一些情况,会导致采集到的数据不能实时发送至后台</p>
<ul>
<li>当网络状态较差,上传失败</li>
<li>当前无可用网络连接,无法上传</li>
<li>当前网络状态不满足上传条件(用户可以设置,比如仅在wifi的状态下上传数据)</li>
</ul>
<p>因此我们需要将数据进行存储,在合适的时机上传到后台,尽量保证数据的完整。</p>
<p>APM SDK的难点是数据的采集,手动埋点的方式无疑是行不通的,一方面代价太大且容易产生错误,另一方面对于没有源代码的第三方库我们无法直接修改,因而不能满足我们的需求。参考New Relic,我们选择在应用构建期间通过修改字节码的方式来进行代码插桩。</p>
<p>首先我们看一下应用构建的过程:</p>
<p><img src="http://nos.netease.com/knowledge/c7e631ec-a4b7-450e-bf87-a68e01f97e9a" alt="Alt pic"> </p>
<p>可以看到,应用中所有的class文件包括引用的第三方库中的class,都会经由dex过程,被转化为一个或者多个dex文件,正因为所有的class文件都会在dex这一步被处理,所以我们选择在这里进行字节码插桩。</p>
<h3 id="javaagent-Instrumentation"><a href="#javaagent-Instrumentation" class="headerlink" title="javaagent + Instrumentation"></a>javaagent + Instrumentation</h3><p>dex的过程是在dx程序中进行,而dx程序是由java实现的,这里我们使用到了javaagent技术,它可以使我们在JVM加载class文件前对字节码作出修改,这里简单介绍一下用法,主要分为两步</p>
<ol>
<li>实现一个javaagent</li>
<li>加载javaagent</li>
</ol>
<h4 id="实现javaagent"><a href="#实现javaagent" class="headerlink" title="实现javaagent"></a>实现javaagent</h4><p>javaagent的形式是一个jar包,根据javaagent的不同加载方式,对它的实现也有不同的要求。</p>
<p>如果javaagent是在虚拟机启动之后加载的,我们需要在它的manifest文件中指定Agent-Class属性,它的值是javaagent的实现类,这个实现类需要实现一个<em>agentmain</em>方法</p>
<pre><code>public static void agentmain(String agentArgs, Instrumentation instrumentation) {
//xxx
}
</code></pre><p><em>agentmain</em>会成为javaagent的入口,它会在javaagent被加载时调用。</p>
<p>但是如果javaagent是在JVM启动时通过命令行参数加载的,情况会不太一样,需要在它的manifest文件中指定Premain-Class属性,它的值是javaagent的实现类,这个实现类需要实现一个<em>premain</em>方法。</p>
<pre><code>public static void premain(String agentArgs, Instrumentation instrumentation) {
//xxx
}
</code></pre><p>我们知道,一个java程序的入口是main方法,而如果javaagent是在JVM启动时通过命令行参数加载的,虚拟机会在应用的main方法执行之前调用javaagent的<em>premain</em>方法,这应该也是<em>premain</em>方法名字的由来吧。</p>
<p>如果要支持两种加载方式,那么上述的条件需要同时满足。并且<strong>如果通过命令行参数在JVM启动时加载,agentmain方法不会再被调用</strong>。而在这个时候,应用中的类还没有被加载到虚拟机,所以给我们修改字节码带来了便利,因为一个类被加载之后,修改它的字节码会比较麻烦。</p>
<p>我们看到premain方法的第二个参数是一个Instrumentation的实例,Instrumentation接口有一个方法</p>
<pre><code>void addTransformer(ClassFileTransformer transformer, boolean canRetransform)
</code></pre><p>它会在虚拟机中注册一个ClassFileTransformer,transformer会在类加载时对类进行处理,ClassFileTransformer接口只定义了一个方法</p>
<pre><code>byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException
</code></pre><p>而这个方法的作用就是修改一个类的字节码,className是这个类的名称,classfileBuffer是这个类原本的字节码,而返回值是修改过后的字节码,如果没有修改,可以直接返回null。</p>
<p>因此,如果我们想在程序运行前改变一个类的字节码,可以在javaagent的premain方法中调用Instrumentation的实例的addTransformer方法,添加一个自定义的ClassFileTransformer。伪代码如下:</p>
<pre><code>//实现一个javaagent,注册自定义的ClassFileTransformer
public class MyJavaAgent {
public static void premain(String agentArgs, Instrumentation inst)
throws ClassNotFoundException, UnmodifiableClassException {
inst.addTransformer(new MyTransformer());
}
}
//实现一个 ClassFileTransformer,对xxx.xxx.xxx类的字节码进行修改
public class MyTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
if(name.equals("xxx.xxx.xxx")) {
return changeByteCode(bytes);
}
return null;
}
}
</code></pre><h4 id="加载javaagent"><a href="#加载javaagent" class="headerlink" title="加载javaagent"></a>加载javaagent</h4><p>前边已经提到了javaagent有两种加载方式</p>
<p>1) JVM启动时通过命令行参数加载javaagent</p>
<ul>
<li>manifest中需要指定Premain-Class属性</li>
<li>需要实现premain方法</li>
<li>premain方法会在程序的main方法之前执行</li>
<li><p>agentmain方式不会被调用</p>
<p> 通过命令行加载javaagent的形式如下:</p>
<pre><code>-javaagent:jarpath[=options]
</code></pre><p> 一个示例如下:</p>
<pre><code>java -javaagent:/path/to/myagent.jar -jar myapp.jar
</code></pre></li>
</ul>
<p>2) JVM启动后动态加载javaagent</p>
<ul>
<li>manifest中需要指定Agent-Class属性</li>
<li>需要实现agentmain方法</li>
<li><p>agentmain方法会在javaagent被加载时执行</p>
<p> 一般运行时加载agent的方法如下:</p>
<pre><code>String nameOfRunningVM = ManagementFactory.getRuntimeMXBean().getName();
int p = nameOfRunningVM.indexOf('@');
String pid = nameOfRunningVM.substring(0, p);
String jarFilePath = "/the/path/to/the/agent/jar";
try {
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(jarFilePath);
vm.detach();
} catch (Exception e) {
throw new RuntimeException(e);
}
</code></pre></li>
</ul>
<p>具体使用细节可参考VirtualMachine介绍<a href="http://docs.oracle.com/javase/7/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html" target="_blank" rel="external">http://docs.oracle.com/javase/7/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html</a></p>
<p>借助javaagent,我们可以将代码插桩的工作分为两个步骤:首先是获取到应用中所有的字节码,然后是对应用的字节码进行修改。</p>
<h3 id="获取应用字节码"><a href="#获取应用字节码" class="headerlink" title="获取应用字节码"></a>获取应用字节码</h3><p>首先从要解决的问题出发,上边提到我们会在dex的这一步去获取字节码,通过查看dx程序的代码,我们发现,在dex的过程中所有的class文件会经由<em>com.android.dx.command.dexer.Main</em>的<em>processClass()</em>方法进行处理,<em>processClass()</em>的代码如下:</p>
<pre><code>/**
* Processes one classfile.
*
* @param name {@code non-null;} name of the file, clipped such that it
* <i>should</i> correspond to the name of the class it contains
* @param bytes {@code non-null;} contents of the file
* @return whether processing was successful
*/
private boolean processClass(String name, byte[] bytes) {
if (! args.coreLibrary) {
checkClassName(name);
}
try {
new DirectClassFileConsumer(name, bytes, null).call(
new ClassParserTask(name, bytes).call());
} catch(Exception ex) {
throw new RuntimeException("Exception parsing classes", ex);
}
return true;
}
</code></pre><p>第一个参数是应用中一个类的名字,第二个参数就是这个类的字节码了,应用中所有的类,都会经过这个函数进行处理。</p>
<p>所以我们打算修改<em>com.android.dx.command.dexer.Main</em>的<em>processClass()</em>方法,从而获取到应用中的字节码,那么现在的问题就变成了如何修改<em>com.android.dx.command.dexer.Main</em>的<em>processClass()</em>方法。</p>
<p>掌握了javaagent,想要修改dx程序中<em>com.android.dx.command.dexer.Main</em>的字节码就变得比较容易了,我们需要实现一个javaagent,在其中注册一个ClassFileTransformer,在ClassFileTransformer的<em>transform()</em>方法中对<em>com.android.dx.command.dexer.Main</em>的字节码进行修改,最后在dx程序启动时将这个javaagent加载进去就好了。</p>
<pre><code>//实现一个 ClassFileTransformer,对com.android.dx.command.dexer.Main类的字节码进行修改
public class MainTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
if(name.equals("com/android/dx/command/dexer/Main")) {
return changeMainClassByteCode(bytes);
}
return null;
}
}
byte[] changeMainByteCode(byte[] bytes) {
//修改Main的 processClass() 方法
//返回修改后Main的字节码
}
</code></pre><p>如果你是通过命令行来手动构建应用的,到这里已经可以用上边的方式获取到应用中的字节码了,然而大多数人在开发Android的时候,并不会通过命令行去手动构建,而是通过使用一些构建工具,来完成自动化构建,而dx程序则是由构建工具启动的,所以我们面临的问题就是如何将javaagent加载到dx进程。</p>
<p>我们目前支持了ant构建和gradle构建,通过查看ant和gradle的代码,我们发现最终它们都会通过<em>java.lang.ProcessBuilder</em>的<em>start()</em>方法来启动dx进程。</p>
<p>通过查看<em>java.lang.ProcessBuilder</em>的代码,我们发现它有一个成员</p>
<pre><code>private List<String> command;
</code></pre><p>它是用来保存的是启动目标进程的命令和参数,我们需要做的就是在调用start()方法启动dx进程时,将加载javaagent的参数<em>(-javaagent:jarpath[=options])</em>添加到command中。</p>
<p>这里我们仍然使用javaagent来完成这个工作,我们需要实现另外一个javaagent,在其中注册一个另一个ClassFileTransformer,在它的transform方法中对<em>java.lang.ProcessBuilder</em>的字节码进行修改。</p>
<pre><code>//实现一个 ClassFileTransformer,对com.android.dx.command.dexer.Main类的字节码进行修改
public class ProcessBuilderTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
if(name.equals("java/lang/ProcessBuilder")) {
return changeProcessBuilderClassByteCode(bytes);
}
return null;
}
}
byte[] changeProcessBuilderClassByteCode(byte[] bytes) {
//修改ProcessBuilder的 start() 方法
//返回修改后ProcessBuilder的字节码
}
</code></pre><p>那么最终问题就变成了如何把这个javaagent加载到ant进程和gradle进程。</p>
<p>它们对应到了javaagent的两种加载方式</p>
<ul>
<li><p>ant构建-JVM启动时加载</p>
<pre><code>export ANT_OPTS="-javaagent:/path/to/agent.jar"(mac os环境,windows不太一样)
</code></pre><p> 在ant构建前进行上述配置,可以在启动ant时加载指定的javaagent,这里使用的是在JVM启动时通过命令行参数加载javaagent的方式。</p>
</li>
<li><p>gradle构建 -JVM启动后加载</p>
<p> 我们会编写一个gradle插件来完成javaagent的加载,当我们的插件被加载时,gradle进程已经运行起来了,因此只能通过动态的方式加载javaagent。</p>
</li>
</ul>
<p>因此,获取字节码的流程,大致如下图所示:<br> <img src="http://nos.netease.com/knowledge/95074e7d-bf8b-4971-9ed9-ccdec9bcc9fc?imageView&thumbnail=600x0" alt="Alt pic"> </p>
<p>这个过程中主要使用了两个javaagent,一个用来修改ProcessBuilder类,另一个用来修改Main类,涉及到的进程是ant构建进程或者gradle构建进程,以及由它们启动的dx进程。</p>
<p>对于gradle构建方式,需要注意一点,gradle plugin 在2.1.0之后的版本,支持dx in-process,它使得dx的过程可以直接在当前的gradle进程中执行,而不需要额外启动一个dx进程,从而缩短应用构建的时间。如果你在使用Android Studio构建应用的时候看到<em>To run dex in process, the Gradle daemon needs a larger heap. It currently has 910 MB</em>这样的一句话,它就是指导用户通过配置gradle daemon进程的堆大小来开启dx in-process特性的。</p>
<p>而这个新的特性,会给我们设置javaagent带来麻烦,不启动dx进程使得我们无法对dx进程设置javaagent,而在gradle进程中动态加载javaagent时,<em>com.android.dx.command.dexer.Main</em>类早已经加载过了,所以通过javaagent方式来获取字节码会变得十分困难。</p>
<p>幸运的是,gradle plugin 在1.5.0之后,提供了一个Transform API,它允许第三方插件操作编译后的class文件,而修改的时机正是在将这些字节码转换为dex文件之前,这里就不在展开讲解了,感兴趣的同学可以参考下这篇文章<a href="http://blog.csdn.net/sbsujjbcy/article/details/50839263" target="_blank" rel="external">http://blog.csdn.net/sbsujjbcy/article/details/50839263</a>。</p>
<h3 id="修改应用字节码"><a href="#修改应用字节码" class="headerlink" title="修改应用字节码"></a>修改应用字节码</h3><p>通过javaagent修改<em>com.android.dx.command.dexer.Main</em>和<em>java.lang.ProcessBuilder</em>,以及最终修改应用的字节码进行插桩,都需要对.class文件的格式以及java虚拟机有比较深入的了解,另外需要使用字节码操作工具来帮助我们对字节码进行改造,这里不详细讲解,只是推荐一些有用的的字节码操作框架和工具,后边可能会有同事做相关的分享。</p>
<ul>
<li><p><a href="http://www.ibm.com/developerworks/cn/java/j-lo-asm30/" target="_blank" rel="external">ASM</a>是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。</p>
</li>
<li><p><a href="http://jboss-javassist.github.io/javassist/" target="_blank" rel="external">Javassist</a>是一个开源的分析、编辑和创建Java字节码的类库,它提供了源码级别的API以及字节码级别的API,源码级别的API,直接使用java编码的形式,而不需要深入了解虚拟机指令,就能动态改变类的结构或者动态生成类。</p>
</li>
<li><p><a href="http://asm.ow2.org/eclipse/index.html" target="_blank" rel="external">Bytecode Outline plugin for Eclipse</a>是一个非常有用的eclipse 插件,可以查看当前正在编辑的java文件或者class文件的字节码。</p>
</li>
<li><p>如果需要逆向APK,查看字节码修改的效果,除了dex2jar外,再给大家推荐一个google的逆向工具<a href="https://github.com/google/enjarify" target="_blank" rel="external">enjarify</a>。</p>
</li>
</ul>
<h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>本文重点介绍了使用javaagent在应用打包过程中修改<em>com.android.dx.command.dexer.Main</em>和<em>java.lang.ProcessBuilder</em>的字节码,从而获取到应用的字节码,进行插桩的基本原理,并没有涉及so hook相关的原理,以后有机会的话会再做一次分享。</p>
]]></content>
<summary type="html">
<p>NAPM &#x662F;&#x7F51;&#x6613;&#x7684;&#x5E94;&#x7528;&#x6027;&#x80FD;&#x7BA1;&#x7406;&#x5E73;&#x53F0;&#xFF0C;&#x91C7;&#x7528;&#x975E;&#x4FB5;&#x5165;&#x7684;&#x65B9;&#x5F0F;&#x83B7;&#x53D6;&#x5E94;&#x7528;&#x6027;&#x80FD;&#x6570;&#x636E;&#xFF0C;&#x53EF;&#x4EE5;&#x5B9E;&#x65F6;&#x5C55;&#x793A;&#x591A;&#x4E2A;&#x7EF4;&#x5EA6;&#x7684;&#x5206;&#x6790;&#x7ED3;&#x679C;&#x3002;&#x672C;&#x6587;&#x4E3B;&#x8981;&#x7ED9;&#x5927;&#x5BB6;&#x5206;&#x4EAB;&#x4E00;&#x4E0B;Android&#x7AEF;SDK&#x7684;&#x5B9E;&#x73B0;&#x539F;&#x7406;&#x3002;</p>
</summary>
<category term="android" scheme="http://NEYouFan.github.io/categories/android/"/>
<category term="APM" scheme="http://NEYouFan.github.io/tags/APM/"/>
<category term="javaagent" scheme="http://NEYouFan.github.io/tags/javaagent/"/>
</entry>
<entry>
<title>谈谈MultiDex启动优化</title>
<link href="http://NEYouFan.github.io/2017/01/20/android/Multidex%E5%8A%A0%E9%80%9F/"/>
<id>http://NEYouFan.github.io/2017/01/20/android/Multidex加速/</id>
<published>2017-01-19T16:00:00.000Z</published>
<updated>2017-01-20T02:38:02.000Z</updated>
<content type="html"><![CDATA[<p>旧文一篇,移动开发前线推送了<a href="https://zhuanlan.zhihu.com/p/24305296" target="_blank" rel="external">MultiDex工作原理分析和优化方案</a>,也分享一下我们的MultiDex启动优化思路<a id="more"></a></p>
<h2 id="MultiDex存在的问题"><a href="#MultiDex存在的问题" class="headerlink" title="MultiDex存在的问题"></a>MultiDex存在的问题</h2><p>我们经常说的MultiDex,可以分成运行时和编译时两个部分:</p>
<ul>
<li><p>编译时的分包机制,将app中的class以某种策略分散在多个dex中,目的是减少为了第一个dex也就是main dex中包含的class</p>
</li>
<li><p>运行时: app启动时,虚拟机只加载main dex中的class。app启动以后,使用<em>Multidex.install</em> API,通过反射修改ClassLoader中的dexElements加载其他dex</p>
</li>
</ul>
<p>MultiDex机制的出现本身是为了避免出现app 65535问题的出现,但随着业务逻辑的增长,以及不合理的模块划分,可能会导致main dex的方法数也超出了65535,这就导致了<em>main dex capacity exceeded</em>异常。</p>
<p>此外,Multidex的接入额外还会对app的启动性能造成影响。Multidex在install时需要加载dex,首次启动时还需要做odex的转换,而这些都是在ui主线程中完成。<br>根据<a href="https://medium.com/@Macarse/lazy-loading-dex-files-d41f6f37df0e#.3vx8kle8j" target="_blank" rel="external"> Carlos Sessa</a>的测试,启用multidex后,4.4或以下的设备,app的启动时间平均会增加15%,更严重的情况,甚至在启动时候会出现了黑屏。</p>
<p>因此目前部分app采取的策略是,放弃掉Multidex的,而转为插件化的架构。通过将非核心模块的lazy load,来达到启动速度的优化,但我们需要明确的是,并不是所有app都适合插件化架构,为了实现启动加速将本耦合的业务逻辑硬生生拆解其实是本末倒置。</p>
<h2 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h2><h3 id="Multidex异步化"><a href="#Multidex异步化" class="headerlink" title="Multidex异步化"></a>Multidex异步化</h3><p>在Android的性能优化中,最常见的思路就是异步化,减少UI线程的工作。在应用的交互层面上,app启动时,几乎所有app都会有一个SplashActivity。在界面上展示欢迎页,在后台进行初始化的业务逻辑。这就给我们一个启发,我们可以将系统的初始化逻辑,延迟到我们的业务初始化时间点上。</p>
<p>更加具体的方式是,我们可以将Multidex.install这个操作异步化,保证主线程的正常进行,待dex加载完成后通知SplashActivity跳转到真正的业务主界面。</p>
<p>对MultiDex的加载进行异步化之后,我们还可以进行第二步,main dex大小的精简。</p>
<h3 id="Main-Dex精简"><a href="#Main-Dex精简" class="headerlink" title="Main Dex精简"></a>Main Dex精简</h3><p>我们先了解一下MultiDex分包的原理,Multidex会在入口Application的attachBaseContext,加载second dex,因此multidex分包的基本原则是:保证app启动需要的class放置在main dex上。在android gradle 1.5之后,multidex都通过一个MultidexTransform完成,分包过程可以分为三步:</p>
<ul>
<li>生成manifest_keep.txt</li>
</ul>
<p>MutidexTransform会解析出AndroidManifest.xml中所有的组件类:包括Activity、Service、Receiver以及ContentProvider,这些类将和Application入口类一起放在build/intermediates/multi-dex/{flavor}/{buildType}/manifest_keep.txt文件中</p>
<ul>
<li>生成maindexlist.txt文件</li>
</ul>
<p>查找manifest_keep.txt中所有类的直接引用类,具体的方式是遍历类的所有字段以及方法,查看方法的参数和返回值的类型,将其放保存在maindexlist.txt</p>
<ul>
<li>生成main dex</li>
</ul>
<p>将maindexlist.txt文件包含的所有class编译进main dex</p>
<p>从上面的分析中,我们可以确定的是,MultiDex的分包机制并不严密:</p>
<ul>
<li><p>MultiDex将AndroidManifest.xml中的所有组件都包含在了manifest_keep.txt。但app在首次启动时,并不需要加载所有的组件,而只是需要入口的activity,供其他app访问的service、contentprovider以及注册获取系统通知的receiver。MainDex中过多的组件信息反而可能导致了app启动过慢。</p>
</li>
<li><p>MultidexTransform只查找了manifest_keep.txt中类的直接引用类,间接引用类并没有出现在maindex中,特殊情况下,会出现<a href="http://blog.waynell.com/2015/04/19/android-multidex/" target="_blank" rel="external">NoClassDefFoundError</a>的异常,这时候开发者需要自行将需要的class添加到maindexlist.txt</p>
</li>
</ul>
<p>针对这两个缺陷,我们的优化思路是MultiDex的分包流程进行改进:</p>
<ul>
<li><p>使用SAX自行解析AndroidMainfest.xml,抽取出组件信息,将原始的Manifest_keep.txt内容替换掉,去除启动不需要的Activity组件,保证启动加载的类最小。</p>
</li>
<li><p>在gradle中添加multiDexExt扩展块,通过指定类名或通配符来设置必须编译在MainDex中类,在扩展块中指定的类都会被添加到maindexlist.txt文件汇中。</p>
</li>
</ul>
<pre><code>multiDexExt {
keepClasses += 'android.support.v7.app.AppCompatActivity'
keepClasses += 'android.support.v7.app.AppCompatDelegate'
keepClasses += 'android.support.v7.app.**'
}
</code></pre><p>额外需要提的一个细节是,为了保证以上精简生效。我们还需要开启dx工具的minimal-main-dex参数:</p>
<p><img src="http://coolpers.github.io/assets/posts/2015-04-08-multidex/dxhelp.png" alt=""></p>
<p>这个参数可以保证MainDex只包含maindexlist.txt文件中指定的类。但在gradle1.5到2.2之间,这个参数被默认关闭的,可以参考这篇文章:<a href="http://blog.csdn.net/lizhen3125/article/details/51911989?utm_source=itdadao&utm_medium=referral" target="_blank" rel="external">Gradle1.5.0之后如何控制dex包内的方法数上限?</a> ,直到gradle2.2之后,dx的minimal-main-dex才重新开放给了开发者。在gradle 2.0~2.1的版本阶段,我们通过挂载<a href="http://www.infoq.com/cn/articles/javaagent-illustrated/" target="_blank" rel="external">javaagent</a>的方式对dx过程进行了hook,重新开放minimal-main-dex参数,后面我们再出一篇文章来详细描述这个流程。 </p>
<h2 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h2><p>在main dex的分包过程中,maindex只包含了组件以及直接引用类。通过我们的优化进一步减少了maindex的大小,因此也增大了<a href="http://blog.waynell.com/2015/04/19/android-multidex/" target="_blank" rel="external">NoClassDefFoundError</a>的异常的可能,使用以上的优化思路做好测试,一旦发现启动失败,使用multiDexExt重新添加缺失的类型。</p>
]]></content>
<summary type="html">
<p>&#x65E7;&#x6587;&#x4E00;&#x7BC7;&#xFF0C;&#x79FB;&#x52A8;&#x5F00;&#x53D1;&#x524D;&#x7EBF;&#x63A8;&#x9001;&#x4E86;<a href="https://zhuanlan.zhihu.com/p/24305296">MultiDex&#x5DE5;&#x4F5C;&#x539F;&#x7406;&#x5206;&#x6790;&#x548C;&#x4F18;&#x5316;&#x65B9;&#x6848;</a>&#xFF0C;&#x4E5F;&#x5206;&#x4EAB;&#x4E00;&#x4E0B;&#x6211;&#x4EEC;&#x7684;MultiDex&#x542F;&#x52A8;&#x4F18;&#x5316;&#x601D;&#x8DEF;</p>
</summary>
<category term="android" scheme="http://NEYouFan.github.io/categories/android/"/>
<category term="性能优化" scheme="http://NEYouFan.github.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
</entry>
<entry>
<title>大白健康系统--iOS APP运行时Crash自动修复系统</title>
<link href="http://NEYouFan.github.io/2017/01/13/ios/BayMax_HTSafetyGuard/"/>
<id>http://NEYouFan.github.io/2017/01/13/ios/BayMax_HTSafetyGuard/</id>
<published>2017-01-12T16:00:00.000Z</published>
<updated>2017-01-13T02:39:17.000Z</updated>
<content type="html"><![CDATA[<p>关于app运行时的crash,我们是不是可以做的更多?是否可以做到实时抓取app运行时产生的crash,然后直接自动修复它,从而不让app crash呢?答案是:YES!<a id="more"></a></p>
<h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>大白(<em>Baymax</em>),迪士尼动画《超能陆战队》中的健康机器人,是一个体型胖胖的充气机器人,因呆萌的外表和善良的本质获得大家的喜爱,被称为“萌神”。</p>
<p><em>Baymax</em>项目是为了减少开发人员在开发中一些不规范的代码编写造成的内存泄露,界面卡顿,耗电等问题而来的一个监控系统。</p>
<p>现在Baymax迎来了它新的功能:APP运行时Crash自动防护功能,为app的流程顺利运行保驾护航!</p>
<p>下面将详细介绍一下<font color="#0099ff"> APP运行时Crash自动修复系统 </font>开发的目的,设计的原理以及使用的方法。</p>
<hr>
<h1 id="APP运行时Crash自动修复系统"><a href="#APP运行时Crash自动修复系统" class="headerlink" title="APP运行时Crash自动修复系统"></a>APP运行时Crash自动修复系统</h1><h2 id="Chapter-1-开发目的"><a href="#Chapter-1-开发目的" class="headerlink" title="Chapter 1 - 开发目的"></a>Chapter 1 - 开发目的</h2><p>是否存在这样的夜晚,当刚刚躺下准备美美的睡一觉的时候, 突然来一记夺命电话Call,一接起来发现是你老板!!!“小王啊,刚刚上线的X.X.X版本出问题了啊,怎么样操作会crash啊,导致新功能都无法使用了,快定位一下是什么原因,抓紧hotpatch修复一下啊!”。心里一万头草泥马呼啸而过,瞬间已经满头大汗的你却还要故作镇静地回答:“嗯,老板我马上去看看,一定努力解决问题!” 急忙打开电脑的你,知道今夜注定无眠了。</p>
<p>是否又存在这样的情形,你老板把大家都聚起来开了一个年初KPI目标制定会议,说到:“作为一个资深的技术团队,app性能是我们技术团队首抓的目标,其中很最要的一项就是app的崩溃率,去年我们app统计出来的崩溃率是千分之五,而我们的竞争对手的崩溃率只有万分之五,相差了10倍!今年我们要赶超他们,最起码也要和他们持平。” 你甚是赞同,但是你心里却又有点怀疑,对方的开发资源是我们的好几倍而且个个都是资深老司机,我们团队里却大多都是应届生小鲜肉,这KPI能完成么?</p>
<p>如果你遇到过以上的情况并且对此深表头痛的话,那么 <font color="#0099ff">大白健康系统–APP运行时Crash自动修复系统</font> 将会是你的不二选择!</p>
<p><font color="#0099ff">APP运行时Crash自动修复+捕获系统</font> 的设计初衷,就是为了降低app的crash率。利用Objective-C语言的动态特性,采用<em>AOP</em>(Aspect Oriented Programming) 面向切面编程的设计思想,做到无痕植入。能够<strong>自动在app运行时实时捕获导致app崩溃的破环因子,然后通过特定的技术手段去化解这些破坏因子,使app免于崩溃,照样可以继续正常运行,为app的持续运转保驾护航</strong>。</p>
<hr>
<h2 id="Chapter-2-功能简介"><a href="#Chapter-2-功能简介" class="headerlink" title="Chapter 2 - 功能简介"></a>Chapter 2 - 功能简介</h2><p><font color="#0099ff">APP运行时Crash自动修复系统</font> 的主要功能,可以用一句话来简单的概括:<strong>对业务代码的零侵入性地将原本会导致app崩溃的crash抓取住,消灭掉,保证app继续正常地运行,再将crash的具体信息提取出来,实时返回给用户</strong>。</p>
<p>通过下面的一个小例子就可以很直观的体现出来系统的作用:</p>
<p>调用以下的一段代码</p>
<figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div></pre></td><td class="code"><pre><div class="line">//test code</div><div class="line"></div><div class="line">UIButton * testObj = [[UIButton alloc] init];</div><div class="line">[testObj performSelector:@selector(someMethod:)];</div></pre></td></tr></table></figure>
<p>结果肯定会导致app的崩溃,因为testObj是一个UIButton对象,而UIButton并没有实现 someMethod: 这个方法,所以向testObj发送someMethod:这个方法的时候,将会导致该方法无法在相关的方法列表里找到,最终导致app的crash。</p>
<p>但是通过我们的crash防护系统,调用这段代码时app并不会崩溃,同时XCode的Console如下:<br><img src="http://7xqcm1.com1.z0.glb.clouddn.com/crash_catch_info.png" alt="image"><br>可见对应的crash的信息(crash类型,原因,调用栈信息)均可以完整的打印在XCode的Console中。</p>
<p>说明我们的大白系统已经捕捉到了这个crash,将该crash消灭掉并且吐出来该crash的完整信息。</p>
<p>当然目前系统的功能并没有强大到可以把所有的crash都处理掉,不过一些常见的高频次发生的crash,系统均会针对他们一一处理。目前可以处理掉的crash类型具体有以下几种:</p>
<ul>
<li>unrecognized selector crash</li>
<li>KVO crash</li>
<li>NSNotification crash</li>
<li>NSTimer crash</li>
<li>Container crash(数组越界,插nil等)</li>
<li>NSString crash (字符串操作的crash)</li>
<li>Bad Access crash (野指针)</li>
<li>UI not on Main Thread Crash (非主线程刷UI(机制待改善))</li>
</ul>
<p>对于每种类型的crash,安全系统都采取不同的方式,进行了对应的处理。 具体的处理细节详见下章:<strong>Chapter 3 - 实现原理</strong></p>
<hr>
<h2 id="Chapter-3-实现原理"><a href="#Chapter-3-实现原理" class="headerlink" title="Chapter 3 - 实现原理"></a>Chapter 3 - 实现原理</h2><p>前面已经提过,目前的安全防护系统可以覆盖到8中类型的Crash,分别为:</p>
<ul>
<li><a href="#unrecognized selector">unrecognized selector crash</a></li>
<li><a href="#kvo">KVO crash</a></li>
<li><a href="#notification">NSNotification crash</a></li>
<li><a href="#NSTimer">NSTimer crash</a></li>
<li><a href="#container">Container crash(数组越界,插nil等)</a></li>
<li><a href="#NSString">NSString crash (字符串操作的crash)</a></li>
<li><a href="#badaccess">Bad Access crash (野指针)</a></li>
<li><a href="#ui not on mainthread">UI not on Main Thread Crash (非主线程刷UI (机制待改善))</a></li>
</ul>
<p>接下来将一一详细介绍这8种类型的Crash的防护的实现的具体原理:</p>
<h3 id="3-1-Unrecognized-Selector类型crash防护(Unrecognized-Selector)"><a href="#3-1-Unrecognized-Selector类型crash防护(Unrecognized-Selector)" class="headerlink" title="3.1 Unrecognized Selector类型crash防护(Unrecognized Selector)"></a><span id="unrecognized selector">3.1 Unrecognized Selector类型crash防护(Unrecognized Selector)</span></h3><h4 id="3-1-1-unrecognized-selector-crash-产生原因"><a href="#3-1-1-unrecognized-selector-crash-产生原因" class="headerlink" title="3.1.1 unrecognized selector crash 产生原因"></a>3.1.1 unrecognized selector crash 产生原因</h4><p>unrecognized selector类型的crash在app众多的crash类型中占着比较大的成分,通常是因为一个对象调用了一个不属于它方法的方法导致的。 </p>
<p>例如调用以下一段代码就会产生crash</p>
<figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div></pre></td><td class="code"><pre><div class="line">//test code</div><div class="line"></div><div class="line">UIButton * testObj = [[UIButton alloc] init];</div><div class="line">[testObj performSelector:@selector(someMethod:)];</div></pre></td></tr></table></figure>
<p>具体crash时的表现见下图:</p>
<p><img src="http://7xqcm1.com1.z0.glb.clouddn.com/unrecognized_selector.png" alt="image"></p>
<p>要解决这中类型的crash,我们需要先了解清楚它产生的具体原因和流程。</p>
<h4 id="3-1-2-方法调用流程"><a href="#3-1-2-方法调用流程" class="headerlink" title="3.1.2 方法调用流程"></a>3.1.2 方法调用流程</h4><p>让我们看一下方法调用在运行时的过程。</p>
<p>runtime中具体的方法调用流程大致如下:</p>
<p><strong>1.首先,在相应操作的对象中的缓存方法列表中找调用的方法,如果找到,转向相应实现并执行。</strong></p>
<p><strong>2.如果没找到,在相应操作的对象中的方法列表中找调用的方法,如果找到,转向相应实现执行</strong></p>
<p><strong>3.如果没找到,去父类指针所指向的对象中执行1,2.</strong></p>
<p><strong>4.以此类推,如果一直到根类还没找到,转向拦截调用,走消息转发机制。</strong></p>
<p><strong>5.如果没有重写拦截调用的方法,程序报错。</strong></p>
<h4 id="3-1-3-拦截调用"><a href="#3-1-3-拦截调用" class="headerlink" title="3.1.3 拦截调用"></a>3.1.3 拦截调用</h4><p>在方法调用中说到了,如果没有找到方法就会转向拦截调用。</p>
<p>那么什么是拦截调用呢?</p>
<p>拦截调用就是,在找不到调用的方法程序崩溃之前,你有机会通过重写NSObject的四个方法来处理:</p>
<figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div><div class="line">9</div></pre></td><td class="code"><pre><div class="line">+ (BOOL)resolveClassMethod:(SEL)sel;</div><div class="line"></div><div class="line">+ (BOOL)resolveInstanceMethod:(SEL)sel;</div><div class="line"></div><div class="line">//后两个方法需要转发到其他的类处理</div><div class="line"></div><div class="line">- (id)forwardingTargetForSelector:(SEL)aSelector;</div><div class="line"></div><div class="line">- (void)forwardInvocation:(NSInvocation *)anInvocation;</div></pre></td></tr></table></figure>
<p>拦截调用的整个流程即Objective——C的消息转发机制。其具体流程如下图:</p>
<p><img src="http://7xqcm1.com1.z0.glb.clouddn.com/message_forwarding.png" alt="image"></p>
<p>由上图可见,在一个函数找不到时,runtime提供了三种方式去补救:</p>
<p><strong>1、调用resolveInstanceMethod给个机会让类添加这个实现这个函数</strong></p>
<p><strong>2、调用forwardingTargetForSelector让别的对象去执行这个函数</strong></p>
<p><strong>3、调用forwardInvocation(函数执行器)灵活的将目标函数以其他形式执行。</strong></p>
<p><strong>如果都不中,调用doesNotRecognizeSelector抛出异常。</strong></p>
<h4 id="3-1-4-unrecognized-selector-crash-防护方案"><a href="#3-1-4-unrecognized-selector-crash-防护方案" class="headerlink" title="3.1.4 unrecognized selector crash 防护方案"></a>3.1.4 unrecognized selector crash 防护方案</h4><p>既然可以补救,我们完全也可以利用消息转发机制来做文章。那么问题来了,在这三个步骤里面,选择哪一步去改造比较合适呢。</p>
<p>这里我们选择了第二步forwardingTargetForSelector来做文章。原因如下:</p>
<ol>
<li>resolveInstanceMethod 需要在类的本身上动态添加它本身不存在的方法,这些方法对于该类本身来说冗余的</li>
<li>forwardInvocation可以通过NSInvocation的形式将消息转发给多个对象,但是其开销较大,需要创建新的NSInvocation对象,并且forwardInvocation的函数经常被使用者调用,来做多层消息转发选择机制,不适合多次重写</li>
<li>forwardingTargetForSelector可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写</li>
</ol>
<p>选择了forwardingTargetForSelector之后,可以将NSObject的该方法重写,做以下几步的处理:</p>
<p><strong>1.动态创建一个桩类</strong></p>
<p><strong>2.动态为桩类添加对应的Selector,用一个通用的返回0的函数来实现该SEL的IMP</strong></p>
<p><strong>3.将消息直接转发到这个桩类对象上。</strong></p>
<p>流程图如下:</p>
<p><img src="http://7xqcm1.com1.z0.glb.clouddn.com/unrecognized_selector_flow.png" alt="image"></p>
<p>注意如果对象的类本事如果重写了forwardInvocation方法的话,就不应该对forwardingTargetForSelector进行重写了,否则会影响到该类型的对象原本的消息转发流程。</p>
<p>通过重写NSObject的forwardingTargetForSelector方法,我们就可以将无法识别的方法进行拦截并且将消息转发到安全的<em>桩类</em>对象中,从而可以使app继续正常运行。</p>
<h3 id="3-2-KVO类型crash防护(KVO)"><a href="#3-2-KVO类型crash防护(KVO)" class="headerlink" title="3.2 KVO类型crash防护(KVO)"></a><span id="kvo">3.2 KVO类型crash防护(KVO)</span></h3><h4 id="3-2-1-KVO-crash-产生原因"><a href="#3-2-1-KVO-crash-产生原因" class="headerlink" title="3.2.1 KVO crash 产生原因"></a>3.2.1 KVO crash 产生原因</h4><p>KVO,即:Key-Value Observing,它提供一种机制,当指定的对象的属性被修改后,则对象就会接受收到通知。简单的说就是每次指定的被观察的对象的属性被修改后,KVO就会自动通知相应的观察者了。</p>
<p>KVO机制在iOS的很多开发场景中都会被使用到。不过如果一不小心使用不当的话,会导致大量的crash问题。所以如果能找到一种方法能够自动抓取这些由于开发者粗心所导致的KVO Crash问题的话,是有一定的价值的。</p>
<p>首先我们来看看通过会导致KVO Crash的两种情形:</p>
<ol>
<li>KVO的被观察者dealloc时仍然注册着KVO导致的crash,见下图</li>
</ol>
<p><img src="http://7xqcm1.com1.z0.glb.clouddn.com/kvo_crash_console_type1.png" alt="image"></p>
<ol>
<li>添加KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)导致的crash,见下图</li>
</ol>
<p><img src="http://7xqcm1.com1.z0.glb.clouddn.com/kvo_crash_console_type2.png" alt="image"></p>
<h4 id="3-2-2-KVO-crash-防护方案"><a href="#3-2-2-KVO-crash-防护方案" class="headerlink" title="3.2.2 KVO crash 防护方案"></a>3.2.2 KVO crash 防护方案</h4><p>通常一个对象的KVO关系图如下:</p>
<p><img src="http://7xqcm1.com1.z0.glb.clouddn.com/kvo_original.png" alt="image"></p>
<p>一个被观察的对象(Observed Object)上有若干个观察者(Observer),每个观察者又观察若干条KeyPath。</p>
<p>如果观察者和keypath的数量一多,很容易理不清楚被观察对象整个KVO关系,导致被观察者在dealloc的时候,还残存着一些关系没有被注销。 同时还会导致KVO注册观察者与移除观察者不匹配的情况发生。</p>
<p>笔者曾经还遇到过在多线程的情况下,导致KVO重复添加观察者或移除观察者的情况。这类问题通常多数发生的比较隐蔽,不容易从代码的层面去排查。</p>
<p>由上可见多数由于KVO而导致的crash原因是由于被观察对象的KVO关系图混乱导致。那么如何来管理混乱的KVO关系呢。可以让被观察对象持有一个KVO的delegate,所有和KVO相关的操作均通过delegate来进行管理,delegate通过建立一张map来维护KVO整个关系。如下图:</p>
<p><img src="http://7xqcm1.com1.z0.glb.clouddn.com/kvo_modified.png" alt="image"></p>
<p>这样做的好处有两个:</p>
<p>1.如果出现KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)的情况,delegate可以直接阻止这些非正常的操作。</p>
<p>2.被观察对象dealloc之前,可以通过delegate自动将与自己有关的KVO关系都注销掉,避免了KVO的被观察者dealloc时仍然注册着KVO导致的crash。</p>
<p>被swizzle的方法分别是:</p>
<figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div><div class="line">5</div><div class="line">6</div><div class="line">7</div><div class="line">8</div></pre></td><td class="code"><pre><div class="line">- (void)addObserver:(NSObject *)observer </div><div class="line"> forKeyPath:(NSString *)keyPath</div><div class="line"> options:(NSKeyValueObservingOptions)options </div><div class="line"> context:(nullable void *)context;</div><div class="line"></div><div class="line">- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;</div><div class="line"></div><div class="line">- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;</div></pre></td></tr></table></figure>
<p>关于</p>
<figure class="highlight plain"><table><tr><td class="gutter"><pre><div class="line">1</div><div class="line">2</div><div class="line">3</div><div class="line">4</div></pre></td><td class="code"><pre><div class="line">- (void)addObserver:(NSObject *)observer</div><div class="line"> forKeyPath:(NSString *)keyPath</div><div class="line"> options:(NSKeyValueObservingOptions)options</div><div class="line"> context:(void *)context</div></pre></td></tr></table></figure>