first commit
This commit is contained in:
243
专栏/Android开发高手课/20UI优化(上):UI渲染的几个关键概念.md
Normal file
243
专栏/Android开发高手课/20UI优化(上):UI渲染的几个关键概念.md
Normal file
@ -0,0 +1,243 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 UI 优化(上):UI 渲染的几个关键概念
|
||||
在开始今天的学习前,我祝各位同学新春快乐、工作顺利、身体健康、阖家幸福,绍文给您拜年啦!
|
||||
|
||||
|
||||
每个做UI的Android开发,上辈子都是折翼的天使。
|
||||
|
||||
|
||||
多年来,有那么一群苦逼的Android开发,他们饱受碎片化之苦,面对着各式各样的手机屏幕尺寸和分辨率,还要与“凶残”的产品和UI设计师过招,日复一日、年复一年的做着UI适配和优化工作,蹉跎着青春的岁月。更加不幸的是,最近两年这个趋势似乎还愈演愈烈:刘海屏、全面屏,还有即将推出的柔性折叠屏,UI适配将变得越来越复杂。
|
||||
|
||||
UI优化究竟指的是什么呢?我认为所谓的UI优化,应该包含两个方面:一个是效率的提升,我们可以非常高效地把UI的设计图转化成应用界面,并且保证UI界面在不同尺寸和分辨率的手机上都是一致的;另一个是性能的提升,在正确实现复杂、炫酷的UI设计的同时,需要保证用户有流畅的体验。
|
||||
|
||||
那如何将我们从无穷无尽的UI适配中拯救出来呢?
|
||||
|
||||
UI渲染的背景知识
|
||||
|
||||
究竟什么是UI渲染呢?Android的图形渲染框架十分复杂,不同版本的差异也比较大。但是无论怎么样,它们都是为了将我们代码中的View或者元素显示到屏幕中。
|
||||
|
||||
而屏幕作为直接面对用户的手机硬件,类似厚度、色彩、功耗等都是厂家非常关注的。从功能机小小的黑白屏,到现在超大的全面屏,我们先来看手机屏幕的发展历程。
|
||||
|
||||
1. 屏幕与适配
|
||||
|
||||
作为消费者来说,通常会比较关注屏幕的尺寸、分辨率以及厚度这些指标。Android的碎片化问题令人痛心疾首,屏幕的差异正是碎片化问题的“中心”。屏幕的尺寸从3英寸到10英寸,分辨率从320到1920应有尽有,对我们UI适配造成很大困难。
|
||||
|
||||
除此之外,材质也是屏幕至关重要的一个评判因素。目前智能手机主流的屏幕可分为两大类:一种是LCD(Liquid Crystal Display),即液晶显示器;另一种是OLED(Organic Light-Emitting Diode的)即有机发光二极管。
|
||||
|
||||
最新的旗舰机例如iPhone XS Max和华为Mate 20 Pro使用的都是OLED屏幕。相比LCD屏幕,OLED屏幕在色彩、可弯曲程度、厚度以及耗电都有优势。正因为这些优势,全面屏、曲面屏以及未来的柔性折叠屏,使用的都是OLED材质。关于OLED与LCD的具体差别,你可以参考《OLED和LCD区别》和《手机屏幕的前世今生,可能比你想的还精彩》。今年柔性折叠屏肯定是最大的热点,不过OLED的单价成本要比LCD高很多。
|
||||
|
||||
对于屏幕碎片化的问题,Android推荐使用dp作为尺寸单位来适配UI,因此每个Android开发都应该很清楚px、dp、dpi、ppi、density这些概念。
|
||||
|
||||
|
||||
|
||||
通过dp加上自适应布局可以基本解决屏幕碎片化的问题,也是Android推荐使用的屏幕兼容性适配方案。但是它会存在两个比较大的问题:
|
||||
|
||||
|
||||
不一致性。因为dpi与实际ppi的差异性,导致在相同分辨率的手机上,控件的实际大小会有所不同。
|
||||
|
||||
效率。设计师的设计稿都是以px为单位的,开发人员为了UI适配,需要手动通过百分比估算出dp值。
|
||||
|
||||
|
||||
除了直接dp适配之外,目前业界比较常用的UI适配方法主要有下面几种:
|
||||
|
||||
|
||||
限制符适配方案。主要有宽高限定符与smallestWidth限定符适配方案,具体可以参考《Android 目前稳定高效的UI适配方案》《smallestWidth 限定符适配方案》。
|
||||
|
||||
今日头条适配方案。通过反射修正系统的density值,具体可以参考《一种极低成本的Android屏幕适配方式》《今日头条适配方案》。
|
||||
|
||||
|
||||
2. CPU与GPU
|
||||
|
||||
除了屏幕,UI渲染还依赖两个核心的硬件:CPU与GPU。UI组件在绘制到屏幕之前,都需要经过Rasterization(栅格化)操作,而栅格化操作又是一个非常耗时的操作。GPU(Graphic Processing Unit )也就是图形处理器,它主要用于处理图形运算,可以帮助我们加快栅格化操作。
|
||||
|
||||
|
||||
|
||||
你可以从图上看到,软件绘制使用的是Skia库,它是一款能在低端设备如手机上呈现高质量的2D跨平台图形框架,类似Chrome、Flutter内部使用的都是Skia库。
|
||||
|
||||
3. OpenGL与Vulkan
|
||||
|
||||
对于硬件绘制,我们通过调用OpenGL ES接口利用GPU完成绘制。OpenGL是一个跨平台的图形API,它为2D/3D图形处理硬件指定了标准软件接口。而OpenGL ES是OpenGL的子集,专为嵌入式设备设计。
|
||||
|
||||
在官方硬件加速的文档中,可以看到很多API都有相应的Android API level限制。
|
||||
|
||||
|
||||
|
||||
这是为什么呢?其实这主要是受OpenGL ES版本与系统支持的限制,直到最新的Android P,有3个API是仍然没有支持。对于不支持的API,我们需要使用软件绘制模式,渲染的性能将会大大降低。
|
||||
|
||||
|
||||
|
||||
Android 7.0把OpenGL ES升级到最新的3.2版本同时,还添加了对Vulkan的支持。Vulkan是用于高性能3D图形的低开销、跨平台 API。相比OpenGL ES,Vulkan在改善功耗、多核优化提升绘图调用上有着非常明显的优势。
|
||||
|
||||
在国内,“王者荣耀”是比较早适配Vulkan的游戏,虽然目前兼容性还有一些问题,但是Vulkan版本的王者荣耀在流畅性和帧数稳定性都有大幅度提升,即使是战况最激烈的团战阶段,也能够稳定保持在55~60帧。
|
||||
|
||||
Android渲染的演进
|
||||
|
||||
跟耗电一样,Android的UI渲染性能也是Google长期以来非常重视的,基本每次Google I/O都会花很多篇幅讲这一块。每个开发者都希望自己的应用或者游戏可以做到60 fps如丝般顺滑,不过相比iOS系统,Android的渲染性能一直被人诟病。
|
||||
|
||||
Android系统为了弥补跟iOS的差距,在每个版本都做了大量的优化。在了解Android的渲染之前,需要先了解一下Android图形系统的整体架构,以及它包含的主要组件。
|
||||
|
||||
|
||||
|
||||
我曾经在一篇文章看过一个生动的比喻,如果把应用程序图形渲染过程当作一次绘画过程,那么绘画过程中Android的各个图形组件的作用是:
|
||||
|
||||
|
||||
画笔:Skia或者OpenGL。我们可以用Skia画笔绘制2D图形,也可以用OpenGL来绘制2D/3D图形。正如前面所说,前者使用CPU绘制,后者使用GPU绘制。
|
||||
|
||||
画纸:Surface。所有的元素都在Surface这张画纸上进行绘制和渲染。在Android中,Window是View的容器,每个窗口都会关联一个Surface。而WindowManager则负责管理这些窗口,并且把它们的数据传递给SurfaceFlinger。
|
||||
|
||||
画板:Graphic Buffer。Graphic Buffer缓冲用于应用程序图形的绘制,在Android 4.1之前使用的是双缓冲机制;在Android 4.1之后,使用的是三缓冲机制。
|
||||
|
||||
显示:SurfaceFlinger。它将WindowManager提供的所有Surface,通过硬件合成器Hardware Composer合成并输出到显示屏。
|
||||
|
||||
|
||||
接下来我将通过Android渲染演进分析的方法,帮你进一步加深对Android渲染的理解。
|
||||
|
||||
1. Android 4.0:开启硬件加速
|
||||
|
||||
在Android 3.0之前,或者没有启用硬件加速时,系统都会使用软件方式来渲染UI。
|
||||
|
||||
|
||||
|
||||
整个流程如上图所示:
|
||||
|
||||
|
||||
Surface。每个View都由某一个窗口管理,而每一个窗口都关联有一个Surface。
|
||||
|
||||
Canvas。通过Surface的lock函数获得一个Canvas,Canvas可以简单理解为Skia底层接口的封装。
|
||||
|
||||
Graphic Buffer。SurfaceFlinger会帮我们托管一个BufferQueue,我们从BufferQueue中拿到Graphic Buffer,然后通过Canvas以及Skia将绘制内容栅格化到上面。
|
||||
|
||||
SurfaceFlinger。通过Swap Buffer把Front Graphic Buffer的内容交给SurfaceFinger,最后硬件合成器Hardware Composer合成并输出到显示屏。
|
||||
|
||||
|
||||
整个渲染流程是不是非常简单?但是正如我前面所说,CPU对于图形处理并不是那么高效,这个过程完全没有利用到GPU的高性能。
|
||||
|
||||
硬件加速绘制
|
||||
|
||||
所以从Androd 3.0开始,Android开始支持硬件加速,到Android 4.0时,默认开启硬件加速。
|
||||
|
||||
|
||||
|
||||
硬件加速绘制与软件绘制整个流程差异非常大,最核心就是我们通过GPU完成Graphic Buffer的内容绘制。此外硬件绘制还引入了一个DisplayList的概念,每个View内部都有一个DisplayList,当某个View需要重绘时,将它标记为Dirty。
|
||||
|
||||
当需要重绘时,仅仅只需要重绘一个View的DisplayList,而不是像软件绘制那样需要向上递归。这样可以大大减少绘图的操作数量,因而提高了渲染效率。
|
||||
|
||||
|
||||
|
||||
2. Android 4.1:Project Butter
|
||||
|
||||
优化是无止境的,Google在2012年的I/O大会上宣布了Project Butter黄油计划,并且在Android 4.1中正式开启了这个机制。
|
||||
|
||||
Project Butter主要包含两个组成部分,一个是VSYNC,一个是Triple Buffering。
|
||||
|
||||
VSYNC信号
|
||||
|
||||
在讲文件I/O跟网络I/O的时候,我讲到过中断的概念。对于Android 4.0,CPU可能会因为在忙别的事情,导致没来得及处理UI绘制。
|
||||
|
||||
为解决这个问题,Project Buffer引入了VSYNC,它类似于时钟中断。每收到VSYNC中断,CPU会立即准备Buffer数据,由于大部分显示设备刷新频率都是60Hz(一秒刷新60次),也就是说一帧数据的准备工作都要在16ms内完成。
|
||||
|
||||
|
||||
|
||||
这样应用总是在VSYNC边界上开始绘制,而SurfaceFlinger总是VSYNC边界上进行合成。这样可以消除卡顿,并提升图形的视觉表现。
|
||||
|
||||
三缓冲机制Triple Buffering
|
||||
|
||||
在Android 4.1之前,Android使用双缓冲机制。怎么理解呢?一般来说,不同的View或者Activity它们都会共用一个Window,也就是共用同一个Surface。
|
||||
|
||||
而每个Surface都会有一个BufferQueue缓存队列,但是这个队列会由SurfaceFlinger管理,通过匿名共享内存机制与App应用层交互。
|
||||
|
||||
|
||||
|
||||
整个流程如下:
|
||||
|
||||
|
||||
每个Surface对应的BufferQueue内部都有两个Graphic Buffer ,一个用于绘制一个用于显示。我们会把内容先绘制到离屏缓冲区(OffScreen Buffer),在需要显示时,才把离屏缓冲区的内容通过Swap Buffer复制到Front Graphic Buffer中。
|
||||
|
||||
这样SurfaceFlinge就拿到了某个Surface最终要显示的内容,但是同一时间我们可能会有多个Surface。这里面可能是不同应用的Surface,也可能是同一个应用里面类似SurefaceView和TextureView,它们都会有自己单独的Surface。
|
||||
|
||||
这个时候SurfaceFlinger把所有Surface要显示的内容统一交给Hareware Composer,它会根据位置、Z-Order顺序等信息合成为最终屏幕需要显示的内容,而这个内容会交给系统的帧缓冲区Frame Buffer来显示(Frame Buffer是非常底层的,可以理解为屏幕显示的抽象)。
|
||||
|
||||
|
||||
如果你理解了双缓冲机制的原理,那就非常容易理解什么是三缓冲区了。如果只有两个Graphic Buffer缓存区A和B,如果CPU/GPU绘制过程较长,超过了一个VSYNC信号周期,因为缓冲区B中的数据还没有准备完成,所以只能继续展示A缓冲区的内容,这样缓冲区A和B都分别被显示设备和GPU占用,CPU无法准备下一帧的数据。
|
||||
|
||||
|
||||
|
||||
如果再提供一个缓冲区,CPU、GPU和显示设备都能使用各自的缓冲区工作,互不影响。简单来说,三缓冲机制就是在双缓冲机制基础上增加了一个Graphic Buffer缓冲区,这样可以最大限度的利用空闲时间,带来的坏处是多使用的了一个Graphic Buffer所占用的内存。
|
||||
|
||||
|
||||
|
||||
对于VSYNC信号和Triple Buffering更详细的介绍,可以参考《Android Project Butter分析》。
|
||||
|
||||
数据测量
|
||||
|
||||
“工欲善其事,必先利其器”,Project Butter在优化UI渲染性能的同时,也希望可以帮助我们更好地排查UI相关的问题。
|
||||
|
||||
在Android 4.1,新增了Systrace性能数据采样和分析工具。在卡顿和启动优化中,我们已经使用过Systrace很多次了,也可以用它来检测每一帧的渲染情况。
|
||||
|
||||
Tracer for OpenGL ES也是Android 4.1新增加的工具,它可逐帧、逐函数的记录App用OpenGL ES的绘制过程。它提供了每个OpenGL函数调用的消耗时间,所以很多时候用来做性能分析。但因为其强大的记录功能,在分析渲染问题时,当Traceview、Systrace都显得棘手时,还找不到渲染问题所在时,此时这个工具就会派上用场了。
|
||||
|
||||
在Android 4.2,系统增加了检测绘制过度工具,具体的使用方法可以参考《检查GPU渲染速度和绘制过度》。
|
||||
|
||||
|
||||
|
||||
3. Android 5.0:RenderThread
|
||||
|
||||
经过Project Butter黄油计划之后,Android的渲染性能有了很大的改善。但是不知道你有没有注意到一个问题,虽然我们利用了GPU的图形高性能运算,但是从计算DisplayList,到通过GPU绘制到Frame Buffer,整个计算和绘制都在UI主线程中完成。
|
||||
|
||||
|
||||
|
||||
UI主线程“既当爹又当妈”,任务过于繁重。如果整个渲染过程比较耗时,可能造成无法响应用户的操作,进而出现卡顿。GPU对图形的绘制渲染能力更胜一筹,如果使用GPU并在不同线程绘制渲染图形,那么整个流程会更加顺畅。
|
||||
|
||||
正因如此,在Android 5.0引入了两个比较大的改变。一个是引入了RenderNode的概念,它对DisplayList及一些View显示属性做了进一步封装。另一个是引入了RenderThread,所有的GL命令执行都放到这个线程上,渲染线程在RenderNode中存有渲染帧的所有信息,可以做一些属性动画,这样即便主线程有耗时操作的时候也可以保证动画流畅。
|
||||
|
||||
在官方文档 《检查 GPU 渲染速度和绘制过度》中,我们还可以开启Profile GPU Rendering检查。在Android 6.0之后,会输出下面的计算和绘制每个阶段的耗时:
|
||||
|
||||
|
||||
|
||||
如果我们把上面的步骤转化线程模型,可以得到下面的流水线模型。CPU将数据同步(sync)给GPU之后,一般不会阻塞等待GPU渲染完毕,而是通知结束后就返回。而RenderThread承担了比较多的绘制工作,分担了主线程很多压力,提高了UI线程的响应速度。
|
||||
|
||||
|
||||
|
||||
4. 未来
|
||||
|
||||
在Android 6.0的时候,Android在gxinfo添加了更详细的信息;在Android 7.0又对HWUI进行了一些重构,而且支持了Vulkan;在Android P支持了Vulkun 1.1。我相信在未来不久的Android Q,更好地支持Vulkan将是一个必然的方向。
|
||||
|
||||
总的来说,UI渲染的优化必然会朝着两个方向。一个是进一步压榨硬件的性能,让UI可以更加流畅。一个是改进或者增加更多的分析工具,帮助我们更容易地发现以及定位问题。
|
||||
|
||||
总结
|
||||
|
||||
今天我们通过Android渲染的演进历程,一步一步加深对Android渲染机制的理解,这对我们UI渲染优化工作会有很大的帮助。
|
||||
|
||||
但是凡事都要两面看,硬件加速绘制虽然极大地提高了Android系统显示和刷新的速度,但它也存在那么一些问题。一方面是内存消耗,OpenGL API调用以及Graphic Buffer缓冲区会占用至少几MB的内存,而实际上会占用更多一些。不过最严重的还是兼容性问题,部分绘制函数不支持是其中一部分原因,更可怕的是硬件加速绘制流程本身的Bug。由于Android每个版本对渲染模块都做了一些重构,在某些场景经常会出现一些莫名其妙的问题。
|
||||
|
||||
例如每个应用总有那么一些libhwui.so相关的崩溃,曾经这个崩溃占我们总崩溃的20%以上。我们内部花了整整一个多月,通过发了几十个灰度,使用了Inline Hook、GOT Hook等各种手段。最后才定位到问题的原因是系统内部RenderThread与主线程数据同步的Bug,并通过规避的方法得以解决。
|
||||
|
||||
课后作业
|
||||
|
||||
人们都说iOS系统更加流畅,对于Android的UI渲染你了解多少呢?在日常工作中,你是使用哪种方式做UI适配的,觉得目前在渲染方面最大的痛点是什么?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
在UI渲染这方面,其实我也并不是非常资深,针对文中所讲的,如果你有更好的思路和想法,一定给我留言,欢迎留下你的想法。
|
||||
|
||||
Android渲染架构非常庞大,而且演进得也非常快。如果你还有哪些不理解的地方,可以进一步阅读下面的参考资料:
|
||||
|
||||
|
||||
2018 Google I/O:Drawn out: how Android renders
|
||||
|
||||
官方文档:Android 图形架构
|
||||
|
||||
浏览器渲染:一颗像素的诞生
|
||||
|
||||
Android 屏幕绘制机制及硬件加速
|
||||
|
||||
Android性能优化之渲染篇
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
279
专栏/Android开发高手课/21UI优化(下):如何优化UI渲染?.md
Normal file
279
专栏/Android开发高手课/21UI优化(下):如何优化UI渲染?.md
Normal file
@ -0,0 +1,279 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 UI 优化(下):如何优化 UI 渲染?
|
||||
孔子曰:“温故而知新”,在学习如何优化UI渲染之前,我们先来回顾一下在“卡顿优化”中学到的知识。关于卡顿优化,我们学习了4种本地排查卡顿的工具,以及多种线上监控卡顿、帧率的方法。为什么要回顾卡顿优化呢?那是因为UI渲染也会造成卡顿,并且肯定会有同学疑惑卡顿优化和UI优化的区别是什么。
|
||||
|
||||
在Android系统的VSYNC信号到达时,如果UI线程被某个耗时任务堵塞,长时间无法对UI进行渲染,这时就会出现卡顿。但是这种情形并不是我们今天讨论的重点,UI优化要解决的核心是由于渲染性能本身造成用户感知的卡顿,它可以认为是卡顿优化的一个子集。
|
||||
|
||||
从设计师和产品的角度,他们希望应用可以用丰富的图形元素、更炫酷的动画来实现流畅的用户体验。但是Android系统很有可能无法及时完成这些复杂的界面渲染操作,这个时候就会出现掉帧。也正因如此我才希望做UI优化,因为我们有更高的要求,希望它能达到流畅画面所需要的60 fps。这里需要说的是,即使40 fps用户可能不会感到明显的卡顿,但我们也仍需要去做进一步的优化。
|
||||
|
||||
那么接下来我们就来看看,如何让我们的UI渲染达到60 fps?有哪些方法可以帮助我们优化UI渲染性能?
|
||||
|
||||
UI渲染测量
|
||||
|
||||
通过上一期的学习,你应该已经掌握了一些UI测试和问题定位的工具。
|
||||
|
||||
|
||||
测试工具:Profile GPU Rendering和Show GPU Overdraw,具体的使用方法你可以参考《检查GPU渲染速度和绘制过度》。
|
||||
|
||||
问题定位工具:Systrace和Tracer for OpenGL ES,具体使用方法可以参考《Slow rendering》。
|
||||
|
||||
|
||||
在Android Studio 3.1之后,Android推荐使用Graphics API Debugger(GAPID)来替代Tracer for OpenGL ES工具。GAPID可以说是升级版,它不仅可以跨平台,而且功能更加强大,支持Vulkan与回放。
|
||||
|
||||
|
||||
|
||||
通过上面的几个工具,我们可以初步判断应用UI渲染的性能是否达标,例如是否经常出现掉帧、掉帧主要发生在渲染的哪一个阶段、是否存在Overdraw等。
|
||||
|
||||
虽然这些图形化界面工具非常好用,但是它们难以用在自动化测试场景中,那有哪些测量方法可以用于自动化测量UI渲染性能呢?
|
||||
|
||||
1. gfxinfo
|
||||
|
||||
gfxinfo可以输出包含各阶段发生的动画以及帧相关的性能信息,具体命令如下:
|
||||
|
||||
adb shell dumpsys gfxinfo 包名
|
||||
|
||||
|
||||
除了渲染的性能之外,gfxinfo还可以拿到渲染相关的内存和View hierarchy信息。在Android 6.0之后,gxfinfo命令新增了framestats参数,可以拿到最近120帧每个绘制阶段的耗时信息。
|
||||
|
||||
adb shell dumpsys gfxinfo 包名 framestats
|
||||
|
||||
|
||||
通过这个命令我们可以实现自动化统计应用的帧率,更进一步还可以实现自定义的“Profile GPU Rendering”工具,在出现掉帧的时候,自动统计分析是哪个阶段的耗时增长最快,同时给出相应的建议。
|
||||
|
||||
|
||||
|
||||
2. SurfaceFlinger
|
||||
|
||||
除了耗时,我们还比较关心渲染使用的内存。上一期我讲过,在Android 4.1以后每个Surface都会有三个Graphic Buffer,那如何查看Graphic Buffer占用的内存,系统是怎么样管理这部分的内存的呢?
|
||||
|
||||
你可以通过下面的命令拿到系统SurfaceFlinger相关的信息:
|
||||
|
||||
adb shell dumpsys SurfaceFlinger
|
||||
|
||||
|
||||
下面以今日头条为例,应用使用了三个Graphic Buffer缓冲区,当前用在显示的第二个Graphic Buffer,大小是1080 x 1920。现在我们也可以更好地理解三缓冲机制,你可以看到这三个Graphic Buffer的确是在交替使用。
|
||||
|
||||
+ Layer 0x793c9d0c00 (com.ss.***。news/com.**.MainActivity)
|
||||
//序号 //状态 //对象 //大小
|
||||
>[02:0x794080f600] state=ACQUIRED, 0x794081bba0 [1080x1920:1088, 1]
|
||||
[00:0x793e76ca00] state=FREE , 0x793c8a2640 [1080x1920:1088, 1]
|
||||
[01:0x793e76c800] state=FREE , 0x793c9ebf60 [1080x1920:1088, 1]
|
||||
|
||||
|
||||
继续往下看,你可以看到这三个Buffer分别占用的内存:
|
||||
|
||||
Allocated buffers:
|
||||
0x793c8a2640: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900
|
||||
0x793c9ebf60: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900
|
||||
0x794081bba0: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900
|
||||
|
||||
|
||||
这部分的内存其实真的不小,特别是现在手机的分辨率越来越大,而且还很多情况应用会有其他的Surface存在,例如使用了SurfaceView或者TextureView等。
|
||||
|
||||
那系统是怎么样管理这部分内存的呢?当应用退到后台的时候,系统会将这些内存回收,也就不会再把它们计算到应用的内存占用中。
|
||||
|
||||
+ Layer 0x793c9d0c00 (com.ss.***。news/com.**.MainActivity)
|
||||
[00:0x0] state=FREE
|
||||
[01:0x0] state=FREE
|
||||
[02:0x0] state=FREE
|
||||
|
||||
|
||||
那么如何快速地判别UI实现是否符合设计稿?如何更高效地实现UI自动化测试?这些问题你可以先思考一下,我们将在后面“高效测试”中再详细展开。
|
||||
|
||||
UI优化的常用手段
|
||||
|
||||
让我们再重温一下UI渲染的阶段流程图,我们的目标是实现60 fps,这意味着渲染的所有操作都必须在16 ms(= 1000 ms/60 fps)内完成。
|
||||
|
||||
|
||||
|
||||
所谓的UI优化,就是拆解渲染的各个阶段的耗时,找到瓶颈的地方,再加以优化。接下来我们一起来看看UI优化的一些常用的手段。
|
||||
|
||||
1. 尽量使用硬件加速
|
||||
|
||||
通过上一期学习,相信你也发自内心地认同硬件加速绘制的性能是远远高于软件绘制的。所以说UI优化的第一个手段就是保证渲染尽量使用硬件加速。
|
||||
|
||||
有哪些情况我们不能使用硬件加速呢?之所以不能使用硬件加速,是因为硬件加速不能支持所有的Canvas API,具体API兼容列表可以见drawing-support文档。如果使用了不支持的API,系统就需要通过CPU软件模拟绘制,这也是渐变、磨砂、圆角等效果渲染性能比较低的原因。
|
||||
|
||||
SVG也是一个非常典型的例子,SVG有很多指令硬件加速都不支持。但我们可以用一个取巧的方法,提前将这些SVG转换成Bitmap缓存起来,这样系统就可以更好地使用硬件加速绘制。同理,对于其他圆角、渐变等场景,我们也可以改为Bitmap实现。
|
||||
|
||||
这种取巧方法实现的关键在于如何提前生成Bitmap,以及Bitmap的内存需要如何管理。你可以参考一下市面常用的图片库实现。
|
||||
|
||||
2. Create View优化
|
||||
|
||||
观察渲染的流水线时,有没有同学发现缺少一个非常重要的环节,那就是View创建的耗时。请不要忘记,View的创建也是在UI线程里,对于一些非常复杂的界面,这部分的耗时不容忽视。
|
||||
|
||||
在优化之前我们先来分解一下View创建的耗时,可能会包括各种XML的随机读的I/O时间、解析XML的时间、生成对象的时间(Framework会大量使用到反射)。
|
||||
|
||||
相应的,我们来看看这个阶段有哪些优化方式。
|
||||
|
||||
使用代码创建
|
||||
|
||||
使用XML进行UI编写可以说是十分方便,可以在Android Studio中实时预览到界面。如果我们要对一个界面进行极致优化,就可以使用代码进行编写界面。
|
||||
|
||||
但是这种方式对开发效率来说简直是灾难,因此我们可以使用一些开源的XML转换为Java代码的工具,例如X2C。但坦白说,还是有不少情况是不支持直接转换的。
|
||||
|
||||
所以我们需要兼容性能与开发效率,我建议只在对性能要求非常高,但修改又不非常频繁的场景才使用这个方式。
|
||||
|
||||
异步创建
|
||||
|
||||
那我们能不能在线程提前创建View,实现UI的预加载吗?尝试过的同学都会发现系统会抛出下面这个异常:
|
||||
|
||||
java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()
|
||||
at android.os.Handler.<init>(Handler.java:121)
|
||||
|
||||
|
||||
事实上,我们可以通过又一个非常取巧的方式来实现。在使用线程创建UI的时候,先把线程的Looper的MessageQueue替换成UI线程Looper的Queue。
|
||||
|
||||
|
||||
|
||||
不过需要注意的是,在创建完View后我们需要把线程的Looper恢复成原来的。
|
||||
|
||||
View重用
|
||||
|
||||
正常来说,View会随着Activity的销毁而同时销毁。ListView、RecycleView通过View的缓存与重用大大地提升渲染性能。因此我们可以参考它们的思想,实现一套可以在不同Activity或者Fragment使用的View缓存机制。
|
||||
|
||||
但是这里需要保证所有进入缓存池的View都已经“净身出户”,不会保留之前的状态。微信曾经就因为这个缓存,导致出现不同的用户聊天记录错乱。
|
||||
|
||||
|
||||
|
||||
3. measure/layout优化
|
||||
|
||||
渲染流程中measure和layout也是需要CPU在主线程执行的,对于这块内容网上有很多优化的文章,一般的常规方法有:
|
||||
|
||||
|
||||
减少UI布局层次。例如尽量扁平化,使用<ViewStub> <Merge>等优化。
|
||||
|
||||
优化layout的开销。尽量不使用RelativeLayout或者基于weighted LinearLayout,它们layout的开销非常巨大。这里我推荐使用ConstraintLayout替代RelativeLayout或者weighted LinearLayout。
|
||||
|
||||
背景优化。尽量不要重复去设置背景,这里需要注意的是主题背景(theme), theme默认会是一个纯色背景,如果我们自定义了界面的背景,那么主题的背景我们来说是无用的。但是由于主题背景是设置在DecorView中,所以这里会带来重复绘制,也会带来绘制性能损耗。
|
||||
|
||||
|
||||
对于measure和layout,我们能不能像Create View一样实现线程的预布局呢?这样可以大大地提升首次显示的性能。
|
||||
|
||||
Textview是系统控件中非常强大也非常重要的一个控件,强大的背后就代表着需要做很多计算。在2018年的Google I/O大会,发布了PrecomputedText并已经集成在Jetpack中,它给我们提供了接口,可以异步进行measure和layout,不必在主线程中执行。
|
||||
|
||||
UI优化的进阶手段
|
||||
|
||||
那对于其他的控件我们是不是也可以采用相同的方式?接下来我们一起来看看近两年新框架的做法,我来介绍一下Facebook的一个开源库Litho以及Google开源的Flutter。
|
||||
|
||||
1. Litho:异步布局
|
||||
|
||||
Litho是Facebook开源的声明式Android UI渲染框架,它是基于另外一个Facebook开源的布局引擎Yoga开发的。
|
||||
|
||||
Litho本身非常强大,内部做了很多非常不错的优化。下面我来简单介绍一下它是如何优化UI的。
|
||||
|
||||
异步布局-
|
||||
一般来说的Android所有的控件绘制都要遵守measure -> layout -> draw的流水线,并且这些都发生在主线程中。
|
||||
|
||||
|
||||
|
||||
Litho如我前面提到的PrecomputedText一样,把measure和layout都放到了后台线程,只留下了必须要在主线程完成的draw,这大大降低了UI线程的负载。它的渲染流水线如下:
|
||||
|
||||
|
||||
|
||||
界面扁平化
|
||||
|
||||
前面也提到过,降低UI的层级是一个非常通用的优化方法。你肯定会想,有没有一种方法可以直接降低UI的层级,而不通过代码的改变呢?Litho就给了我们一种方案,由于Litho使用了自有的布局引擎(Yoga),在布局阶段就可以检测不必要的层级、减少ViewGroups,来实现UI扁平化。比如下面这样图,上半部分是我们一般编写这个界面的方法,下半部分是Litho编写的界面,可以看到只有一层层级。
|
||||
|
||||
|
||||
|
||||
优化RecyclerView-
|
||||
Litho还优化了RecyclerView中UI组件的缓存和回收方法。原生的RecyclerView或者ListView是按照viewType来进行缓存和回收,但如果一个RecyclerView/ListView中出现viewType过多,会使缓存形同虚设。但Litho是按照text、image和video独立回收的,这可以提高缓存命中率、降低内存使用率、提高滚动帧率。
|
||||
|
||||
|
||||
|
||||
Litho虽然强大,但也有自己的缺点。它为了实现measure/layout异步化,使用了类似react单向数据流设计,这一定程度上加大了UI开发的复杂性。并且Litho的UI代码是使用Java/Kotlin来进行编写,无法做到在AS中预览。
|
||||
|
||||
如果你没有计划完全迁移到Litho,我建议可以优先使用Litho中的RecyclerCollectionComponent和Sections来优化自己的RecyelerView的性能。
|
||||
|
||||
2. Flutter:自己的布局 + 渲染引擎
|
||||
|
||||
如下图所示,Litho虽然通过使用自己的布局引擎Yoga,一定程度上突破了系统的一些限制,但是在draw之后依然走的系统的渲染机制。
|
||||
|
||||
|
||||
|
||||
那我们能不能再往底层深入,把系统的渲染也同时接管过来?Flutter正是这样的框架,它也是最近十分火爆的一个新框架,这里我也简单介绍一下。
|
||||
|
||||
Flutter是Google推出并开源的移动应用开发框架,开发者可以通过Dart语言开发App,一套代码同时运行在iOS和Android平台。
|
||||
|
||||
我们先整体看一下Flutter的架构,在Android上Flutter完全没有基于系统的渲染引擎,而是把Skia引擎直接集成进了App中,这使得Flutter App就像一个游戏App。并且直接使用了Dart虚拟机,可以说是一套跳脱出Android的方案,所以Flutter也可以很容易实现跨平台。
|
||||
|
||||
|
||||
|
||||
开发Flutter应用总的来说简化了线程模型,框架给我们抽象出各司其职的Runner,包括UI、GPU、I/O、Platform Runner。Android平台上面每一个引擎实例启动的时候会为UI Runner、GPU Runner、I/O Runner各自创建一个新的线程,所有Engine实例共享同一个Platform Runner和线程。
|
||||
|
||||
由于本期我们主要讨论UI渲染相关的内容,我来着重分析一下Flutter的渲染步骤,相关的具体知识你可以阅读《Flutter原理与实践》。
|
||||
|
||||
|
||||
首先UI Runner会执行root isolate(可以简单理解为main函数。需要简单解释一下isolate的概念,isolate是Dart虚拟机中一种执行并发代码实现,Dart虚拟机实现了Actor的并发模型,与大名鼎鼎的Erlang使用了类似的并发模型。如果不太了解Actor的同学,可以简单认为isolate就是Dart虚拟机的“线程”,Root isolate会通知引擎有帧要渲染)。
|
||||
|
||||
Flutter引擎得到通知后,会告知系统我们要同步VSYNC。
|
||||
|
||||
得到GPU的VSYNC信号后,对UI Widgets进行Layout并生成一个Layer Tree。
|
||||
|
||||
然后Layer Tree会交给GPU Runner进行合成和栅格化。
|
||||
|
||||
GPU Runner使用Skia库绘制相关图形。
|
||||
|
||||
|
||||
|
||||
|
||||
Flutter也采用了类似Litho、React属性不可变,单向数据流的方案。这已经成为现代UI渲染引擎的标配。这样做的好处是可以将视图与数据分离。
|
||||
|
||||
总体来说Flutter吸取和各个优秀前端框架的精华,还“加持”了强大的Dart虚拟机和Skia渲染引擎,可以说是一个非常优秀的框架,闲鱼、今日头条等很多应用部分功能已经使用Flutter开发。结合Google最新的Fuchsia操作系统,它会不会是一个颠覆Android的开发框架?我们在专栏后面会单独详细讨论Flutter。
|
||||
|
||||
3. RenderThread与RenderScript
|
||||
|
||||
在Android 5.0,系统增加了RenderThread,对于ViewPropertyAnimator和CircularReveal动画,我们可以使用RenderThead实现动画的异步渲染。当主线程阻塞的时候,普通动画会出现明显的丢帧卡顿,而使用RenderThread渲染的动画即使阻塞了主线程仍不受影响。
|
||||
|
||||
现在越来越多的应用会使用一些高级图片或者视频编辑功能,例如图片的高斯模糊、放大、锐化等。拿日常我们使用最多的“扫一扫”这个场景来看,这里涉及大量的图片变换操作,例如缩放、裁剪、二值化以及降噪等。
|
||||
|
||||
图片的变换涉及大量的计算任务,而根据我们上一期的学习,这个时候使用GPU是更好的选择。那如何进一步压榨系统GPU的性能呢?
|
||||
|
||||
我们可以通过RenderScript,它是Android操作系统上的一套API。它基于异构计算思想,专门用于密集型计算。RenderScript提供了三个基本工具:一个硬件无关的通用计算API;一个类似于CUDA、OpenCL和GLSL的计算API;一个类C99的脚本语言。允许开发者以较少的代码实现功能复杂且性能优越的应用程序。
|
||||
|
||||
如何将它们应用到我们的项目中?你可以参考下面的一些实践方案:
|
||||
|
||||
|
||||
RenderScript渲染利器
|
||||
|
||||
RenderScript :简单而快速的图像处理
|
||||
|
||||
Android RenderScript 简单高效实现图片的高斯模糊效果
|
||||
|
||||
|
||||
总结
|
||||
|
||||
回顾一下UI优化的所有手段,我们会发现它存在这样一个脉络:
|
||||
|
||||
1. 在系统的框架下优化。布局优化、使用代码创建、View缓存等都是这个思路,我们希望减少甚至省下渲染流水线里某个阶段的耗时。
|
||||
|
||||
2. 利用系统新的特性。使用硬件加速、RenderThread、RenderScript都是这个思路,通过系统一些新的特性,最大限度压榨出性能。
|
||||
|
||||
3. 突破系统的限制。由于Android系统碎片化非常严重,很多好的特性可能低版本系统并不支持。而且系统需要支持所有的场景,在一些特定场景下它无法实现最优解。这个时候,我们希望可以突破系统的条条框框,例如Litho突破了布局,Flutter则更进一步,把渲染也接管过来了。
|
||||
|
||||
回顾一下过去所有的UI优化,第一阶段的优化我们在系统的束缚下也可以达到非常不错的效果。不过越到后面越容易出现瓶颈,这个时候我们就需要进一步往底层走,可以对整个架构有更大的掌控力,需要造自己的“轮子”。
|
||||
|
||||
对于UI优化的另一个思考是效率,目前Android Studio对设计并不友好,例如不支持Sketch插件和AE插件。Lottie是一个非常好的案例,它很大提升了开发人员写动画的效率。
|
||||
|
||||
“设计师和产品,你们长大了,要学会自己写UI了”。在未来,我们希望UI界面与适配可以实现自动化,或者干脆把它交还给设计师和产品。
|
||||
|
||||
课后作业
|
||||
|
||||
在你平时的工作中,做过哪些UI优化的工作,有没有什么“大招”跟其他同学分享?对于Litho, Flutter,你又有什么看法?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天还有两个课后小作业,尝试使用Litho和Flutter这两个框架。
|
||||
|
||||
1.使用Litho实现一个信息流界面。
|
||||
|
||||
2.使用Flutter写一个Hello World,分析安装包的体积。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
313
专栏/Android开发高手课/22包体积优化(上):如何减少安装包大小?.md
Normal file
313
专栏/Android开发高手课/22包体积优化(上):如何减少安装包大小?.md
Normal file
@ -0,0 +1,313 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 包体积优化(上):如何减少安装包大小?
|
||||
曾经在15年的时候,我在WeMobileDev公众号就写过一篇文章《Android安装包相关知识汇总》,也开源了一个不少同学都使用过的资源混淆工具AndResGuard。
|
||||
|
||||
现在再看看这篇4年前的文章,就像看到了4年前的自己,感触颇多啊。几年过去了,网上随意一搜都有大量安装包优化的文章,那还有哪些“高深”的珍藏秘笈值得分享呢?
|
||||
|
||||
时至今日,微信包体积也从当年的30MB增长到现在的100MB了。我们经常会想,现在WiFi这么普遍了,而且5G都要来了,包体积优化究竟还有没有意义?它对用户和应用的价值在哪里?
|
||||
|
||||
安装包的背景知识
|
||||
|
||||
还记得在2G时代,我们每个月只有30MB流量,那个时候安装包体积确实至关重要。当时我在做“搜狗输入法”的时候,我们就严格要求包体积在5MB以内。
|
||||
|
||||
几年过去了,我们对包体积的看法有什么改变吗?
|
||||
|
||||
1. 为什么要优化包体积
|
||||
|
||||
在2018年的Google I/O,Google透露了Google Play上安装包体积与下载转化率的关系图。
|
||||
|
||||
|
||||
|
||||
从这张图上看,大体来说,安装包越小,转化率越高这个结论依然成立。而包体积对应用的影响,主要有下面几点:
|
||||
|
||||
|
||||
下载转化率。一个100MB的应用,用户即使点了下载,也可能因为网络速度慢、突然反悔下载失败。对于一个10MB的应用,用户点了下载之后,在犹豫要不要下的时候已经下载完了。但是正如上图的数据,安装包大小与转化率的关系是非常微妙的。10MB跟15MB可能差距不大,但是10MB跟40MB的差距还是非常明显的。
|
||||
|
||||
推广成本。一般来说,包体积对渠道推广和厂商预装的单价会有非常大的影响。特别是厂商预装,这主要是因为厂商留给预装应用的总空间是有限的。如果你的包体积非常大,那就会影响厂商预装其他应用。
|
||||
|
||||
应用市场。苹果的App Store强制超过150MB的应用只能使用WiFi网络下载,Google Play要求超过100MB的应用只能使用APK扩展文件方式上传,由此可见应用包体积对应用市场的服务器带宽成本还是会有一点压力的。
|
||||
|
||||
|
||||
目前成熟的超级App越来越多,很多产品也希望自己成为下一个超级App,希望功能可以包罗万象,满足用户的一切需求。但这同样也导致安装包不断变大,其实很多用户只使用到很少一部分功能。
|
||||
|
||||
下面我们就来看看微信、QQ、支付宝以及淘宝这几款超级App这几年安装包增长的情况。
|
||||
|
||||
|
||||
|
||||
我还记得在15年的时候,为了让微信6.2版本小于30MB,我使用了各种各样的手段,把体积从34MB降到29.85MB,资源混淆工具AndResGuard也就是在那个优化专项中写的。几年过去了,微信包体积已经涨到100MB了,淘宝似乎也不容乐观。相比之下,QQ和支付宝相对还比较节制。
|
||||
|
||||
2. 包体积与应用性能
|
||||
|
||||
React Native 5MB、Flutter 4MB、浏览器内核20MB、Chromium网络库2MB…现在第三方开发框架和扩展库越来越多,很多的应用包体积都已经几十是MB起步了。
|
||||
|
||||
那包体积除了转化率的影响,它对我们应用性能还有哪些影响呢?
|
||||
|
||||
|
||||
安装时间。文件拷贝、Library解压、编译ODEX、签名校验,特别对于Android 5.0和6.0系统来说(Android 7.0之后有了混合编译),微信13个Dex光是编译ODEX的时间可能就要5分钟。
|
||||
|
||||
运行内存。在内存优化的时候我们就说过,Resource资源、Library以及Dex类加载这些都会占用不少的内存。
|
||||
|
||||
ROM空间。100MB的安装包,启动解压之后很有可能就超过200MB了。对低端机用户来说,也会有很大的压力。在“I/O优化”中我们讨论过,如果闪存空间不足,非常容易出现写入放大的情况。
|
||||
|
||||
|
||||
对于大部分一两年前的“千元机”,淘宝和微信都已经玩不转了。“技术短期内被高估,长期会被低估”,特别在业务高速发展的时候,性能往往就被排到后面。
|
||||
|
||||
包体积对技术人员来说应该是非常重要的技术指标,我们不能放任它的增长,它对我们还有不少意义。
|
||||
|
||||
|
||||
业务梳理。删除无用或者低价值的业务,永远都是最有效的性能优化方式。我们需要经常回顾过去的业务,不能只顾着往前冲,适时地还一些“技术债务”。
|
||||
|
||||
开发模式升级。如果所有的功能都不能移除,那可能需要倒逼开发模式的转变,更多地采用小程序、H5这样开发模式。
|
||||
|
||||
|
||||
包体积优化
|
||||
|
||||
国内地开发者都非常羡慕海外的应用,因为海外有统一的Google Play市场。它可以根据用户的ABI、density和language发布,还有在2018年最新推出的App Bundle。
|
||||
|
||||
|
||||
|
||||
事实上安装包中无非就是Dex、Resource、Assets、Library以及签名信息这五部分,接下来我们就来看看对于国内应用来说,还有什么高级“秘籍”。
|
||||
|
||||
1. 代码
|
||||
|
||||
对于大部分应用来说,Dex都是包体积中的大头。看一下上面表格中微信、QQ、支付宝和淘宝的数据,它们的Dex数量从1个增长到10多个,我们的代码量真的增长了那么多倍吗?
|
||||
|
||||
而且Dex的数量对用户安装时间也是一个非常大的挑战,在不砍功能的前提下,我们看看有哪些方法可以减少这部分空间。
|
||||
|
||||
ProGuard-
|
||||
“十个ProGuard配置九个坑”,特别是各种第三方SDK。我们需要仔细检查最终合并的ProGuard配置文件,是不是存在过度keep的现象。
|
||||
|
||||
你可以通过下面的方法输出ProGuard的最终配置,尤其需要注意各种的keep *,很多情况下我们只需要keep其中的某个包、某个方法,或者是类名就可以了。
|
||||
|
||||
-printconfiguration configuration.txt
|
||||
|
||||
|
||||
那还有没有哪些方法可以进一步加大混淆力度呢?这时我们可能要向四大组件和View下手了。一般来说,应用都会keep住四大组件以及View的部分方法,这样是为了在代码以及XML布局中可以引用到它们。
|
||||
|
||||
-keep public class * extends android.app.Activity
|
||||
-keep public class * extends android.app.Application
|
||||
-keep public class * extends android.app.Service
|
||||
-keep public class * extends android.content.BroadcastReceiver
|
||||
-keep public class * extends android.content.ContentProvider
|
||||
-keep public class * extends android.view.View
|
||||
|
||||
|
||||
事实上,我们完全可以把非exported的四大组件以及View混淆,但是需要完成下面几个工作:
|
||||
|
||||
|
||||
XML替换。在代码混淆之后,需要同时修改AndroidManifest以及资源XML中引用的名称。
|
||||
|
||||
代码替换。需要遍历其他已经混淆好的代码,将变量或者方法体中定义的字符串也同时修改。需要注意的是,代码中不能出现经过运算得到的类名,这种情况会导致替换失败。
|
||||
|
||||
|
||||
// 情况一:变量
|
||||
public String activityName = "com.sample.TestActivity";
|
||||
// 情况二:方法体
|
||||
startActivity(new Intent(this, "com.sample.TestActivity"));
|
||||
// 情况三:通过运算得到,不支持
|
||||
startActivity(new Intent(this, "com.sample" + ".TestActivity"));
|
||||
|
||||
|
||||
代码替换的方法,我推荐使用ASM。不熟悉ASM的同学也不用着急,后面我会专门讲它的原理和用法。饿了么曾经开源过一个可以实现四大组件和View混淆的组件Mess,不过似乎已经没在维护了,可供你参考。
|
||||
|
||||
Android Studio 3.0推出了新Dex编译器D8与新混淆工具R8,目前D8已经正式Release,大约可以减少3%的Dex体积。但是计划用于取代ProGuard的R8依然处于实验室阶段,期待它在未来能有更好的表现。
|
||||
|
||||
去掉Debug信息或者去掉行号-
|
||||
某个应用通过相同的ProGuard规则生成一个Debug包和Release包,其中Debug包的大小是4MB,Release包只有3.5MB。
|
||||
|
||||
既然它们ProGuard的混淆与优化的规则是一样的,那它们之间的差异在哪里呢?那就是DebugItem。
|
||||
|
||||
|
||||
|
||||
DebugItem里面主要包含两种信息:
|
||||
|
||||
|
||||
调试的信息。函数的参数变量和所有的局部变量。
|
||||
|
||||
排查问题的信息。所有的指令集行号和源文件行号的对应关系。
|
||||
|
||||
|
||||
事实上,在ProGuard配置中一般我们也会通过下面的方式保留行号信息。
|
||||
|
||||
-keepattributes SourceFile, LineNumberTable
|
||||
|
||||
|
||||
对于去除debuginfo以及行号信息更详细的分析,推荐你认真看一下支付宝的一篇文章《Android包大小极致压缩》。通过这个方法,我们可以实现既保留行号,但是又可以减少大约5%的Dex体积。
|
||||
|
||||
事实上,支付宝参考的是Facebook的一个开源编译工具ReDex。ReDex除了没有文档之外,绝对是客户端领域非常硬核的一个开源库,非常值得你去认真研究。
|
||||
|
||||
ReDex这个库里面的好东西实在是太多了,后面我们还会反复讲到,其中去除Debug信息是通过StripDebugInfoPass完成。
|
||||
|
||||
{
|
||||
"redex" : {
|
||||
"passes" : [
|
||||
"StripDebugInfoPass"
|
||||
]
|
||||
},
|
||||
"StripDebugInfoPass" : {
|
||||
"drop_all_dbg_info" : "0", // 去除所有的debug信息,0表示不去除
|
||||
"drop_local_variables" : "1", // 去除所有局部变量,1表示去除
|
||||
"drop_line_numbers" : "0", // 去除行号,0表示不去除
|
||||
"drop_src_files" : "0",
|
||||
"use_whitelist" : "0",
|
||||
"drop_prologue_end" : "1",
|
||||
"drop_epilogue_begin" : "1",
|
||||
"drop_all_dbg_info_if_empty" : "1"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Dex分包-
|
||||
当我们在Android Studio查看一个APK的时候,不知道你是否知道下图中“defines 19272 methods”和“references 40229 methods”的区别。
|
||||
|
||||
|
||||
|
||||
关于Dex的格式以及各个字段的定义,你可以参考《Dex文件格式详解》。为了加深对Dex格式的理解,推荐你使用010Editor。
|
||||
|
||||
|
||||
|
||||
“define classes and methods”是指真正在这个Dex中定义的类以及它们的方法。而“reference methods”指的是define methods以及define methods引用到的方法。
|
||||
|
||||
简单来说,如下图所示如果将Class A与Class B分别编译到不同的Dex中,由于method a调用了method b,所以在classes2.dex中也需要加上method b的id。
|
||||
|
||||
|
||||
|
||||
因为跨Dex调用造成的这些冗余信息,它对我们Dex的大小会造成哪些影响呢?
|
||||
|
||||
|
||||
method id爆表。我们都知道每个Dex的method id需要小于65536,因为method id的大量冗余导致每个Dex真正可以放的Class变少,这是造成最终编译的Dex数量增多。
|
||||
|
||||
信息冗余。因为我们需要记录跨Dex调用的方法的详细信息,所以在classes2.dex我们还需要记录Class B以及method b的定义,造成string_ids、type_ids、proto_ids这几部分信息的冗余。
|
||||
|
||||
|
||||
事实上,我自己定义了一个Dex信息有效率的指标,希望保证Dex有效率应该在80%以上。同时,为了进一步减少Dex的数量,我们希望每个Dex的方法数都是满的,即分配了65536个方法。
|
||||
|
||||
Dex信息有效率 = define methods数量/reference methods数量
|
||||
|
||||
|
||||
那如何实现Dex信息有效率提升呢?关键在于我们需要将有调用关系的类和方法分配到同一个Dex中,即减少跨Dex的调用的情况。但是由于类的调用关系非常复杂,我们不太可能可以计算出最优解,只能得到局部的最优解。
|
||||
|
||||
为了提高Dex信息有效率,我在微信时曾参与写过一个依赖分析的工具Builder。但在微信最新的7.0版本,你可以看到上面表中Dex的数量和大小都增大了很多,这是因为他们不小心把这个工具搞失效了。Dex数量的增多,对于Tinker热修复时间、用户安装时间都有很大影响。如果把这个问题修复,微信7.0版本的Dex数量应该可以从13个降到6个左右,包体积可以减少10MB左右。
|
||||
|
||||
但是我在研究ReDex的时候,发现它也提供了这个优化,而且实现得比微信的更好。ReDex在分析类调用关系后,使用的是贪心算法计算局部最优值,具体算法可查看CrossDexDefMinimizer。
|
||||
|
||||
为什么我们不能计算到最优解?因为我们需要在编译速度和效果之间找一个平衡点,在ReDex中使用这个优化的配置如下:
|
||||
|
||||
{
|
||||
"redex" : {
|
||||
"passes" : [
|
||||
"InterDexPass"
|
||||
]
|
||||
},
|
||||
"InterDexPass" : {
|
||||
"minimize_cross_dex_refs": true,
|
||||
"minimize_cross_dex_refs_method_ref_weight": 100,
|
||||
"minimize_cross_dex_refs_field_ref_weight": 90,
|
||||
"minimize_cross_dex_refs_type_ref_weight": 100,
|
||||
"minimize_cross_dex_refs_string_ref_weight": 90
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
那么通过Dex分包可以对包体积优化多少呢?因为Android默认的分包方式做得实在不好,如果你的应用有4个以上的Dex,我相信这个优化至少有10%的效果。
|
||||
|
||||
Dex压缩-
|
||||
我曾经在逆向Facebook的App时惊奇地发现,它怎么可能只有一个700多KB的Dex。Google Play是不允许动态下发代码的,那它的代码都放到哪里了呢?
|
||||
|
||||
|
||||
|
||||
事实上,Facebook App的classes.dex只是一个壳,真正的代码都放到assets下面。它们把所有的Dex都合并成同一个secondary.dex.jar.xzs文件,并通过XZ压缩。
|
||||
|
||||
|
||||
|
||||
XZ压缩算法和7-Zip一样,内部使用的都是LZMA算法。对于Dex格式来说,XZ的压缩率可以比Zip高30%左右。但是不知道你有没有注意到,这套方案似乎存在一些问题:
|
||||
|
||||
|
||||
首次启动解压。应用首次启动的时候,需要将secondary.dex.jar.xzs解压缩,根据上图的配置信息,应该一共有11个Dex。Facebook使用多线程解压的方式,这个耗时在高端机是几百毫秒左右,在低端机可能需要3~5秒。这里为什么不采用Zstandard或者Brotli呢?主要是压缩率与解压速度的权衡。
|
||||
|
||||
ODEX文件生成。前面我就讲过,当Dex非常多的时候会增加应用的安装时间。对于Facebook的这个做法,首次生成ODEX的时间可能就会达到分钟级别。Facebook为了解决这个问题,使用了ReDex另外一个超级硬核的方法,那就是oatmeal。
|
||||
|
||||
|
||||
oatmeal的原理非常简单,就是根据ODEX文件的格式,自己生成一个ODEX文件。它生成的结果跟解释执行的ODEX一样,内部是没有机器码的。
|
||||
|
||||
|
||||
|
||||
如上图所示,对于正常的流程,我们需要fork进程来生成dex2oat,这个耗时一般都比较大。通过oatmeal,我们直接在本进程生成ODEX文件。一个10MB的Dex,如果在Android 5.0生成一个ODEX的耗时大约在10秒以上,在Android 8.0使用speed模式大约在1秒左右,而通过oatmeal这个耗时大约在100毫秒左右。
|
||||
|
||||
我一直都很想把oatmeal引入进Tinker,但是比较担心兼容性的问题。因为每个版本ODEX格式都有一些差异,oatmeal是需要分版本适配的。
|
||||
|
||||
2. Native Library
|
||||
|
||||
现在音视频、美颜、AI、VR这些功能在应用越来越普遍,但这些库一般都是使用C或者C++写的,也就是说,我们的APK中Native Library的体积越来越大了。
|
||||
|
||||
对于Native Library,传统的优化方法可能就是去除Debug信息、使用c++_shared这些。那我们还有没有更好的优化方法呢?
|
||||
|
||||
Library压缩-
|
||||
跟Dex压缩一样,Library优化最有效果的方法也是使用XZ或者7-Zip压缩。
|
||||
|
||||
|
||||
|
||||
在默认的lib目录,我们只需要加载少数启动过程相关的Library,其他的Library我们都在首次启动时解压。对于Library格式来说,压缩率同样可以比Zip高30%左右,效果十分惊人。
|
||||
|
||||
Facebook有一个So加载的开源库SoLoader,它可以跟这套方案配合使用。和Dex压缩一样,压缩方案的主要缺点在于首次启动的时间,毕竟对于低端机来说,多线程的意义并不大,因此我们要在包体积和用户体验之间做好平衡。
|
||||
|
||||
Library合并与裁剪-
|
||||
对于Native Library,Facebook中的编译构建工具Buck也有两个比较硬核的高科技。当然在官方文档中是完全找不到的,它们都隐藏在源码中。
|
||||
|
||||
|
||||
Library合并。在Android 4.3之前,进程加载的Library数量是有限制的。在编译过程,我们可以自动将部分Library合并成一个。具体思路你可以参考文章《Android native library merging》以及Demo。
|
||||
|
||||
Library裁剪。Buck里面有一个relinker的功能,原理就是分析代码中JNI方法以及不同Library的方法调用,找到没有无用的导出symbol,将它们删掉。这样linker在编译的时候也会把对应的无用代码同时删掉,这个方法相当于实现了Library的ProGuard Shrinking功能。
|
||||
|
||||
|
||||
|
||||
|
||||
包体积监控
|
||||
|
||||
关于包体积,如果一直放任不管,几个版本之后就会给你很大的“惊喜”。我了解到一些应用对包体积卡得很紧,任何超过100KB的功能都需要审批。
|
||||
|
||||
对于包体积的监控,通常有下面几种:
|
||||
|
||||
|
||||
大小监控。这个非常好理解,每个版本跟上一个版本包体积的对比情况。如果某个版本体积增长过大,需要分析具体原因,是否有优化空间。
|
||||
|
||||
依赖监控。每一版本我们都需要监控依赖,这里包括新增JAR以及AAR依赖。这是因为很多开发者非常不细心,经常会不小心把一些超大的开源库引进来。
|
||||
|
||||
规则监控。如果发现某个版本包体积增长很大,我们需要分析原因。规则监控也就是将包体积的监控抽象为规则,例如无用资源、大文件、重复文件、R文件等。比如我在微信的时候,使用ApkChecker实现包体积的规则监控。
|
||||
|
||||
|
||||
|
||||
|
||||
包体积的监控最好可以实现自动化与平台化,作为发布流程的其中一个环节。不然通过人工的方式,很难持续坚持下去。
|
||||
|
||||
总结
|
||||
|
||||
今天我们一起分析了实现难度比较大的包体积优化方法,可能有人会想这些方法实现难度那么大,真的有价值吗?根据我的理解,现在我们已经到了移动优化的“深水区”,网上那些千篇一律的文章已经无法满足需求。也就是说,简单的方法我们都掌握了,而且也都已经在做了,需要考虑接下来应该如何进一步优化。
|
||||
|
||||
这时候就需要静下心来,学会思考与钻研,再往底层走走。我们要去研究APK的文件格式,进一步还要研究内部Dex、Library以及Resource的文件格式。同时思考整个编译流程,才能找到那些可以突破的地方。
|
||||
|
||||
在实现AndResGuard的时候,我就对resources.arsc格式以及Android加载资源的流程有非常深入的研究。几年过去了,对于资源的优化又有哪些新的秘籍呢?我们下一期就会讨论“资源优化”这个主题。
|
||||
|
||||
从Buck和ReDex看出来,Facebook比国内的研究真的要高深很多,希望他们可以补充一些文档,让我们学习起来更轻松一些。
|
||||
|
||||
课后作业
|
||||
|
||||
你的应用会关注包体积吗?你做过哪些包体积优化的工作,有哪些好的方法可以跟同学们分享呢?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的练习Sample,尝试使用ReDex这个项目来优化我们应用的包体积,主要有下面几个小任务:
|
||||
|
||||
|
||||
strip debuginfo
|
||||
|
||||
分包优化
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
229
专栏/Android开发高手课/23包体积优化(下):资源优化的进阶实践.md
Normal file
229
专栏/Android开发高手课/23包体积优化(下):资源优化的进阶实践.md
Normal file
@ -0,0 +1,229 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 包体积优化(下):资源优化的进阶实践
|
||||
上一期我们聊了Dex与Native Library的优化,是不是还有点意犹未尽的感觉呢?那安装包还有哪些可以优化的地方呢?
|
||||
|
||||
|
||||
|
||||
请看上面这张图,Assets、Resource以及签名metadata都是安装包中的“资源”部分,今天我们就一起来看看如何进一步优化资源的体积。
|
||||
|
||||
AndResGuard工具
|
||||
|
||||
在美团的一篇文章《Android App包瘦身优化实践》中,也讲到了很多资源优化相关的方法,例如WebP和SVG、R文件、无用资源、资源混淆以及语言压缩等。
|
||||
|
||||
在我们的安装包中,资源相关的文件具体有下面这几个,它们都是我们需要优化的目标文件。
|
||||
|
||||
|
||||
|
||||
想使用好AndResGuard工具,需要对安装包格式以及Android资源编译的原理有很深地理解,它主要有两个功能,一个是资源混淆,一个是资源的极限压缩。
|
||||
|
||||
接下来我们先来复习一下这个工具的核心实现,然后再进一步思考还有哪些地方需要继续优化。
|
||||
|
||||
1. 资源混淆
|
||||
|
||||
ProGuard的核心优化主要有三个:Shrink、Optimize和Obfuscate,也就是裁剪、优化和混淆。当初我在写AndResGuard的时候,希望实现的就是ProGuard中的混淆功能。
|
||||
|
||||
资源混淆的思路其实非常简单,就是把资源和文件的名字混淆成短路径:
|
||||
|
||||
Proguard -> Resource Proguard
|
||||
R.string.name -> R.string.a
|
||||
res/drawable/icon -> res/s/a
|
||||
|
||||
|
||||
那么这样的实现究竟对哪些资源文件有优化作用呢?
|
||||
|
||||
|
||||
resources.arsc。因为资源索引文件resources.arsc需要记录资源文件的名称与路径,使用混淆后的短路径res/s/a,可以减少整个文件的大小。
|
||||
|
||||
metadata签名文件。签名文件MF与SF都需要记录所有文件的路径以及它们的哈希值,使用短路径可以减少这两个文件的大小。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
ZIP文件索引。ZIP文件格式里面也需要记录每个文件Entry的路径、压缩算法、CRC、文件大小等信息。使用短路径,本身就可以减少记录文件路径的字符串大小。
|
||||
|
||||
|
||||
|
||||
|
||||
资源文件有一个非常大的特点,那就是文件数量特别多。以微信7.0为例,安装包中就有7000多个资源文件。所以说,资源混淆工具仅仅通过短路径的优化,就可以达到减少resources.arsc、签名文件以及ZIP文件大小的目的。
|
||||
|
||||
既然移动优化已经到了“深水区”,正如Dex和Library优化一样,我们需要对它们的格式以及特性有非常深入的研究,才能找到优化的思路。而我们要做的资源优化也是如此,要对resources.arsc、签名文件以及ZIP格式需要有非常深入的研究与思考。
|
||||
|
||||
2. 极限压缩
|
||||
|
||||
AndResGuard的另外一个优化就是极限压缩,它的极限压缩功能体现在两个方面:
|
||||
|
||||
|
||||
更高的压缩率。虽然我们使用的还是Zip算法,但是利用了7-Zip的大字典优化,APK的整体压缩率可以提升3%左右。
|
||||
|
||||
压缩更多的文件。Android编译过程中,下面这些格式的文件会指定不压缩;在AndResGuard中,我们支持针对resources.arsc、PNG、JPG以及GIF等文件的强制压缩。
|
||||
|
||||
|
||||
/* these formats are already compressed, or don't compress well */
|
||||
static const char* kNoCompressExt[] = {
|
||||
".jpg", ".jpeg", ".png", ".gif",
|
||||
".wav", ".mp2", ".mp3", ".ogg", ".aac",
|
||||
".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet",
|
||||
".rtttl", ".imy", ".xmf", ".mp4", ".m4a",
|
||||
".m4v", ".3gp", ".3gpp", ".3g2", ".3gpp2",
|
||||
".amr", ".awb", ".wma", ".wmv", ".webm", ".mkv"
|
||||
};
|
||||
|
||||
|
||||
这里可能会有一个疑问,为什么Android系统会专门选择不去压缩这些文件呢?
|
||||
|
||||
|
||||
压缩效果并不明显。这些格式的文件大部分本身已经压缩过,重新做Zip压缩效果并不明显。例如PNG和JPG格式,重新压缩只有3%~5%的收益,并不是十分明显。
|
||||
|
||||
读取时间与内存的考虑。如果文件是没有压缩的,系统可以利用mmap的方式直接读取,而不需要一次性解压并放在内存中。
|
||||
|
||||
|
||||
Android 6.0之后AndroidManifest支持不压缩Library文件,这样安装APK的时候也不需要把Library文件解压出来,系统可以直接mmap安装包中的Library文件。
|
||||
|
||||
|
||||
android:extractNativeLibs=“true”
|
||||
|
||||
|
||||
简单来说,我们在启动性能、内存和安装包体积之间又做了一个抉择。在上一期中我就讲过对于Dex和Library来说,最有效果的方法是使用XZ或者7-Zip压缩,对于资源来说也是如此,一些比较大的资源文件我们也可以考虑使用XZ压缩,但是在首次启动时需要解压出来。
|
||||
|
||||
进阶的优化方法
|
||||
|
||||
学习完AndResGuard工具的混淆和压缩功能的实现原理后,可以帮助我们加深对安装包格式以及Android资源编译的原理的认识。
|
||||
|
||||
但AndResGuard毕竟是几年前的产物,那现在又有哪些新的进阶优化方法呢?
|
||||
|
||||
1. 资源合并
|
||||
|
||||
在资源混淆方案中,我们发现资源文件的路径对于resources.arsc、签名信息以及ZIP文件信息都会有影响。而且因为资源文件数量非常非常多,导致这部分的体积非常可观。
|
||||
|
||||
那我们能不能把所有的资源文件都合并成同一个大文件,这样做肯定会比资源混淆方案效果更好。
|
||||
|
||||
|
||||
|
||||
事实上,大部分的换肤方案也是采用这个思路,这个大资源文件就相当于一套皮肤。因此我们完全可以把这套方案推广开来,但是实现起来还是需要解决不少问题的。
|
||||
|
||||
|
||||
资源的解析。我们需要模拟系统实现资源文件的解析,例如把PNG、JPG以及XML文件转换为Bitmap或者Drawable,这样获取资源的方法需要改成我们自定义的方法。
|
||||
|
||||
|
||||
// 系统默认的方式
|
||||
Drawable drawable = getResouces().getDrawable(R.drawable.loading);
|
||||
|
||||
// 新的获取方式
|
||||
Drawable drawable = CustomResManager.getDrawable(R.drawable.loading);
|
||||
|
||||
|
||||
那为什么我们不像SVG那样,直接把这些解析完的所有Drawable全部丢到系统的缓存中呢?这样代码就无需做太多修改?之所以没这么做主要是考虑对内存的影响,如果我们把全部的资源文件一次性全部解析,并且丢到系统的缓存中,这部分会占用非常大的内存。
|
||||
|
||||
|
||||
资源的管理。考虑到内存和启动时间,所有的资源也是用时加载,我们只需要使用mmap来加载“Big resource File”。同时我们还要实现自己的资源缓存池ResourceCache,释放不再使用的资源文件,这部分内容你可以参考类似Glide图片库的实现。
|
||||
|
||||
|
||||
我在逆向Facebook的App的时候也发现,它们的资源和多语言基本走的完全是自己的流程。在“UI优化”时我就说过,我们先在系统的框架下尝试做了很多的优化,但是渐渐发现这样的方式依然要受系统的各种制约,这时就要考虑去突破系统的限制,把所有的流程都接管过来。
|
||||
|
||||
当然我们也需要在性能和效率之间寻找平衡点,要看自己的应用当前更重视性能提升还是开发效率。
|
||||
|
||||
2. 无用资源
|
||||
|
||||
AndResGuard中的资源混淆实现的是ProGuard的Obfuscate,那我们是否可以同样实现资源的Shrink,也就是裁剪功能呢?应用通过长时间的迭代,总会有一些无用的资源,尽管它们在程序运行过程不会被使用,但是依然占据着安装包的体积。
|
||||
|
||||
事实上,Android官方早就考虑到这种情况了,下面我们一起来看看无用资源优化方案的演进过程。
|
||||
|
||||
第一阶段:Lint
|
||||
|
||||
从Eclipse时代开始,我们就开始使用Lint这个静态代码扫描工具,它里面就支持Unused Resources扫描。
|
||||
|
||||
|
||||
|
||||
然后我们直接选择“Remove All Unused Resources”,就可以轻松删除所有的无用资源了。既然它是第一阶段的方案,那Lint方案扫描具体的缺点是什么呢?
|
||||
|
||||
Lint作为一个静态扫描工具,它最大的问题在于没有考虑到ProGuard的代码裁剪。在ProGuard过程我们会shrink掉大量的无用代码,但是Lint工具并不能检查出这些无用代码所引用的无用资源。
|
||||
|
||||
第二阶段:shrinkResources
|
||||
|
||||
所以Android在第二阶段增加了“shrinkResources”资源压缩功能,它需要配合ProGurad的“minifyEnabled”功能同时使用。
|
||||
|
||||
如果ProGuard把部分无用代码移除,这些代码所引用的资源也会被标记为无用资源,然后通过资源压缩功能将它们移除。
|
||||
|
||||
android {
|
||||
...
|
||||
buildTypes {
|
||||
release {
|
||||
shrinkResources true
|
||||
minifyEnabled true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
是不是看起来很完美,但是目前的shrinkResources实现起来还有几个缺陷。
|
||||
|
||||
|
||||
没有处理resources.arsc文件。这样导致大量无用的String、ID、Attr、Dimen等资源并没有被删除。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
没有真正删除资源文件。对于Drawable、Layout这些无用资源,shrinkResources也没有真正把它们删掉,而是仅仅替换为一个空文件。为什么不能删除呢?主要还是因为resources.arsc里面还有这些文件的路径,具体你可以查看这个issues。
|
||||
|
||||
|
||||
所以尽管我们的应用有大量的无用资源,但是系统目前的做法并没有真正减少文件数量。这样resources.arsc、签名信息以及ZIP文件信息这几个“大头”依然没有任何改善。
|
||||
|
||||
那为什么Studio不把这些资源真正删掉呢?事实上Android也知道有这个问题,在它的核心实现ResourceUsageAnalyzer中的注释也写得非常清楚,并尝试解决这个问题提供了两种思路。
|
||||
|
||||
|
||||
|
||||
如果想解答系统为什么不能直接把这些资源删除,我们需要先回过头来重温一下Android的编译流程。
|
||||
|
||||
|
||||
|
||||
|
||||
由于Java代码需要用到资源的R.java文件,所以我们就需要把R.java提前准备好。
|
||||
|
||||
在编译Java代码过程,已经根据R.java文件,直接将代码中资源的引用替换为常量,例如将R.String.sample替换为0x7f0c0003。
|
||||
|
||||
.ap_资源文件的同步编译,例如resources.arsc、XML文件的处理等。
|
||||
|
||||
|
||||
如果我们在这个过程强行把无用资源文件删除,resources.arsc和R.java文件的资源ID都会改变(因为默认都是连续的),这个时候代码中已经替换过的0x7f0c0003就会出现资源错乱或者找不到的情况。
|
||||
|
||||
因此系统为了避免发生这种情况,采用了折中的方法,并没有二次处理resources.arsc文件,只是仅仅把无用的Drawable和Layout文件替换为空文件。
|
||||
|
||||
第三阶段:realShrinkResources
|
||||
|
||||
那怎么样才能真正实现无用资源的删除功能呢?ResourceUsageAnalyzer的注释中就提供了一个思路,我们可以利用resources.arsc中Public ID的机制,实现非连续的资源ID。
|
||||
|
||||
简单来说,就是keep住保留资源的ID,保证已经编译完的代码可以正常找到对应的资源。
|
||||
|
||||
|
||||
|
||||
但是重写resources.arsc的方法会比资源混淆更加复杂,我们既要从这个文件中抹去所有的无用资源相关信息,还要keep住所有保留资源的ID,相当于把整个文件都重写了。
|
||||
|
||||
正因为异常复杂,所以目前Android还没有提供这套方案的完整实现。我最近也正在按照这个思路来实现这套方案,希望完成后可以尽快开源出来。
|
||||
|
||||
总结
|
||||
|
||||
今天我们回顾了AndResGuard工具的实现原理,也学习了两种资源优化的进阶方式。特别是无用资源的优化,你可以看到尽管是无所不能的Google,也并没有把方案做到最好,依然存在一些妥协的地方。
|
||||
|
||||
其实这种不完美的地方还有很多很多,也正是有了这些不完美的地方,才会出现各种各样优秀的开源方案。也因此我们才会不断思考如何突破系统的限制,去实现更多、更底层的优化。
|
||||
|
||||
课后作业
|
||||
|
||||
对于Android的编译流程,你还有不理解的地方吗?对于安装包中的资源,你还有哪些好的优化方案?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
不知道你有没有想过,其实“第三阶段”的无用资源删除方案也并不是终极解决方案,因为它并没有考虑到无用的Assets资源。
|
||||
|
||||
但是对于Assets资源,代码中会有各种各样的引用方式,如果想准确地识别出无用的Assets并不是那么容易。当初在Matrix中,我们尝试提供了一套简单的实现,你可以参考UnusedAssetsTask。
|
||||
|
||||
希望你在课后也可以进一步思考,我们可以如何识别出无用的Assets资源,在这个过程中会遇到哪些问题?
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
119
专栏/Android开发高手课/24想成为Android高手,你需要先搞定这三个问题.md
Normal file
119
专栏/Android开发高手课/24想成为Android高手,你需要先搞定这三个问题.md
Normal file
@ -0,0 +1,119 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 想成为Android高手,你需要先搞定这三个问题
|
||||
专栏上线已经两个多月,模块一“高质量开发”也已经更新完毕,你掌握地如何呢?我知道有不少同学一直随着专栏更新积极学习、认真完成课后的练习作业,并且及时给我反馈,作为专栏作者我很欣慰。
|
||||
|
||||
但也有不少同学表示很难跟上专栏的进度,似乎对“如何成为Android开发高手”感到更加迷茫也更困惑了。“这个专栏实在是太难了!”“我日常工作根本用不上这些知识!”“我应该怎样做才能更好地学习这个专栏?”。太难了、工作用不到、想学但是又不知道从何入手,这是我听到同学们反馈最多的三个问题。
|
||||
|
||||
今天既然是专栏“模块一”的答疑时间,那我就来解答这三个问题,力求帮助迷茫的你拨开云雾,找到通向Android开发高手之路。
|
||||
|
||||
前几年在业务红利期,Atlas、Tinker、React Native、Weex、小程序大行其道,我们的应用程序堆积了各种各样的框架以及无数的业务代码。当现在需要出海东南亚,需要下沉到三四线城市时,在面对各种低端机和恶劣网络条件的时候,再猛然回头一看,会发现原来我们已经欠下了如此恐怖的“技术债”。
|
||||
|
||||
如果想成为一名开发高手,只做好需求是远远不够的,还需要有系统性解决应用性能和架构问题的能力。而这些问题本来就是很复杂的,可能对于一些同学来说解决复杂问题会感到很难,但你要成为高手,就一定要具备解决复杂问题的能力。下面我就先来谈谈这个专栏真的这么“难”吗?
|
||||
|
||||
问题一:这个专栏太难了?
|
||||
|
||||
|
||||
性能优化就像一个坑,你永远不知道自己跳进去的坑有多深 。
|
||||
|
||||
|
||||
在过去几个月,我一直在填“启动优化”这个坑,也对这句话有了更深的感触。
|
||||
|
||||
|
||||
业务优化:应用层。开始的时候通过业务代码的优化,我很轻松地就把启动速度优化了50%。但正如《大停滞》所说的,这只是摘完了“所有低垂的果实”。之后很快就陷入了停滞,就像冲进了一条漆黑的隧道,不知道还可以做哪些事情,很多现象从表面也不知道如何解释:I/O有时候为什么那么慢?线程的优化应该怎么样去衡量?
|
||||
|
||||
Android Framework:系统框架层。为了冲出这条漆黑的隧道,我去研究了Android的内存管理、文件系统、渲染框架等各个模块;学习如何去优化和监控I/O、线程、卡顿以及帧率,建立了各种各样的性能监控框架。为什么我对监控如此重视?这是因为对于大厂来说,“挖坑容易,填坑难”,几十上百人协同开发一个项目,我们不希望只是解决具体的某一个问题,而是要彻底解决某一类问题。但想要实现一个监控框架,前提是需要对Framework有非常充分地理解和研究。
|
||||
|
||||
Linux Kernel:内核层。再深入下去,我还需要利用Linux的一些机制,例如ftrace、Perf、JVMTI等。在做I/O的类重排、文件重排评估的时候,还需要自己去修改内核的参数,去刷ROM。
|
||||
|
||||
Hardware:硬件层。高端机和低端机的硬件差异究竟在哪里?eMMC闪存和UFS闪存的区别是什么?除了更加了解硬件的性能和特性,到了这个阶段我还希望可以向手机厂商和硬件厂商要性能。例如高通的CPU Boost、微信的Hardcoder、OPPO的开放平台等。
|
||||
|
||||
|
||||
|
||||
|
||||
启动优化的过程,就像是一个知识爬坡的过程。我们不停地尝试往底层深入,希望去摘更高的果实。那再回到你疑惑的问题:这个专栏难不难?难,因为它试图为你从上往下拆解整个知识架构。
|
||||
|
||||
坦白说,现在很多移动开发工程师更像是API工程师,背后的数据结构、算法和架构相关的知识是不达标的。这个时候如果想往底层走,就会感觉步步艰辛。但是上层的API很容易被Deprecated,即使你对Android的所有API倒背如流也无法成为真正的开发高手。这样的你,即便以后把Android替换成Fuchsia,你也还只是一个Dart API工程师。
|
||||
|
||||
相反越底层的东西越不容易过时,假如我们以后面对的不是Linux内核的系统,比如Fuchsia OS,也可以根据已经掌握的系统知识套用到现有的操作系统上,因为像内存管理、文件系统、信号机制、进程调度、系统调用、中断机制、驱动等内容都是共通的,在迁移到新的系统上时可以有一个全局的视角,帮助你快速上手。
|
||||
|
||||
因此我希望5年后再回头看这个专栏,它依然不会过时。所以从知识的深度来看,这个专栏的确难。那从知识的广度来看呢?
|
||||
|
||||
|
||||
|
||||
崩溃、内存、卡顿、I/O、渲染、网络…这个专栏涉及的知识的确非常多,而且这个专栏也只能提纲挈领,还需要你花时间去补充文章里给你链接的更多知识。
|
||||
|
||||
所以说无论从知识的深度还是广度来看,专栏的确算是比较难。那这个专栏究竟有多难呢?可以说超越了大多数腾讯T3或者阿里P7的水平。如果你还没到达这个级别,看不懂是正常的,因为大部分内容BAT的工程师第一遍可能也看不懂。
|
||||
|
||||
把这个专栏写“难”,并不是因为我想炫技,而是成为一名真正的Android开发高手本来就没有想象得那么容易。只有看到差距才有前进的动力,2019年你需要真正迈出走向高手的第一步。
|
||||
|
||||
问题二:专栏所讲的工作上用不到?
|
||||
|
||||
|
||||
我一直只是做业务,没有机会接触性能。对于这个专栏,我更期待可以对日常工作有帮助的内容。
|
||||
|
||||
|
||||
正如我上面所说的,之所以选择写这些内容,是因为它们是移动开发高手所必须掌握的。如果我告诉你MAT怎么用、Profiler怎么用,或者告诉你如何去写界面,或许这些内容对你日常工作能有帮助,但仅仅这些你依然不可能成为一名Android的高手。
|
||||
|
||||
这个专栏目希望可以提高你的个人能力,帮助你成长,或许不一定与你当前的工作完全契合,那这个问题怎么解决呢?我认为完全不需要等着别人给你安排,我们在完成工作之余,可以尝试去解决一些应用性能和架构的问题,又或者是团队效率的问题。
|
||||
|
||||
这样你工作上的表现也会超出上级的预期,并且可能以后这些高级问题大家都会来咨询你。同事对你建立了信任,这些事情以后可能就都由你负责了,你也成为了大家心目中的“开发高手”。
|
||||
|
||||
当然如果你认为目前的平台对未来的发展制约太多,那这个专栏同样也是你去面试大厂的一块非常好的敲门砖。
|
||||
|
||||
“打铁还需自身硬”,专栏里很多内容大厂面试官可能也不熟悉,我专栏里讲的很多问题其实大厂目前做得也都不很完善。在专栏中,我会力求去分析腾讯、阿里、头条、Facebook、Google等国内外大厂目前遇到的问题、尝试解决的方案,以及未来优化的方向。希望可以扩宽你的视野,帮你知道大厂在玩什么、他们都在意什么,因为这些对于面试来说也非常重要。
|
||||
|
||||
虽然这个专栏涉及那么多的内容,但毕竟我们不可能每一项都精通。之前我在一篇文章曾经讲过微信的T型人才理论,说的是微信在面试时,不会问你Android和iOS的API怎么使用,而是希望候选人在某一个领域研究得特别牛、特别深入,并且是可以打动面试官的。这意味着如果你在某一个领域证明过自己,那微信也会愿意在其他领域给你机会。这里我推荐你看看《谈谈腾讯的技术价值观与技术人才修炼》这篇文章。
|
||||
|
||||
不夸张地说,LeetCode适量刷题,加上这个专栏知识的广度,如果再找其中一两个知识点更加深入地研究,这样的话进入大公司是不会有太大问题的。
|
||||
|
||||
问题三:这个专栏应该怎么学习
|
||||
|
||||
|
||||
我工作用不上,平时还那么忙,应该怎么去学习这个专栏呢?
|
||||
|
||||
|
||||
如果专栏的学习可以跟我们的工作紧密结合在一起,的确是一个非常理想的情况。但是即使是理想情况,关键也还是要靠个人的自驱力。
|
||||
|
||||
在极客时间的年终总结里,看到一句话特别有感触:“2018年买了32个专栏,完成了开篇词的学习”。这个专栏应该怎么学?你首先应该抛弃焦虑,无所畏惧地往前冲。
|
||||
|
||||
既然腾讯T3或者阿里P7都会觉得难,如果看不懂真的不要气馁,也不要焦虑,可以结合参考资料慢慢看。因为专栏一直都在,可以按照自己的节奏来学习,甚至可以用2019年一整年的时间来“死磕”它,但千万不要放弃。
|
||||
|
||||
还记得当初你在专栏“导读”里立下的flag吗,你可以利用这个专栏好好地将知识架构补充完整。我们的基础能力提升了,未来无论是大前端还是Flutter都会有用武之地,也就更加无需担心Android系统是否会被颠覆。
|
||||
|
||||
这个专栏应该怎么学?我给你的第二个建议是多看、多想、多实践。看再多的文章,不去思考文章所讲的内容和意图也是没用的;思考再多,不去动手真正实践也是没用的。
|
||||
|
||||
正因为实践这么重要,所以我在写专栏时才会把大量的时间花在Sample上面。想想现在有那么多的开源项目,可能我们只是调用API或者提一两个issue,并不算是真正使用。想要真正用好开源项目,需要你去研究内部的机制,思考作者的意图。只有在认真研究之后,我们才能发现优化的空间。
|
||||
|
||||
在学习专栏时,我建议可以先挑一两个知识点开始深入学习。如果你觉得崩溃相关的内容比较困难,可以先略过,等学习完其他知识后再回头来看,肯定会有不一样的体会。
|
||||
|
||||
|
||||
|
||||
我们的学习过程也是一个树立信心的过程,可能在某几个阶段会感到煎熬,但是只要你攀登上去了,一切都会柳暗花明。
|
||||
|
||||
另外,专栏的很多文章我喜欢用演进的思路去讲,比如耗电的演进、渲染的演进、Android Runtime的演进等。同样的,你成为高手的道路应该也是不停向前演进的,可能刚开始时不一定是最好的,但是只要方向是正确的,终究可以到达“高手”这个终点。
|
||||
|
||||
最后,也是我反复强调过的,专栏的学习还需要结合大量的背景知识和外部资料,推荐的书籍你可以参考《专栏学得苦?可能你还需要一份配套学习书单》。
|
||||
|
||||
总结
|
||||
|
||||
今天我们一起来定一个“小目标”也不迟:按照专栏给出的方向尝试一下、努力一下,走向通往Android开发的高手之路。
|
||||
|
||||
未来移动开发无论是变成大前端还是Flutter的世界,性能、效率和架构都是永恒不变的主线。今天我们在Android开发打下的坚实基础,未来也会帮助我们更好地理解和深入新的开发模式或者新的系统。崩溃、内存、存储、渲染、I/O、网络…这些知识以及它们背后的底层原理依然还是非常重要的。对于其他领域也是如此,一个前端开发工程师不能只知道HTML、CSS和JS语法,还需要知道它们编译的原理、浏览器实现的原理以及底层的渲染机制等。
|
||||
|
||||
最后我想说一个人学习可能会比较孤独,如果可以找到更多志同道合的朋友一起学习,效果可能会更好。正如很多开发所提倡的结对编程,推荐你看看《两位拯救谷歌的超级工程师的故事》。除了结对编程的范例之外,文中有一段话对我的触动也非常大。
|
||||
|
||||
|
||||
Jeff与Sanjay对于计算机的工作原理非常熟悉,能够立足bit层级进行思考。Jeff曾经整理出一份《每位程序员都应该了解的那些延迟数字》清单。虽然名为“每位程序员都应该了解”,但大多数从业者对这些数字其实非常陌生——例如一级缓存引用通常需要半纳秒,或者从内存中顺序读取1MB 大概需要250微秒等等。但这些数字已经直接烙进了Jeff与Sanjay的大脑当中。凭借着他们对谷歌核心软件的多次重写,该系统的容量已经提升至新的数量级。
|
||||
|
||||
|
||||
2019年已经过去将近1/6了,今年你定的目标完成得怎么样了?还有哪些学习计划?有什么感受想跟其他同学分享吗?欢迎留言跟我和其他同学一起见证你的成长。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
113
专栏/Android开发高手课/25如何提升组织与个人的研发效能?.md
Normal file
113
专栏/Android开发高手课/25如何提升组织与个人的研发效能?.md
Normal file
@ -0,0 +1,113 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 如何提升组织与个人的研发效能?
|
||||
通过“高质量开发”模块的学习,相信你已经对打造一款高质量应用信心满满了。不过人们常说“提质增效”,总喜欢把质量和效率联系在一起,我们都希望在保证质量的前提下,为自己的团队提速。
|
||||
|
||||
特别是移动互联网在红海厮杀的今天,快速试错变得越来越重要,敏捷开发也被越来越多的团队所推崇。有些时候为了效率我们甚至愿意牺牲部分性能,而选择在合适的时间去偿还这些“债务”。
|
||||
|
||||
在“高质量开发”模块中,我侧重如何给应用交付的每个步骤做好“质检”。今天我们就一起来开启新的征程,从组织和个人研发效能的角度,重新帮你审视整个应用交付的过程。
|
||||
|
||||
组织的研发效能
|
||||
|
||||
1. 何为研发效能
|
||||
|
||||
在讨论如何优化组织研发效能之前,请你先思考一下什么是研发效能。
|
||||
|
||||
我们平常开发的过程,是从产品的一个需求想法,转变为功能并且发布上线。这个过程会涉及产品、设计、开发、测试,更多的时候可能还会拉上前端、后台或者算法。
|
||||
|
||||
产品的交付涉及很多的流程和人员,虽然设计人员出图很快、我们开发效率很高,但也并不能代表研发效能同样很高,研发效能是对整个产品最终交付的速度和质量负责。在《如何衡量研发效能》一文中,将研发效能定义为一个组织持续快速交付价值的能力。
|
||||
|
||||
在文中,作者从流动效率、资源效率和质量进一步拆解了研发效能,并提出了研发效能的五个衡量标准。
|
||||
|
||||
|
||||
|
||||
对于客户端研发来说,我们是不是只要保证按时按质实现需求就可以了呢?有很多公司,尽管实行“996”,产品、开发和测试看起来的确都很忙了,但是交付速度和质量却仍然不令人满意:产品埋怨开发效率低、开发埋怨产品需求不明确、测试埋怨开发质量差、开发埋怨测试发现不了问题等。
|
||||
|
||||
这是因为什么呢?对于研发效能这个话题,虽然我并不是这个领域的专家,但根据我多年的工作经验,我可以谈谈个人的两点思考:
|
||||
|
||||
|
||||
提效是每个人的职责。尽管在BAT这些大厂,会有专门的研发效能部门,但是效能的提升并不是单单只依靠效能部门,或者认为是领导的事情,而是组织里面每一个人都应该去思考的事情。例如天猫设立的效能目标是“211”,也就是2周交付周期、1周开发周期以及1小时发布时长。那团队中的每一个人都应该为这一共同目标而努力,回顾在每个发布迭代中遇到的问题以及改进的建议。
|
||||
|
||||
提效不仅限于写Android代码。尽管我们是Android开发工程师,但是我们的工作不应该局限在写Android代码上,关键还是解决需求场景。无论是APM系统、大网络平台、大数据平台,这种大型的前后端一体化解决方案,还是需求流程上或者增强产研测沟通信任的优化。只要是对提升效能有帮助的,我们都可以尝试去实践。在建立了这种整体统筹的思维之后,未来我们想转后端、前端,甚至是产品都会有很大的帮助。
|
||||
|
||||
|
||||
微信在很早的时候就引入了Google的OKR绩效考核制度,记得在2017年Android团队定了“质量”“效能”和“影响力”这三个目标。
|
||||
|
||||
接着Android团队的30多个研发也都在围绕这三个目标来制定自己的工作计划,例如针对“效能”来说,有的人抽离一个UI库或者动画库,有的人写一个监控工具,有的人提升编译速度,有的人写一个Web的值班页面,有的人优化需求评审的流程…
|
||||
|
||||
这样大家集思广益,一起思考、一起讨论,为达成组织的共同目标而努力,这也是为什么微信开发人员虽然不多,但是战斗力在业界数一数二的原因。
|
||||
|
||||
2. 应用交付的流程
|
||||
|
||||
前面我从整个组织的角度,定义了研发效能的含义以及衡量它的五个标准。同时也结合我在微信的经历,谈了我关于提升研发效能的两点思考。可能大部分同学还是感觉整个产品的交付流程类似产品、UI设计这些环节是研发人员无法把控的,那接下来我只从研发的流程来看如何提高效能。
|
||||
|
||||
正如我在专栏导读《如何打造高质量的应用》所说的,一个应用至少会经过开发、编译CI、测试、灰度和发布这几个阶段。下面我从效能的角度,分别看看每个阶段需要关注什么问题。
|
||||
|
||||
|
||||
|
||||
|
||||
开发阶段。开发阶段解决的是如何用尽可能短的时间,完成尽可能多的需求,并且保证开发的质量,不至于后期过多的返工。项目的架构应该如何选择?例如应该采用原生开发,还是Web、React Native、Flutter这样的跨平台方案。如何提升团队人员的能力以及工具和框架的成熟度?有哪些提升团队工作效率的技巧?
|
||||
|
||||
编译、CI阶段。编译CI阶段解决的是如何发现和优化开发阶段的一些编码问题,以及快速构建出最终产物。Google的Gradle、Facebook的Buck为编译速度做了哪些努力?Flutter的Hot Reload为什么可以这么快?AspectJ、ASM、ReDex这三种插桩方法的原理和差别是什么?腾讯的RDM、阿里的摩天轮这些编译构建平台有什么特别之处?
|
||||
|
||||
测试阶段。测试阶段是为了发现交付过程的质量问题。测试的确不容易,自动化测试成本高,也不容易把控发布质量。那如何可以让测试覆盖更多的场景,Monkey、性能测试、UI测试应该怎么实践?腾讯的RDM、蚂蚁的伙伴是如何做到人人都是测试?网易的Airtest测试框架有哪些过人之处?
|
||||
|
||||
灰度、发布阶段。灰度发布是为了验证产品的效果。发布并不是把包丢出去就可以了,我们需要对自己的产品负责。那如何准确、快速地评估产品数据?头条、快手是如何做到精准运营和A/B测试?如果遇到疑难的线上问题应该怎么解决?复杂多变的网络问题又应该怎样去定位和分析?
|
||||
|
||||
|
||||
当然为了提升在这个过程的效率,我们会用到一些很有用的第三方工具,例如用于CodeReview的Gerrit、持续集成的Jenkins、代码审计的Coverity等。
|
||||
|
||||
工具不仅可以将大量人工操作变成自动化,也可以方便团队更好的协作。例如我们把需求的流转、进度安排变得可视化,可以大大地减少产研测团队之间消息的隔阂,在这方面阿里的AONE或者腾讯的TPAD都做得非常不错。
|
||||
|
||||
项目管理、需求管理、代码托管、构建/部署、测试平台…都是我们常用的工具,类似阿里的云效会提供这样的一站式平台,从需求发起到分支管理、代码review,再到测试发布。在过去,这些工具都是各大公司研发效能部门多年的结晶,一般都不愿意对外提供。但是得益于云时代、TOB时代的到来,现在都愿意打包成商品供我们使用。
|
||||
|
||||
当然每个项目都会有自己特殊的情况,这些工具也不一定可以完全符合我们的需要,我们可以根据自己的情况选择合适的服务,或者直接开发自己的工具。
|
||||
|
||||
个人的研发效率
|
||||
|
||||
个人作为整个组织的一部分,我们效率的提升也会对组织有正向的作用。特别是对某些小团队或者独立开发者来说,个人可能就代表了整个团队。
|
||||
|
||||
关于个人效率的提升和时间的管理,有很多书籍专门在讲这个内容。下面从我看到的一些不好的现象,谈谈我个人两个比较深的体会。
|
||||
|
||||
1. 思考
|
||||
|
||||
|
||||
年轻人千万不要碰的东西之一,便是能获得短期快感的软件。它们会在不知不觉中偷走你的时间,消磨你的意志力,摧毁你向上的勇气。
|
||||
|
||||
|
||||
随着我们接触到的信息越来越多,越来越多的人很难保持对事情的专注力。工作期间经常想着去刷一下抖音、头条、微信、王者荣耀,强行把时间打破成碎片。
|
||||
|
||||
|
||||
跟产品开了一天的会,他的需求有了,你的代码呢?
|
||||
|
||||
|
||||
可能也有一部分同学他们不刷抖音和头条,但是在上班时间也会被各种邮件、钉钉、会议折磨得痛不欲生。针对这个问题,我的做法是每天上午和下午都会至少保留一个小时“目空一切”的时间,不看邮件、不看钉钉、不接会议。当然有的时候也是无法避免被老板当面拉回到“现实”。
|
||||
|
||||
我经常看到团队里面的一些人也存在这种现象,最终表现可能是这个人经常“996”,看起来很忙,但是产出并不高,而且个人成长也不明显。
|
||||
|
||||
每天我们应该需要有一段时间真正的静下心来工作,而且每过一段时间也要重新审视一下自己的工作,有哪些地方做的不够好?有没有什么事情是自己或者团队的人正在反复而低效在做的,是否可以优化。
|
||||
|
||||
2. 方法
|
||||
|
||||
关于方法,这里我只说两点,也是同学们经常会出现的问题。
|
||||
|
||||
|
||||
做事的方法。曾经看到一些开发人员,非常喜欢用二分法来排查问题。当测试给他报Bug时,他会非常熟练的操作Git命令,花上一两个小时打出几十个验证包,不辞劳苦地尝试。当然二分法我也使用过,在毫无头绪的时候的确可以“死马当活马医”。但是我们在使用这个“大杀器”之前,起码应该经过自己的思考,尝试正面去迎击Bug本身。
|
||||
|
||||
提问的方法。在微信和QQ群里面,经常会看到有些同学在群里问一个问题,可能Google一下就可以得到答案。然后他们在群里灌了一个小时水,最后还是没有任何答案。在做Tinker开源的时候,我有时也被一些使用者问得心情不再愉悦。事实上提问题是非常体现技术和职业素养的,我们在提问题之前需要经过自己的思考和努力,在这里推荐你看看《提问的艺术》。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
“吾日三省吾身”,无论是组织的研发效能,还是个人的工作效率,我们都需要学会经常去回顾和思考,快速演进、快速迭代,争取未来做得更好。
|
||||
|
||||
你在工作和学习的效率上,遇到过哪些问题?对于如何提升工作和学习的效率,你还有什么好的方法和建议吗?欢迎留言分享给我和其他同学。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
249
专栏/Android开发高手课/26关于编译,你需要了解什么?.md
Normal file
249
专栏/Android开发高手课/26关于编译,你需要了解什么?.md
Normal file
@ -0,0 +1,249 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 关于编译,你需要了解什么?
|
||||
作为Android工程师,我们每天都会经历无数次编译;而对于大型项目来说,每次编译就意味着要花去一杯咖啡的时间。可能我讲具体的数字你会更有体会,当时我在微信时,全量编译Debug包需要5分钟,而编译Release包更是要超过15分钟。
|
||||
|
||||
如果每次编译可以减少1分钟,对微信整个Android团队来说就可以节约1200分钟(团队40人 × 每天编译30次 × 1分钟)。所以说优化编译速度,对于提升整个团队的开发效率是非常重要的。
|
||||
|
||||
那应该怎么样优化编译速度呢?微信、Google、Facebook等国内外大厂都做了哪些努力呢?除了编译速度之外,关于编译你还需要了解哪些知识呢?
|
||||
|
||||
关于编译
|
||||
|
||||
虽然我们每天都在编译,那到底什么是编译呢?
|
||||
|
||||
你可以把编译简单理解为,将高级语言转化为机器或者虚拟机所能识别的低级语言的过程。对于Android来说,这个过程就是把Java或者Kotlin转变为Android虚拟机运行的Dalvik字节码的过程。
|
||||
|
||||
编译的整个过程会涉及词法分析、语法分析 、语义检查和代码优化等步骤。对于底层编译原理感兴趣的同学,你可以挑战一下编译原理的三大经典巨作:龙书、虎书、鲸鱼书。
|
||||
|
||||
但今天我们的重点不是底层的编译原理,而是希望一起讨论Android编译需要解决的问题是什么,目前又遇到了哪些挑战,以及国内外大厂又给出了什么样的解决方案。
|
||||
|
||||
1. Android编译的基础知识
|
||||
|
||||
无论是微信的编译优化,还是Tinker项目,都涉及比较多的编译相关知识,因此我在Android编译方面研究颇多,经验也比较丰富。Android的编译构建流程主要包括代码、资源以及Native Library三部分,整个流程可以参考官方文档的构建流程图。
|
||||
|
||||
|
||||
|
||||
Gradle是Android官方的编译工具,它也是GitHub上的一个开源项目。从Gradle的更新日志可以看到,当前这个项目还更新得非常频繁,基本上每一两个月都会有新的版本。对于Gradle,我感觉最痛苦的还是Gradle Plugin的编写,主要是因为Gradle在这方面没有完善的文档,因此一般都只能靠看源码或者断点调试的方法。
|
||||
|
||||
但是编译实在太重要了,每个公司的情况又各不相同,必须强行造一套自己的“轮子”。已经开源的项目有Facebook的Buck以及Google的Bazel。
|
||||
|
||||
为什么要自己“造轮子”呢?主要有下面几个原因:
|
||||
|
||||
|
||||
统一编译工具。Facebook、Google都有专门的团队负责编译工作,他们希望内部的所有项目都使用同一套构建工具,这里包括Android、Java、iOS、Go、C++等。编译工具的统一优化,所有项目都会受益。
|
||||
|
||||
代码组织管理架构。Facebook和Google的代码管理有一个非常特别的地方,就是整个公司的所有项目都放到同一个仓库里面。因此整个仓库非常庞大,所以他们也不会使用Git。目前Google使用的是Piper,Facebook是基于HG修改的,也是一种基于分布式的文件系统。
|
||||
|
||||
极致的性能追求。Buck和Bazel的性能的确比Gradle更好,内部包含它们的各种编译优化。但是它们或多或少都有一些定制的味道,例如对Maven、JCenter这样的外部依赖支持的也不是太好。
|
||||
|
||||
|
||||
|
||||
|
||||
“程序员最痛恨写文档,还有别人不写文档”,所以它们的文档也是比较少的,如果想做二次定制开发会感到很痛苦。如果你想把编译工具切换到Buck和Bazel,需要下很大的决心,而且还需要考虑和其他上下游项目的协作。当然即使我们不去直接使用,它们内部的优化思路也非常值得我们学习和参考。
|
||||
|
||||
Gradle、Buck、Bazel都是以更快的编译速度、更强大的代码优化为目标,我们下面一起来看看它们做了哪些努力。
|
||||
|
||||
2. 编译速度
|
||||
|
||||
回想一下我们的Android开发生涯,在编译这件事情上面究竟浪费了多少时间和生命。正如前面我所说,编译速度对团队效率非常重要。
|
||||
|
||||
关于编译速度,我们最关心的可能还是编译Debug包的速度,尤其是增量编译(incremental build)的速度,希望可以做到更加快速的调试。正如下图所示,我们每次代码验证都要经过编译和安装两个步骤。
|
||||
|
||||
|
||||
|
||||
|
||||
编译时间。把Java或者Kotlin代码编译为“.class“文件,然后通过dx编译为Dex文件。对于增量编译,我们希望编译尽可能少的代码和资源,最理想情况是只编译变化的部分。但是由于代码之间的依赖,大部分情况这并不可行。这个时候我们只能退而求其次,希望编译更少的模块。Android Plugin 3.0使用Implementation代替Compile,正是为了优化依赖关系。
|
||||
|
||||
安装时间。我们要先经过签名校验,校验成功后会有一大堆的文件拷贝工作,例如APK文件、Library文件、Dex文件等。之后我们还需要编译Odex文件,这个过程特别是在Android 5.0和6.0会非常耗时。对于增量编译,最好的优化是直接应用新的代码,无需重新安装新的APK。
|
||||
|
||||
|
||||
对于增量编译,我先来讲讲Gradle的官方方案Instant Run。在Android Plugin 2.3之前,它使用的Multidex实现。在Android Plugin 2.3之后,它使用Android 5.0新增的Split APK机制。
|
||||
|
||||
如下图所示,资源和Manifest都放在Base APK中, 在Base APK中代码只有Instant Run框架,应用的本身的代码都在Split APK中。
|
||||
|
||||
|
||||
|
||||
Instant Run有三种模式,如果是热交换和温交换,我们都无需重新安装新的Split APK,它们的区别在于是否重启Activity。对于冷交换,我们需要通过adb install-multiple -r -t重新安装改变的Split APK,应用也需要重启。
|
||||
|
||||
虽然无论哪一种模式,我们都不需要重新安装Base APK。这让Instant Run看起来是不是很不错,但是在大型项目里面,它的性能依然非常糟糕,主要原因是:
|
||||
|
||||
|
||||
多进程问题。“The app was restarted since it uses multiple processes”,如果应用存在多进程,热交换和温交换都不能生效。因为大部分应用都会存在多进程的情况,Instant Run的速度也就大打折扣。
|
||||
|
||||
Split APK安装问题。虽然Split APK的安装不会生成Odex文件,但是这里依然会有签名校验和文件拷贝(APK安装的乒乓机制)。这个时间需要几秒到几十秒,是不能接受的。
|
||||
|
||||
javac问题。在Gradle 4.6之前,如果项目中运用了Annotation Processor。那不好意思,本次修改以及它依赖的模块都需要全量javac,而这个过程是非常慢的,可能会需要几十秒。这个问题直到Gradle 4.7才解决,关于这个问题原因的讨论你可以参考这个Issue。
|
||||
|
||||
|
||||
你还可以看看这一个Issue:“full rebuild if a class contains a constant”,假设修改的类中包含一个“public static final”的变量,那同样也不好意思,本次修改以及它依赖的模块都需要全量javac。这是为什么呢?因为常量池是会直接把值编译到其他类中,Gradle并不知道有哪些类可能使用了这个常量。
|
||||
|
||||
询问Gradle的工作人员,他们出给的解决方案是下面这个:
|
||||
|
||||
// 原来的常量定义:
|
||||
public static final int MAGIC = 23
|
||||
|
||||
// 将常量定义替换成方法:
|
||||
public static int magic() {
|
||||
return 23;
|
||||
}
|
||||
|
||||
|
||||
对于大型项目来说,这肯定是不可行的。正如我在Issue中所写的一样,无论我们是不是真正改到这个常量,Gradle都会无脑的全量javac,这样肯定是不对的。事实上,我们可以通过比对这次代码修改,看看是否有真正改变某一个常量的值。
|
||||
|
||||
但是可能用过阿里的Freeline或者蘑菇街的极速编译的同学会有疑问,它们的方案为什么不会遇到Annotation和常量的问题?
|
||||
|
||||
事实上,它们的方案在大部分情况比Instant Run更快,那是因为牺牲了正确性。也就是说它们为了追求更快的速度,直接忽略了Annotation和常量改变可能带来错误的编译产物。Instant Run作为官方方案,它优先保证的是100%的正确性。
|
||||
|
||||
当然Google的人也发现了Instant Run的种种问题,在Android Studio 3.5之后,对于Android 8.0以后的设备将会使用新的方案“Apply Changes”代替Instant Run。目前我还没找到关于这套方案更多的资料,不过我认为应该是抛弃了Split APK机制。
|
||||
|
||||
一直以来,我心目中都有一套理想的编译方案,这套方案安装的Base APK依然只是一个壳APK,真正的业务代码放到Assets的ClassesN.dex中。
|
||||
|
||||
|
||||
|
||||
|
||||
无需安装。依然使用类似Tinker热修复的方法,每次只把修改以及依赖的类插入到pathclassloader的最前方就可以,不熟悉的同学可以参考《微信Android热补丁实践演进之路》中的Qzone方案。
|
||||
|
||||
Oatmeal。为了解决首次运行时Assets中ClassesN.dex的Odex耗时问题,我们可以使用“安装包优化“中讲过的ReDex中的黑科技:Oatmeal。它可以在100毫秒以内生成一个完全解释执行的Odex文件。
|
||||
|
||||
关闭JIT。我们通过在AndroidManifest指定android:vmSafeMode=“true”,关闭虚拟机的JIT优化,这样也就不会出现Tinker在Android N混合编译遇到的问题。
|
||||
|
||||
|
||||
这套方案应该可以完全解决Instant Run当前的各种问题,我也希望对编译优化感兴趣的同学可以自行实现这一套方案,并能开源出来。
|
||||
|
||||
对于编译速度的优化,我还有几个建议:
|
||||
|
||||
|
||||
更换编译机器。对于实力雄厚的公司,直接更换Mac或者其他更给力的设备作为编译机,这种方式是最简单的。
|
||||
|
||||
Build Cache。可以将大部分不常改变的项目拆离出去,并使用远端Cache模式保留编译后的缓存。
|
||||
|
||||
升级Gradle和SDK Build Tools。我们应该及时去升级最新的编译工具链,享受Google的最新优化成果。
|
||||
|
||||
使用Buck。无论是Buck的exopackage,还是代码的增量编译,Buck都更加高效。但我前面也说过,一个大型项目如果要切换到Buck,其实顾虑还是比较多的。在2014年初微信就接入了Buck,但是因为跟其他项目协作的问题,导致在2015年切换回Gradle方案。相比之下,可能目前最热的Flutter中Hot Reload秒级编译功能会更有吸引力。
|
||||
|
||||
|
||||
当然最近几个Android Studio版本,Google也做了大量的其他优化,例如使用AAPT2替代了AAPT来编译Android资源。AAPT2实现了资源的增量编译,它将资源的编译拆分成Compile和Link两个步骤。前者资源文件以二进制形式编译Flat格式,后者合并所有的文件再打包。
|
||||
|
||||
除了AAPT2,Google还引入了d8和R8,下面分别是Google提供的一些测试数据。
|
||||
|
||||
-
|
||||
|
||||
|
||||
那什么是d8和R8呢?除了编译速度的优化,它们还有哪些其他的作用?
|
||||
|
||||
3. 代码优化
|
||||
|
||||
对于Debug包编译,我们更关心速度。但是对于Release包来说,代码的优化更加重要,因为我们会更加在意应用的性能。
|
||||
|
||||
下面我就分别讲讲ProGuard、d8、R8和ReDex这四种我们可能会用到的代码优化工具。
|
||||
|
||||
ProGuard
|
||||
|
||||
在微信Release包12分钟的编译过程里,单独ProGuard就需要花费8分钟。尽管ProGuard真的很慢,但是基本每个项目都会使用到它。加入了ProGuard之后,应用的构建过程流程如下:
|
||||
|
||||
|
||||
|
||||
ProGuard主要有混淆、裁剪、优化这三大功能,它的整个处理流程是:
|
||||
|
||||
|
||||
|
||||
其中优化包括内联、修饰符、合并类和方法等30多种,具体介绍与使用方法你可以参考官方文档。
|
||||
|
||||
d8
|
||||
|
||||
Android Studio 3.0推出了d8,并在3.1正式成为默认工具。它的作用是将“.class”文件编译为Dex文件,取代之前的dx工具。
|
||||
|
||||
|
||||
|
||||
d8除了更快的编译速度之外,还有一个优化是减少生成的Dex大小。根据Google的测试结果,大约会有3%~5%的优化。
|
||||
|
||||
|
||||
|
||||
R8
|
||||
|
||||
R8在Android Studio 3.1中引入,它的志向更加高远,它的目标是取代ProGuard和d8。我们可以直接使用R8把“.class”文件变成Dex。
|
||||
|
||||
|
||||
|
||||
同时,R8还支持ProGuard中混淆、裁剪、优化这三大功能。由于目前R8依然处于实验阶段,网上的介绍资料并不多,你可以参考下面这些资料:
|
||||
|
||||
|
||||
ProGuard和R8对比:ProGuard and R8: a comparison of optimizers。
|
||||
Jake Wharton大神的博客最近有很多R8相关的文章:https://jakewharton.com/blog/。
|
||||
|
||||
|
||||
R8的最终目的跟d8一样,一个是加快编译速度,一个是更强大的代码优化。
|
||||
|
||||
ReDex
|
||||
|
||||
如果说R8是未来想取代的ProGuard的工具,那Facebook的内部使用的ReDex其实已经做到了。
|
||||
|
||||
Facebook内部的很多项目都已经全部切换到ReDex,不再使用ProGuard了。跟ProGuard不同的是,它直接输入的对象是Dex,而不是“.class”文件,也就是它是直接针对最终产物的优化,所见即所得。
|
||||
|
||||
在前面的文章中,我已经不止一次提到ReDex这个项目,因为它里面的功能实在是太强大了,具体可以参考专栏前面的文章《包体积优化(上):如何减少安装包大小?》。
|
||||
|
||||
|
||||
Interdex:类重排和文件重排、Dex分包优化。
|
||||
Oatmeal:直接生成的Odex文件。
|
||||
StripDebugInfo:去除Dex中的Debug信息。
|
||||
|
||||
|
||||
此外,ReDex中例如Type Erasure和去除代码中的Aceess方法也是非常不错的功能,它们无论对包体积还是应用的运行速度都有帮助,因此我也鼓励你去研究和实践一下它们的用法和效果。但是ReDex的文档也是万年不更新的,而且里面掺杂了一些Facebook内部定制的逻辑,所以它用起来的确非常不方便。目前我主要还是直接研究它的源码,参考它的原理,然后再直接单独实现。
|
||||
|
||||
事实上,Buck里面其实也还有很多好用的东西,但是文档里面依然什么都没有提到,所以还是需要“read the source code”。
|
||||
|
||||
|
||||
Library Merge和Relinker
|
||||
多语言拆分
|
||||
分包支持
|
||||
ReDex支持
|
||||
|
||||
|
||||
持续交付
|
||||
|
||||
Gradle、Buck、Bazel它们代表的都是狭义上的编译,我认为广义的编译应该包括打包构建、Code Review、代码工程管理、代码扫描等流程,也就是业界最近经常提起的持续集成。
|
||||
|
||||
|
||||
|
||||
目前最常用的持续集成工具有Jenkins、GitLab CI、Travis CI等,GitHub也有提供自己的持续集成服务。每个大公司都有自己的持续集成方案,例如腾讯的RDM、阿里的摩天轮、大众点评的MCI等。
|
||||
|
||||
下面我来简单讲一下我对持续集成的一些经验和看法:
|
||||
|
||||
|
||||
自定义代码检查。每个公司都会有自己的编码规范,代码检查的目的在于防止不符合规范的代码提交到远程仓库中。比如微信就定义了一套代码规范,并且写了专门的插件来检测。例如日志规范、不能直接使用new Thread、new Handler等,而且违反者将会得到一定的惩罚。自定义代码检测可以通过完全自己实现或者扩展Findbugs插件,例如美团它们就利用Findbugs实现了Android漏洞扫描工具Code Arbiter。
|
||||
|
||||
第三方代码检查。业界比较常用的代码扫描工具有收费的Coverity,以及Facebook开源的Infer,例如空指针、多线程问题、资源泄漏等很多问题都可以扫描出来。除了增加检测流程,我最大的体会是需要同时增加人员的培训。我遇到很多开发者为了解决扫描出来的问题,空指针就直接判空、多线程就直接加锁,最后可能会造成更加严重的问题。
|
||||
|
||||
Code Review。关于Code Review,集成GitLab、Phabricator或者Gerrit都是不错的选择。我们一定要重视Code Review,这也是给其他人展示我们“伟大”代码的机会。而且我们自己应该是第一个Code Reviewer,在给别人Review之前,自己先以第三者的角度审视一次代码。这样先通过自己这一关的考验,既尊重了别人的时间,也可以为自己树立良好的技术品牌。
|
||||
|
||||
|
||||
持续集成涉及的流程有很多,你需要结合自己团队的现状。如果只是一味地去增加流程,有时候可能适得其反。
|
||||
|
||||
总结
|
||||
|
||||
在Android 8.0,Google引入了Dexlayout库实现类和方法的重排,Facebook的Buck也第一时间引入了AAPT2。ReDex、d8、R8其实都是相辅相成,可以看到Google也在摄取社区的知识,但同时我们也会从Google的新技术发展里寻求思路。
|
||||
|
||||
我在写今天的内容时还有另外一个体会,Google为了解决Android编译速度的问题,花了大量的力气结果却不尽如人意。我想说如果我们敢于跳出系统的制约,可能才会彻底解决这个问题,正如在Flutter上面就可以完美实现秒级编译。其实做人、做事也是如此,我们经常会陷入局部最优解的困局,或者走进“思维怪圈”,这时如果能跳出路径依赖,从更高的维度重新思考、审视全局,得到的体会可能会完全不一样。
|
||||
|
||||
课后作业
|
||||
|
||||
在你的工作中,遇到过哪些编译问题?有没有做过具体优化编译速度的工作?对于编译,你还有哪些疑问?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
对于Android Build System,可以说每年都会有不少的变化,也有很多新的东西出来。所以我们应该保持敏感度,你会发现很多工具都非常有用,例如Desugar、Dexlayout、JVM TI、App Bundle等。
|
||||
|
||||
今天的课后作业是,请你观看2018年Google I/O编译工具相关的视频,在留言中写下自己的心得体会。
|
||||
|
||||
|
||||
What’s new with the Android build system (Google I/O ‘18)
|
||||
|
||||
What’s new in Android development tools
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
305
专栏/Android开发高手课/27编译插桩的三种方法:AspectJ、ASM、ReDex.md
Normal file
305
专栏/Android开发高手课/27编译插桩的三种方法:AspectJ、ASM、ReDex.md
Normal file
@ -0,0 +1,305 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 编译插桩的三种方法:AspectJ、ASM、ReDex
|
||||
只要简单回顾一下前面课程的内容你就会发现,在启动耗时分析、网络监控、耗电监控中已经不止一次用到编译插桩的技术了。那什么是编译插桩呢?顾名思义,所谓的编译插桩就是在代码编译期间修改已有的代码或者生成新代码。
|
||||
|
||||
|
||||
|
||||
如上图所示,请你回忆一下Java代码的编译流程,思考一下插桩究竟是在编译流程中的哪一步工作?除了我们之前使用的一些场景,它还有哪些常见的应用场景?在实际工作中,我们应该怎样更好地使用它?现在都有哪些常用的编译插桩方法?今天我们一起来解决这些问题。
|
||||
|
||||
编译插桩的基础知识
|
||||
|
||||
不知道你有没有注意到,在编译期间修改和生成代码其实是很常见的行为,无论是Dagger、ButterKnife这些APT(Annotation Processing Tool)注解生成框架,还是新兴的Kotlin语言编译器,它们都用到了编译插桩的技术。
|
||||
|
||||
下面我们一起来看看还有哪些场景会用到编译插桩技术。
|
||||
|
||||
1. 编译插桩的应用场景
|
||||
|
||||
编译插桩技术非常有趣,同样也很有价值,掌握它之后,可以完成一些其他技术很难实现或无法完成的任务。学会这项技术以后,我们就可以随心所欲地操控代码,满足不同场景的需求。
|
||||
|
||||
|
||||
代码生成。除了Dagger、ButterKnife这些常用的注解生成框架,Protocol Buffers、数据库ORM框架也都会在编译过程生成代码。代码生成隔离了复杂的内部实现,让开发更加简单高效,而且也减少了手工重复的劳动量,降低了出错的可能性。
|
||||
|
||||
代码监控。除了网络监控和耗电监控,我们可以利用编译插桩技术实现各种各样的性能监控。为什么不直接在源码中实现监控功能呢?首先我们不一定有第三方SDK的源码,其次某些调用点可能会非常分散,例如想监控代码中所有new Thread()调用,通过源码的方式并不那么容易实现。
|
||||
|
||||
代码修改。我们在这个场景拥有无限的发挥空间,例如某些第三方SDK库没有源码,我们可以给它内部的一个崩溃函数增加try catch,或者说替换它的图片库等。我们也可以通过代码修改实现无痕埋点,就像网易的HubbleData、51信用卡的埋点实践。
|
||||
|
||||
代码分析。上一期我讲到持续集成,里面的自定义代码检查就可以使用编译插桩技术实现。例如检查代码中的new Thread()调用、检查代码中的一些敏感权限使用等。事实上,Findbugs这些第三方的代码检查工具也同样使用的是编译插桩技术实现。
|
||||
|
||||
|
||||
“一千个人眼中有一千个哈姆雷特”,通过编译插桩技术,你可以大胆发挥自己的想象力,做一些对提升团队质量和效能有帮助的事情。
|
||||
|
||||
那从技术实现上看,编译插桩是从代码编译的哪个流程介入的呢?我们可以把它分为两类:
|
||||
|
||||
|
||||
Java文件。类似APT、AndroidAnnotation这些代码生成的场景,它们生成的都是Java文件,是在编译的最开始介入。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
字节码(Bytecode)。对于代码监控、代码修改以及代码分析这三个场景,一般采用操作字节码的方式。可以操作“.class”的Java字节码,也可以操作“.dex”的Dalvik字节码,这取决于我们使用的插桩方法。
|
||||
|
||||
|
||||
|
||||
|
||||
相对于Java文件方式,字节码操作方式功能更加强大,应用场景也更广,但是它的使用复杂度更高,所以今天我主要来讲如何通过操作字节码实现编译插桩的功能。
|
||||
|
||||
2. 字节码
|
||||
|
||||
对于Java平台,Java虚拟机运行的是Class文件,内部对应的是Java字节码。而针对Android这种嵌入式平台,为了优化性能,Android虚拟机运行的是Dex文件,Google专门为其设计了一种Dalvik字节码,虽然增加了指令长度但却缩减了指令的数量,执行也更为快速。
|
||||
|
||||
那这两种字节码格式有什么不同呢?下面我们先来看一个非常简单的Java类。
|
||||
|
||||
public class Sample {
|
||||
public void test() {
|
||||
System.out.print("I am a test sample!");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
通过下面几个命令,我们可以生成和查看这个Sample.java类的Java字节码和Dalvik字节码。
|
||||
|
||||
javac Sample.java // 生成Sample.class,也就是Java字节码
|
||||
javap -v Sample // 查看Sample类的Java字节码
|
||||
|
||||
//通过Java字节码,生成Dalvik字节码
|
||||
dx --dex --output=Sample.dex Sample.class
|
||||
|
||||
dexdump -d Sample.dex // 查看Sample.dex的Dalvik的字节码
|
||||
|
||||
|
||||
你可以直观地看到Java字节码和Dalvik字节码的差别。
|
||||
|
||||
|
||||
|
||||
它们的格式和指令都有很明显的差异。关于Java字节码的介绍,你可以参考JVM文档。对于Dalvik字节码来说,你可以参考Android的官方文档。它们的主要区别有:
|
||||
|
||||
|
||||
体系结构。Java虚拟机是基于栈实现,而Android虚拟机是基于寄存器实现。在ARM平台,寄存器实现性能会高于栈实现。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
格式结构。对于Class文件,每个文件都会有自己单独的常量池以及其他一些公共字段。对于Dex文件,整个Dex中的所有Class共用同一个常量池和公共字段,所以整体结构更加紧凑,因此也大大减少了体积。
|
||||
|
||||
指令优化。Dalvik字节码对大量的指令专门做了精简和优化,如下图所示,相同的代码Java字节码需要100多条,而Dalvik字节码只需要几条。
|
||||
|
||||
|
||||
|
||||
|
||||
关于Java字节码和Dalvik字节码的更多介绍,你可以参考下面的资料:
|
||||
|
||||
|
||||
Dalvik and ART
|
||||
|
||||
Understanding the Davlik Virtual Machine
|
||||
|
||||
|
||||
编译插桩的三种方法
|
||||
|
||||
AspectJ和ASM框架的输入和输出都是Class文件,它们是我们最常用的Java字节码处理框架。
|
||||
|
||||
|
||||
|
||||
1. AspectJ
|
||||
|
||||
AspectJ是Java中流行的AOP(aspect-oriented programming)编程扩展框架,网上很多文章说它处理的是Java文件,其实并不正确,它内部也是通过字节码处理技术实现的代码注入。
|
||||
|
||||
从底层实现上来看,AspectJ内部使用的是BCEL框架来完成的,不过这个库在最近几年没有更多的开发进展,官方也建议切换到ObjectWeb的ASM框架。关于BCEL的使用,你可以参考《用BCEL设计字节码》这篇文章。
|
||||
|
||||
从使用上来看,作为字节码处理元老,AspectJ的框架的确有自己的一些优势。
|
||||
|
||||
|
||||
成熟稳定。从字节码的格式和各种指令规则来看,字节码处理不是那么简单,如果处理出错,就会导致程序编译或者运行过程出问题。而AspectJ作为从2001年发展至今的框架,它已经很成熟,一般不用考虑插入的字节码正确性的问题。
|
||||
|
||||
使用简单。AspectJ功能强大而且使用非常简单,使用者完全不需要理解任何Java字节码相关的知识,就可以使用自如。它可以在方法(包括构造方法)被调用的位置、在方法体(包括构造方法)的内部、在读写变量的位置、在静态代码块内部、在异常处理的位置等前后,插入自定义的代码,或者直接将原位置的代码替换为自定义的代码。
|
||||
|
||||
|
||||
在专栏前面文章里我提过360的性能监控框架ArgusAPM,它就是使用AspectJ实现性能的监控,其中TraceActivity是为了监控Application和Activity的生命周期。
|
||||
|
||||
// 在Application onCreate执行的时候调用applicationOnCreate方法
|
||||
@Pointcut("execution(* android.app.Application.onCreate(android.content.Context)) && args(context)")
|
||||
public void applicationOnCreate(Context context) {
|
||||
|
||||
}
|
||||
// 在调用applicationOnCreate方法之后调用applicationOnCreateAdvice方法
|
||||
@After("applicationOnCreate(context)")
|
||||
public void applicationOnCreateAdvice(Context context) {
|
||||
AH.applicationOnCreate(context);
|
||||
}
|
||||
|
||||
|
||||
你可以看到,我们完全不需要关心底层Java字节码的处理流程,就可以轻松实现编译插桩功能。关于AspectJ的文章网上有很多,不过最全面的还是官方文档,你可以参考《AspectJ程序设计指南》和The AspectJ 5 Development Kit Developer’s Notebook,这里我就不详细描述了。
|
||||
|
||||
但是从AspectJ的使用说明里也可以看出它的一些劣势,它的功能无法满足我们某些场景的需要。
|
||||
|
||||
|
||||
切入点固定。AspectJ只能在一些固定的切入点来进行操作,如果想要进行更细致的操作则无法完成,它不能针对一些特定规则的字节码序列做操作。
|
||||
|
||||
正则表达式。AspectJ的匹配规则是类似正则表达式的规则,比如匹配Activity生命周期的onXXX方法,如果有自定义的其他以on开头的方法也会匹配到。
|
||||
|
||||
性能较低。AspectJ在实现时会包装自己的一些类,逻辑比较复杂,不仅生成的字节码比较大,而且对原函数的性能也会有所影响。
|
||||
|
||||
|
||||
我举专栏第7期启动耗时Sample的例子,我们希望在所有的方法调用前后都增加Trace的函数。如果选择使用AspectJ,那么实现真的非常简单。
|
||||
|
||||
@Before("execution(* **(..))")
|
||||
public void before(JoinPoint joinPoint) {
|
||||
Trace.beginSection(joinPoint.getSignature().toString());
|
||||
}
|
||||
|
||||
@After("execution(* **(..))")
|
||||
public void after() {
|
||||
Trace.endSection();
|
||||
}
|
||||
|
||||
|
||||
但你可以看到经过AspectJ的字节码处理,它并不会直接把Trace函数直接插入到代码中,而是经过一系列自己的封装。如果想针对所有的函数都做插桩,AspectJ会带来不少的性能影响。
|
||||
|
||||
|
||||
|
||||
不过大部分情况,我们可能只会插桩某一小部分函数,这样AspectJ带来的性能影响就可以忽略不计了。如果想在Android中直接使用AspectJ,还是比较麻烦的。这里我推荐你直接使用沪江的AspectJX框架,它不仅使用更加简便一些,而且还扩展了排除某些类和JAR包的能力。如果你想通过Annotation注解方式接入,我推荐使用Jake Wharton大神写的Hugo项目。
|
||||
|
||||
虽然AspectJ使用方便,但是在使用的时候不注意的话还是会产生一些意想不到的异常。比如使用Around Advice需要注意方法返回值的问题,在Hugo里的处理方法是将joinPoint.proceed()的返回值直接返回,同时也需要注意Advice Precedence的情况。
|
||||
|
||||
2. ASM
|
||||
|
||||
如果说AspectJ只能满足50%的字节码处理场景,那ASM就是一个可以实现100%场景的Java字节码操作框架,它的功能也非常强大。使用ASM操作字节码主要的特点有:
|
||||
|
||||
|
||||
操作灵活。操作起来很灵活,可以根据需求自定义修改、插入、删除。
|
||||
|
||||
上手难。上手比较难,需要对Java字节码有比较深入的了解。
|
||||
|
||||
|
||||
为了使用简单,相比于BCEL框架,ASM的优势是提供了一个Visitor模式的访问接口(Core API),使用者可以不用关心字节码的格式,只需要在每个Visitor的位置关心自己所修改的结构即可。但是这种模式的缺点是,一般只能在一些简单场景里实现字节码的处理。
|
||||
|
||||
事实上,专栏第7期启动耗时的Sample内部就是使用ASM的Core API,具体你可以参考MethodTracer类的实现。从最终效果上来看,ASM字节码处理后的效果如下。
|
||||
|
||||
|
||||
|
||||
相比AspectJ,ASM更加直接高效。但是对于一些复杂情况,我们可能需要使用另外一种Tree API来完成对Class文件更直接的修改,因此这时候你要掌握一些必不可少的Java字节码知识。
|
||||
|
||||
此外,我们还需要对Java虚拟机的运行机制有所了解,前面我就讲到Java虚拟机是基于栈实现。那什么是Java虚拟机的栈呢?,引用《Java虚拟机规范》里对Java虚拟机栈的描述:
|
||||
|
||||
|
||||
每一条Java虚拟机线程都有自己私有的Java虚拟机栈,这个栈与线程同时创建,用于存储栈帧(Stack Frame)。
|
||||
|
||||
|
||||
正如这句话所描述的,每个线程都有自己的栈,所以在多线程应用程序中多个线程就会有多个栈,每个栈都有自己的栈帧。
|
||||
|
||||
|
||||
|
||||
如下图所示,我们可以简单认为栈帧包含3个重要的内容:本地变量表(Local Variable Array)、操作数栈(Operand Stack)和常量池引用(Constant Pool Reference)。
|
||||
|
||||
|
||||
|
||||
|
||||
本地变量表。在使用过程中,可以认为本地变量表是存放临时数据的,并且本地变量表有个很重要的功能就是用来传递方法调用时的参数,当调用方法的时候,参数会依次传递到本地变量表中从0开始的位置上,并且如果调用的方法是实例方法,那么我们可以通过第0个本地变量中获取当前实例的引用,也就是this所指向的对象。
|
||||
|
||||
操作数栈。可以认为操作数栈是一个用于存放指令执行所需要的数据的位置,指令从操作数栈中取走数据并将操作结果重新入栈。
|
||||
|
||||
|
||||
由于本地变量表的最大数和操作数栈的最大深度是在编译时就确定的,所以在使用ASM进行字节码操作后需要调用ASM提供的visitMaxs方法来设置maxLocal和maxStack数。不过,ASM为了方便用户使用,已经提供了自动计算的方法,在实例化ClassWriter操作类的时候传入COMPUTE_MAXS后,ASM就会自动计算本地变量表和操作数栈。
|
||||
|
||||
ClassWriter(ClassWriter.COMPUTE_MAXS)
|
||||
|
||||
|
||||
下面以一个简单的“1+2“为例,它的操作数以LIFO(后进先出)的方式进行操作。
|
||||
|
||||
|
||||
|
||||
ICONST_1将int类型1推送栈顶,ICONST_2将int类型2推送栈顶,IADD指令将栈顶两个int类型的值相加后将结果推送至栈顶。操作数栈的最大深度也是由编译期决定的,很多时候ASM修改后的代码会增加操作数栈最大深度。不过ASM已经提供了动态计算的方法,但同时也会带来一些性能上的损耗。
|
||||
|
||||
在具体的字节码处理过程中,特别需要注意的是本地变量表和操作数栈的数据交换和try catch blcok的处理。
|
||||
|
||||
|
||||
数据交换。如下图所示,在经过IADD指令操作后,又通过ISTORE 0指令将栈顶int值存入第1个本地变量中,用于临时保存,在最后的加法过程中,将0和1位置的本地变量取出压入操作数栈中供IADD指令使用。关于本地变量和操作数栈数据交互的指令,你可以参考虚拟机规范,里面提供了一系列根据不同数据类型的指令。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
异常处理。在字节码操作过程中需要特别注意异常处理对操作数栈的影响,如果在try和catch之间抛出了一个可捕获的异常,那么当前的操作数栈会被清空,并将异常对象压入这个空栈中,执行过程在catch处继续。幸运的是,如果生成了错误的字节码,编译器可以辨别出该情况并导致编译异常,ASM中也提供了字节码Verify的接口,可以在修改完成后验证一下字节码是否正常。
|
||||
|
||||
|
||||
|
||||
|
||||
如果想在一个方法执行完成后增加代码,ASM相对也要简单很多,可以在字节码中出现的每一条RETURN系或者ATHROW的指令前,增加处理的逻辑即可。
|
||||
|
||||
3. ReDex
|
||||
|
||||
ReDex不仅只是作为一款Dex优化工具,它也提供了很多的小工具和文档里没有提到的一些新奇功能。比如在ReDex里提供了一个简单的Method Tracing和Block Tracing工具,这个工具可以在所有方法或者指定方法前面插入一段跟踪代码。
|
||||
|
||||
官方提供了一个例子,用来展示这个工具的使用,具体请查看InstrumentTest。这个例子会将InstrumentAnalysis的onMethodBegin方法插入到除黑名单以外的所有方法的开头位置。具体配置如下:
|
||||
|
||||
"InstrumentPass" : {
|
||||
"analysis_class_name": "Lcom/facebook/redextest/InstrumentAnalysis;", //存在桩代码的类
|
||||
"analysis_method_name": "onMethodBegin", //存在桩代码的方法
|
||||
"instrumentation_strategy": "simple_method_tracing"
|
||||
, //插入策略,有两种方案,一种是在方法前面插入simple_method_tracing,一种是在CFG 的Block前后插入basic_block_tracing
|
||||
}
|
||||
|
||||
|
||||
ReDex的这个功能并不是完整的AOP工具,但它提供了一系列指令生成API和Opcode插入API,我们可以参照这个功能实现自己的字节码注入工具,这个功能的代码在Instrument.cpp中。
|
||||
|
||||
这个类已经将各种字节码特殊情况处理得相对比较完善,我们可以直接构造一段Opcode调用其提供的Insert接口即可完成代码的插入,而不用过多考虑可能会出现的异常情况。不过这个类提供的功能依然耦合了ReDex的业务,所以我们需要提取有用的代码加以使用。
|
||||
|
||||
由于Dalvik字节码发展时间尚短,而且因为Dex格式更加紧凑,修改起来往往牵一发而动全身。并且Dalvik字节码的处理相比Java字节码会更加复杂一些,所以直接操作Dalvik字节码的工具并不是很多。
|
||||
|
||||
市面上大部分需要直接修改Dex的情况是做逆向,很多同学都采用手动书写Smali代码然后编译回去。这里我总结了一些修改Dalvik字节码的库。
|
||||
|
||||
|
||||
ASMDEX,开发者是ASM库的开发者,但很久未更新了。
|
||||
|
||||
Dexter,Google官方开发的Dex操作库,更新很频繁,但使用起来很复杂。
|
||||
|
||||
Dexmaker,用来生成Dalvik字节码的代码。
|
||||
|
||||
Soot,修改Dex的方法很另类,是先将Dalvik字节码转成一种Jimple three-address code,然后插入Jimple Opcode后再转回Dalvik字节码,具体可以参考例子。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天我介绍了几种比较有代表性的框架来讲解编译插桩相关的内容。代码生成、代码监控、代码魔改以及代码分析,编译插桩技术无所不能,因此需要我们充分发挥想象力。
|
||||
|
||||
对于一些常见的应用场景,前辈们付出了大量的努力将它们工具化、API化,让我们不需要懂得底层字节码原理就可以轻松使用。但是如果真要想达到随心所欲的境界,即使有类似ASM工具的帮助,也还是需要我们对底层字节码有比较深的理解和认识。
|
||||
|
||||
当然你也可以成为“前辈”,将这些场景沉淀下来,提供给后人使用。但有的时候“能力限制想象力”,如果能力不够,即使想象力到位也无可奈何。
|
||||
|
||||
课后作业
|
||||
|
||||
你使用过哪些编译插桩相关的工具?使用编译插桩实现过什么功能?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的课后作业是重温专栏第7期练习Sample的实现原理,看看它内部是如何使用ASM完成TAG的插桩。在今天的Sample里,我也提供了一个使用AspectJ实现的版本。想要彻底学会编译插桩的确不容易,单单写一个高效的Gradle Plugin就不那么简单。
|
||||
|
||||
除了上面的两个Sample,我也推荐你认真看看下面的一些参考资料和项目。
|
||||
|
||||
|
||||
一起玩转Android项目中的字节码
|
||||
|
||||
字节码操纵技术探秘
|
||||
|
||||
ASM 6 Developer Guide
|
||||
|
||||
Java字节码(Bytecode)与ASM简单说明
|
||||
|
||||
Dalvik and ART
|
||||
|
||||
Understanding the Davlik Virtual Machine
|
||||
|
||||
基于ASM的字节码处理工具:Hunter和Hibeaver
|
||||
|
||||
基于Javassist的字节码处理工具:DroidAssist
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
183
专栏/Android开发高手课/28大数据与AI,如何高效地测试?.md
Normal file
183
专栏/Android开发高手课/28大数据与AI,如何高效地测试?.md
Normal file
@ -0,0 +1,183 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 大数据与AI,如何高效地测试?
|
||||
测试作为持续交付中重要的一个环节,它的使命是发现交付过程的质量问题。随着互联网迭代速度的加快,很多产品都是两周甚至每周一个版本,留给测试的时间越来越少。
|
||||
|
||||
那在这么短的时间,如何保障产品的质量,怎样高效地测试呢?我们研发模式在不断地变化,测试的定位又有哪些改变,而未来的测试又会发展成什么样的形态呢?
|
||||
|
||||
测试的演进历程
|
||||
|
||||
“专业的事情留给专业的人员”,社会各领域的分工越来越细,设计、研发、产品、测试大家各司其职,共同完成一个产品。但随着技术的发展,这样的分工并不是一成不变,从近两年大公司技术部门的组织调整来看,测试和开发的角色已经在不断融合。
|
||||
|
||||
互联网发展到今天,测试的职责发生了哪些改变?移动端测试又经历了怎么样的演进历程呢?
|
||||
|
||||
1. 测试的田园时代
|
||||
|
||||
在移动互联网起步之初,基本上还处在传统的软件研发阶段。产品将需求交给研发,研发实现后交给测试,测试把最终产品交付给用户。我们可以把这个阶段叫作测试的“田园时代“。
|
||||
|
||||
|
||||
|
||||
测试作为研发流程的一个环节,只是作为产品交付给用户前的一道屏障,承担着质量保证工作。在这个阶段测试有两个最重要的考核指标:每个版本发现的Bug数量和遗漏到线上的Bug数量。
|
||||
|
||||
在测试的“田园时代”,很多Bug可能会出现多次返工,产品的交付流程也很难快起来。这个阶段的主要问题有:
|
||||
|
||||
|
||||
人员对立。测试的KPI是尽可能地发现开发人员的Bug,他们之间非常容易发生对立。特别是出现线上事故的时候,开发埋怨测试没用,测试埋怨开发无能。
|
||||
|
||||
效率低下。虽然引入了Monkey这些基本的自动化工具,但是大多数的测试方法还是依赖人工执行。尽管我们不停地加大测试与研发人员的比例,但整个过程还是问题多多,很难缩短整个产品的交付周期。
|
||||
|
||||
|
||||
2. 测试的效能时代
|
||||
|
||||
随着互联网竞争的加剧,交付速度成为了产品的核心能力。国内外的大公司开始适应趋势的变化,把测试团队的职责转变成为团队的效能服务。
|
||||
|
||||
测试不只是负责交付质量,还需要同时思考产品的质量和效率。正如我前面说到的“211”效能目标,也就是2周交付周期、1周开发周期以及1小时发布时长,测试人员需要直接为这个目标负责,思考如何快速并且高质量完成产品的交付。
|
||||
|
||||
|
||||
长兄于病视神,未有形而除之,故名不出于家。中兄治病,其在毫毛,故名不出于闾。若扁鹊者,镵血脉,投毒药,副肌肤,闲而名出闻于诸侯。
|
||||
|
||||
|
||||
这是扁鹊讲三兄弟治病的故事,说的是长兄治病,是治于病情未发作之前,由于一般人不知道他事先能铲除病因,所以他的名气无法传出去。中兄治病,是治于病情初起之时,一般人以为他只能治轻微的小病,所以他的名气只在乡里。而扁鹊是治于病情严重之时,在经脉上穿针管来放血,在皮肤上敷药,所以都以为我的医术最高明,名气因此响遍天下。
|
||||
|
||||
从这个故事来看,扁鹊认为长兄的医术最高,可以做到“治病于未发”。回到我们今天所谈的测试,很多公司已经开始提出“测试左移”,也是希望测试在更早期的阶段介入到交付过程中,不仅是发现问题,要考虑更多的是如何避免问题的出现。
|
||||
|
||||
|
||||
|
||||
测试需要深入到产品从设计到发布的各个流程,要做到“比产品更懂技术,比研发更懂业务”。为了顺应这个变化,各大公司在组织架构上开始把大型的测试团队打散,揉碎进各个业务开发团队中。“产研测一体化”目的在于统一团队的目标和方向,让所有人为了提升产品效能这个共同目标而努力。这样团队中所有成员成为相亲相爱的一家人,也消除了“产研测”之间的对立现象。
|
||||
|
||||
但是在《如何衡量研发效能》一文中所提到的:“在产品迭代前期,团队集中设计、编码,引入缺陷,但并未即时地集成和验证。缺陷一直掩藏在系统中,直到项目后期,团队才开始集成和测试,缺陷集中爆发”。
|
||||
|
||||
|
||||
|
||||
虽然通过持续交付模式,可以一定程度上削减提交波峰,但是依然无法避免经常出现的“踩点”提交。在测试的效能时代,如何提升测试的效率依然是急需解决的问题。
|
||||
|
||||
在这个阶段,我给出的答案是持续集成的工具化和平台化。从需求发起到分支管理、Code Review、代码检查以及测试发布等,测试团队负责把控各式各样的工具或平台。
|
||||
|
||||
由于整个持续交付过程涉及各个阶段的平台工具,这里我只挑跟测试相关的两个平台重点来讲。
|
||||
|
||||
|
||||
测试平台。竞品测试、弱网络测试、启动测试、UI测试等,整个测试流程引入了大量的自动化工具。各大公司也改良或创造了不少好用的工具,例如腾讯的New Monkey工具,可以大大提升Monkey的智能度和覆盖率。除了我们比较熟悉的Espresso、UIAutomator、Appium以及Robotium自动化框架,像阿里的Macaca、Facebook的Screenshot Tests for Android都是值得学习的开源测试框架。当然,也有一些公司把测试平台打包成服务对外公开,例如腾讯的WeTest、华为的开发者服务等。
|
||||
|
||||
|
||||
下面是这些常用工具的简单对比,供你参考。
|
||||
|
||||
|
||||
|
||||
|
||||
体验平台。在测试的效能时代,自动化并不能完全取代人工测试。在产品交付的不同阶段,我们需要提高团队内外人员的参与度。无论每日的Daily Build、封版时的集中体验,还是测试期间的员工内部体验、灰度期间的外部众测平台,的目的是让尽量多的成员都参与到产品的体验中,提升团队成员对产品的认同感。当然各大公司也都有自己的体验平台,例如腾讯的RDM、蚂蚁的伙伴APP等。
|
||||
|
||||
|
||||
|
||||
|
||||
测试的效能时代也是目前大多数公司所处的阶段,据我了解很多公司的工程效能团队,也是从测试团队演进而来的。随着测试团队职能的转变以及技术深度的提升,会涌现出一大批资深的测试开发人员,也会有更多优秀的测试人员走向开发或者产品的岗位。
|
||||
|
||||
不过我发现关于测试国内外也有一些差异,例如国外十分推崇开发编写的test case,但在国内却非常不容易推行。这主要是因为国内业务的迭代更加快速,开发需求都做不完,根本没有时间去写test case,更不用说有的test case写起来可能比开发需求更费时间。
|
||||
|
||||
测试的智能时代
|
||||
|
||||
“人人都可以是测试”,虽然在稳定性、兼容性又或者是性能测试的一些场景上,我们做得非常不错,但是对于某些自动化测试场景,特别是UI测试,目前还达不到人工测试的水平。
|
||||
|
||||
|
||||
|
||||
就拿UI测试为例,由于版本迭代周期越来越短,而且UI变动又非常频繁,无论是开发还是业务测试人员,对写测试脚本和用例的积极性都不是很高。由于测试脚本的编写成本和维护成本比较高,可复用程度又比较低,所以UI测试往往费时费力,很多时候效率还不如人工测试。
|
||||
|
||||
那测试从效能时代走向下一阶段,在智能时代我们应该怎样去解决这些问题呢?
|
||||
|
||||
1. AI在测试的应用
|
||||
|
||||
AI技术在我们熟悉的围棋和星际争霸的人机大战中已经大放异彩了,那它在测试领域可以擦出哪些不一样的火花呢?
|
||||
|
||||
先看看我们在UI自动化测试中遇到的几个困境:
|
||||
|
||||
|
||||
覆盖率。自动化测试可以覆盖多少场景,如何解决登录、网络异常等各种各样情况的干扰。
|
||||
|
||||
效率。我们是否可以提高写测试用例的效率,或者是降低它的编写成本,做到人人都可以写用例。
|
||||
|
||||
准确性。如何提高整个自动化测试过程的稳定性,对于测试流程和最终结果,是否还需要大量的人工干预分析。
|
||||
|
||||
|
||||
网易开源的Airtest、爱奇艺的AIon,都尝试利用AI技术解决测试用例的编写效率和门槛问题。
|
||||
|
||||
|
||||
|
||||
这里主要用到图像识别以及OCR技术,以爱奇艺的Alon为例,它的整个处理流程是:
|
||||
|
||||
|
||||
图片处理。首先是场景的识别,例如当前界面是否有弹出对话,是否是登陆页面等,然后通过对截屏进行图像分割。这里的难点在于文字的OCR识别以及布局的分类,例如怎么样把不同的切割图像进行分类,并且能够知道这块图像对应的内容。
|
||||
|
||||
结果判定。如何判定本次UI测试的结果是否是符合预期的,相似度达到多高的程度等。因为UI测试可能遇到的情况有很多,我们需要尽可能提升测试的准确性,减少人工干预的情况。
|
||||
|
||||
|
||||
|
||||
|
||||
在Alon的参考文章中,还提到UI2Code这样一个应用场景,也就是把一个应用截图,或者把一个UI设计图,通过图像识别生成对应的代码。其实就是Pixel to App希望实现的效果,相信也是很多开发人员的梦想吧,让我们可以彻底从UI开发中解放出来,通过设计稿就可以直接生成最终的UI代码。
|
||||
|
||||
|
||||
|
||||
Airtest和Alon解决的核心问题是UI自动化测试的效率,但是它们依然需要人工去编写测试用例。对于稳定性测试,虽然我上面提到过腾讯的New Monkey,不过它依然存在很多缺陷:
|
||||
|
||||
|
||||
覆盖场景。虽然是改进版本的Monkey,但是它依然覆盖不了所有的用户场景,而且很多场景它的执行流程不同于真实的用户。
|
||||
|
||||
非智能。这里你可以理解为非人类,Monkey的操作和人类的操作方式完全不同。
|
||||
|
||||
|
||||
那有没有更智能的解决方案呢?Facebook的Sapienz尝试希望像真实用户一样去使用我们开发的应用,它通过收集真实用户的操作路径来训练测试行为。而且在测试出崩溃后,Sapienz会自动关联和定位代码,提升解决问题的效率。
|
||||
|
||||
|
||||
|
||||
虽然AI测试目前还无法完全取代人工测试,但随着技术的进一步成熟,相信它对测试领域的变革将会是革命性的。当然想要实现这个目标我们还有很多工作要做,这也意味着这其中还有很多技术创新的机会。
|
||||
|
||||
2. 大数据在测试的应用
|
||||
|
||||
现在越来越多的业务正在使用数据驱动的方式运作,测试的对象要从简单的代码转变为数据和算法。现在的业务越来越复杂,数据量越来越大,我们应该怎样及时发现产品的质量和业务问题呢?
|
||||
|
||||
国内的一些公司提出了基于大数据的“实时质量”体系,希望通过实时获取线上海量数据,完成业务数据校验和质量风险的感知。
|
||||
|
||||
这里的数据主要包括质量和业务两个方面。
|
||||
|
||||
|
||||
质量。崩溃、启动速度、卡顿以及联网错误等质量问题,我们希望可以做到分钟级别的实时性,同时支持更加细粒度的维度分析。例如可以通过国家、城市、版本等维度来查看网络问题。
|
||||
|
||||
业务。对于产品的核心业务我们需要实现数据收集、分析与校验,打造数据监控和跟踪的能力。
|
||||
|
||||
|
||||
对于基于大数据的“实时质量”测试体系,关键在于如何保证数据的实时性与准确性,这两点我会在“数据评估”的内容中与你详细讨论。
|
||||
|
||||
除了质量和业务数据,大公司做得比较多的还有用户反馈和舆情的跟踪和分析。各大公司基本都有自己的一套系统,通过爬取用户反馈、应用市场、微博、新闻资讯等各方面的消息来源,监控产品的舆情情况,你可以参考支付宝如何为移动端产品构建舆情分析体系。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
在APM系统搭建和持续交付优化过程,我接触了很多测试工程师,也关注了各大公司测试的现状。对于测试的职业道路发展,我个人的建议是:不管测试如何发展,测试效率如何提升,测试人员都要学会不断地变革,变革自己、变革整个研发流程。我们不能只守着自己的一亩三分田,需要尝试去做很多以前开发需要做的事情,比如性能、稳定性、安全等方面的工作。说得严重一点,如果你不能及时地更新自己的技术栈,不去往更深入的底层走,在测试的智能时代,首先淘汰的就是传统的功能测试人员。
|
||||
|
||||
在这个变革的时代,我身边也有很多独当一面的测试工程师通过平台化的机遇晋级为专家,也有一些优秀的测试转向了产品或研发,所以说还是要提高自身的能力,把机会握在自己手上。
|
||||
|
||||
“学如逆水行舟,不进则退”,对于开发人员同样如此。现在平台工具和框架越来越成熟,很多初学者拿着一大堆开源工具,也能写出炫酷的界面。如果我们不去进步,特别是在大环境不好的时候,也会很容易被淘汰。
|
||||
|
||||
今天我分享了一些我对高效测试的心得和体会,如果同学们里有测试领域的专家,非常欢迎你来谈谈对这个行业和发展的看法,分享一下你对高效测试的看法。
|
||||
|
||||
课后作业
|
||||
|
||||
你所在的公司,目前测试正在处于哪个阶段?对于测试,你还有哪些疑问?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的课后作业是,请你观看网易、爱奇艺以及Facebook关于AI在测试领域应用的分享,在留言中写下自己的心得体会。
|
||||
|
||||
|
||||
网易:基于AI的网易UI自动化测试方案与实践
|
||||
|
||||
爱奇艺:基于AI的移动端自动化测试框架的设计与实践
|
||||
|
||||
Facebook:Automated fault-finding with Sapienz
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
210
专栏/Android开发高手课/29从每月到每天,如何给版本发布提速?.md
Normal file
210
专栏/Android开发高手课/29从每月到每天,如何给版本发布提速?.md
Normal file
@ -0,0 +1,210 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 从每月到每天,如何给版本发布提速?
|
||||
还记得我们在持续交付设定的目标吗?我前面提到过,天猫的效能目标是“211”,也就是2周交付周期、1周开发周期以及1小时发布时长。对于一些更加敏捷的产品,我们可能还会加快到每周一个版本。在如此快的节奏下,我们该如何保证产品的质量?还有哪些手段可以进一步为发布“提速保质”?
|
||||
|
||||
更宽泛地说,广义的发布并不仅限于把应用提交到市场。灰度、A/B测试 、运营活动、资源配置…我们的发布类型越来越多,也越来越复杂。该如何建立稳健的发布质量保障体系,防止出现线上事故呢?
|
||||
|
||||
APK的灰度发布
|
||||
|
||||
我们在讨论版本发布速度,是需要兼顾效率和质量。如果不考虑交付质量,每天一个版本也很轻松。在严格保证交付质量的前提下,两周发布一个版本其实并不容易。特别是出现紧急安全或者稳定性问题的时候,我们还需要有1小时的发布能力。
|
||||
|
||||
正如我在专栏“如何高效地测试”中说的,实现“高质高效”的发布需要强大的测试平台和数据验证平台的支撑。
|
||||
|
||||
|
||||
|
||||
下面我们一起来看看影响版本发布效率的那些重要因素,以及我对于提升版本发布速度的实践经验。
|
||||
|
||||
1. APK灰度
|
||||
|
||||
测试平台负责对发布包做各种维度的诊断测试,通常会包括Monkey测试、性能测试(启动、内存、CPU、卡顿等)、竞品测试、UI测试、弱网络测试等。但是即使通过云测平台能够同时测试几十上百台机器,本地测试依然无法覆盖所有的机型和用户路径。
|
||||
|
||||
为了安全稳定地发布新版本,我们需要先圈定少量用户安装试用,这就是灰度发布。而数据验证平台则负责收集灰度和线上版本的应用数据,这里可能包括性能数据、业务数据、用户反馈以及外部舆情等。
|
||||
|
||||
所以说,灰度效率首先被下面两个因素所影响:
|
||||
|
||||
|
||||
测试效率。虽然灰度发布只影响少部分用户,但是我们需要尽可能保障应用的质量,以免造成用户流失。测试平台的发布测试时间是影响发布效率的第一个因素,我们希望可以在1小时内明确待定的发布包是否达到上线标准。
|
||||
|
||||
数据验证效率。数据的全面性、实时性以及准确性都会影响灰度版本的评估决策时间,是停止灰度发布,还是进一步扩大灰度的用户量级,或者可以直接发布到全量用户。对于核心数据,需要建立小时甚至分钟级别的实时监控。比如微信,对于性能数据可以在发布后1小时内评估完毕,而业务数据可以在24小时内评估完毕。
|
||||
|
||||
|
||||
另外一方面,如果我们的灰度发布想覆盖一万名用户,那多长时间才有足够的用户下载和安装呢?渠道的能力对灰度发布效率的影响也十分巨大,在国内主要有下面几个灰度渠道。
|
||||
|
||||
|
||||
|
||||
在国内由于没有统一的应用商店,灰度渠道效率的确是一个非常严峻的问题。即使是微信,如果不动用“红点提示”这个大杀器,每天灰度发布到的用户量可能还不到十万。而国际市场有统一的Google Play,可以通过Google Beta进行灰度。但是版本发布需要考虑GP审核的时间,目前GP审核速度相比之前有所加快,一般只需要一到两天时间。
|
||||
|
||||
通过灰度发布我们可以提前收集少部分用户新版本的性能和业务数据,但是它并不适用于精确评估业务数据的好坏。这主要是因为灰度的用户是有选择的,一般相对活跃的用户会被优先升级。
|
||||
|
||||
2. 动态部署
|
||||
|
||||
对于灰度发布,整个过程最大的痛点依然是灰度包的覆盖速度问题。而且传统的灰度方式还存在一个非常严重的问题,那就是无法回退。“发出去的包,就像泼出去的水”,如果出现严重问题,还可能造成灰度用户的流失。
|
||||
|
||||
Tinker动态部署框架正是为了解决这个问题而诞生的,我们希望Tinker可以成为一种新的发布方式,用来取代传统的灰度甚至是正式版本的发布。相比传统的发布方式,热修复有很多得天独厚的优势。
|
||||
|
||||
|
||||
|
||||
|
||||
快速。如果使用传统的发布方式,微信需要10天时间覆盖50%的用户。而通过热修复,在一天内可以覆盖80%以上的用户,在3天内可以覆盖95%以上的用户。
|
||||
|
||||
可回退。当补丁出现重大问题的时候,可以及时回退补丁,让用户回到基础版本,尽可能降低损失。
|
||||
|
||||
|
||||
为了提升补丁发布的效率,微信还专门开发了TinkerBoots管理平台。TinkerBoots平台不仅支持人数、条件等参数设置,例如可以选择只针对小米的某款机型下发10000人的补丁;而且平台也会打通数据验证平台,实现自动化的控量发布,自动监控核心指标的变化情况,保证发布质量。
|
||||
|
||||
|
||||
|
||||
Tinker发布已经两年多了,虽然热修复技术可以解决很多问题,但作为Tinker的作者,我必须承认它对国内的Android开发造成了一些不好的影响。
|
||||
|
||||
|
||||
用户是最好的测试。很多团队不再信奉前置的测试平台,他们认为反正有可以回退的动态部署发布,出现质量问题并不可怕,多发几个补丁就可以了。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
性能低下。正如专栏“高质量开发”模块所说的,热修复、组件化这些黑科技会对应用的性能产生不小的影响,特别是启动的耗时。
|
||||
|
||||
|
||||
从现在看来,热修复并不能取代发布,它更适合使用在少量用户的灰度发布。如果不是出现重大问题,一般情况也不应该发布针对所有用户的补丁。
|
||||
|
||||
组件化回归模块化,热修复回归灰度,这是国内很多大型App为了性能不得不做出的选择。如果想真正实现“随心所欲”的发布,可能需要倒逼开发模式的变革,例如从组件化转变为Web、React Native/Weex或者小程序来实现。
|
||||
|
||||
A/B测试
|
||||
|
||||
正如我前面所说,APK灰度是对已有功能的线上验证,它并不适合用于准确评估某个功能对业务的影响。
|
||||
|
||||
|
||||
If you are not running experiments, you are probably not growing!-
|
||||
——by Sean Ellis
|
||||
|
||||
|
||||
Sean Ellis是增长黑客模型(AARRR)之父,增长黑客模型中提到的一个重要思想就是“A/B测试”。 Google、 Facebook、国内的头条和快手等公司都非常热衷于A/B测试,希望通过测试进行科学的、数据驱动式的业务和产品决策。
|
||||
|
||||
那究竟什么是A/B测试?如何正确地使用A/B测试呢?
|
||||
|
||||
1. 什么是A/B测试
|
||||
|
||||
可能有同学会认为,A/B测试并不复杂,只是简单地把灰度用户分为A和B两部分,然后通过对比收集到的数据,分析得到测试的结论。
|
||||
|
||||
|
||||
|
||||
事实上A/B测试的难点就是A和B用户群体的圈定,我们需要保证测试方案是针对同质人群、同一时间进行,确保了除方案变量之外其他变量都是一致的,这样才能将指标数据差异归到产品方案,从而选出优胜版本在线上发布,实现数据增长。
|
||||
|
||||
|
||||
同质。指人群的各种特征分布的一致性。例如我们想验证产品方案对女性用户的购买意愿的影响,那么A版本和B版本选择的用户必须都是女性。当然特征分布不光是性别,还有国家、城市、使用频率、年龄、职业、新老用户等。
|
||||
|
||||
同时。在不同的时间点,用户的行为可能不太一样。例如在一些重大的节日,用户活跃度会升高,那如果A方案的作用时间在节日,B方案的作用时间在非节日,很显然这种比较对于B方案是不公平的。
|
||||
|
||||
|
||||
所以实现“同质同时”,并不是那么简单,首先需要丰富和精准的用户画像能力,例如版本、国家、城市、性别、年龄、喜好等用户属性。除此之外,还需要一整套强大的后台,完成测试控制、日志处理、指标计算、统计显著性指标等工作。
|
||||
|
||||
|
||||
|
||||
实现了“同质同时”之后,我们接着要找到产品方案的显著性指标,也就是方案想要证明的目标。例如我们优化了弹窗的提示语,目的是吸引更多的用户点击按钮,那按钮的点击率就是这个测试的显著性指标。
|
||||
|
||||
有了这些以后,那我们是不是就可以愉快地开始测试了?不对,你还需要先思考这两个问题:
|
||||
|
||||
|
||||
流量选择。这个测试应该配置多少流量?配少了怕得不出准确的测试结论,配置多了可能要承担更大的风险。这个时候就需要用到最小样本量计算器。
|
||||
|
||||
|
||||
|
||||
|
||||
A/B测试作为一种抽样统计,它背后涉及大量的统计学原理,而这个计算器需要我们提供以下数值:
|
||||
|
||||
|
||||
基准线指标值:请输入测试要优化的指标的基准值,比方“某按钮的点击率”,如果该值每日在9%~11%之间波动,则可输入10%。-
|
||||
指标最小相对变化:请输入你认为有意义的最小相对变动值。假设你将要优化的指标为10%时,你在最小相对变化中输入5%, 就意味着你认为绝对值变化在(9.5%, 10.5%)之间的变动没有意义,即便此时测试版本更优,你也不会采用。-
|
||||
统计功效1−β:如果设置统计功效为90%,可通俗理解为,在A/B测试中,当版本A和版本B的某项统计指标本质上存在显著差异时,可以正确地识别出版本A和版本B是有显著差异的概率是90%。-
|
||||
显著性水平α:如果统计指标的差异超过具体的差异,我们才说测试的结果是显著的。
|
||||
|
||||
|
||||
|
||||
天数选择。A/B测试需要持续几天,我们才可以认为测试是可靠的。一般来说可以通过下面的方法计算。
|
||||
|
||||
|
||||
测试所需持续的天数 >= 计算获得用户数 / (场景日均流量 * 测试版本设置流量百分比)
|
||||
|
||||
|
||||
2. 如何进行A/B测试
|
||||
|
||||
虽然各个大厂都有自己完善的A/B测试平台,但是A/B测试的科学设计并不容易,需要不断地学习,再加上大量的实践经验。
|
||||
|
||||
首先来说,所有的A/B测试都应该是有“预谋”的,也就是我们需要有对应的预期,需要设计好测试的每一个环节。一般来说,在测试开始之前,需要问清楚自己下面这些问题:
|
||||
|
||||
|
||||
|
||||
为了测试埋点、分流、统计的正确性,以及增加A/B测试的结论可信度,我们还会在A/B测试的同时,增加A/A测试。A/A测试是A/B测试的“孪生兄弟”,有的互联网公司也叫空转测试。它主要用于评估我们测试本身是否科学,一般我推荐使用A/A/B的测试方式。
|
||||
|
||||
那在Android客户端有哪些实现A/B测试的方案呢?
|
||||
|
||||
|
||||
|
||||
用一句话来描述A/B测试的话就是,“拿到A/B测试的数据容易,拿到可信的A/B测试的数据却很难”,因此在指标设计、人群选择、时间设计、方案的设计都需要考虑清楚。同时也需要多思考、多实践,推荐你拓展阅读《移动App A/B测试中的5种常见错误》。
|
||||
|
||||
统一发布平台
|
||||
|
||||
我在专栏里多次提到,虽然我们做了大量的优化,依然受限于原生开发模式的各种天花板的限制。这时可以考虑跳出这些限制,例如Web、React Native/Weex、小程序和Flutter都可以是解决问题的思路。
|
||||
|
||||
但是即使我们转变为新的开发模式,灰度和发布的步骤依然是不可或缺的。此外,我们还要面对各种各样的运营活动、推送弹窗、配置下发。
|
||||
|
||||
可能很多大厂的同学都深受其苦,面对各式各样的发布平台,一不小心就可能造成运营事故。那应该如何规范发布行为,避免出现下发事故呢?
|
||||
|
||||
1. 发布平台架构
|
||||
|
||||
每个应用都或多或少涉及下面这些发布类型,但是往往这些发布类型都分别放在大大小小的各种平台中,而且没有统一的管理规范流程,非常容易因为操作错误造成事故。
|
||||
|
||||
|
||||
|
||||
统一发布平台需要集中管理应用所有的数据下发业务,并建立严格规范的灰度发布流程。
|
||||
|
||||
|
||||
管理。所有的发布都必须通过权限校验,需要经过审批。“谁发布,谁负责”,需要建立严格的事故定级制度。对于因为疏忽导致事故的人员,需要定级处理。
|
||||
|
||||
灰度。所有的发布一定要经过灰度测试,慢慢扩大影响的用户范围。但是需要承认,某些下发并不容易测试,例如之前沸沸扬扬的“圣诞节改变展示样式”的事件。对于这种在特定时间生效的运营活动,很难在线上灰度验证。
|
||||
|
||||
监控。统一发布平台需要对接应用的“实时数据平台”,在出现问题的时候,需要及时采取补救措施。
|
||||
|
||||
|
||||
业务已经那么艰难了,如果下发了一个导致所有用户启动崩溃的闪屏活动,对应用造成的损失就难以衡量。所以规范的流程和章程,可以一定程度上避免问题的发生。当然监控也同样重要,它可以帮助我们及时发现问题并立刻止损。
|
||||
|
||||
2. 运营事故的应对
|
||||
|
||||
每天都有大量各种类型的发布,感觉就像在刀尖上行走一样。“人在江湖飘,哪能不挨刀”,当我们发现线上运营问题的时候,还有哪些挽救的措施呢?
|
||||
|
||||
|
||||
启动安全保护。最低限度要保证用户不会由于运营配置导致无法启动应用。
|
||||
|
||||
动态部署。如果应用可以正常启动,那我们可以通过热修复的方式解决。但是热修复也存在一定的局限性,例如有3%~5%的用户会无法热修复成功,而且也无法做到立即生效。
|
||||
|
||||
远程控制。在应用中,我们还需要保留一条“救命”的指令通道。通过这条通道,可以执行删除某些文件、拉取补丁、修改某些配置等操作。但是为了避免自有通道失效,这个控制指令需要同时支持厂商通道,例如某个下发资源导致应用启动后界面白屏,这个时候我们可以通过下发指令去删除有问题的资源文件。
|
||||
|
||||
|
||||
|
||||
|
||||
“万丈高楼平地起”,类似测试平台、发布平台或者数据平台这些内部系统很难短时间内做得非常完善,它们都是通过优化内部无数的小细节演进而来。所以我们需要始终保持耐心,朝着提升组织效能的方向前进。
|
||||
|
||||
总结
|
||||
|
||||
在过去那个做A/B测试非常艰难而且耗时耗力的年代,测试的每个环节都会经过千锤百炼。而现在通过功能强大的A/B测试系统,极大地降低测试的成本,提高了测试的效率,反而很多产品和开发人员变得有点滥用A/B测试,或者说有了“不去思考”的借口。
|
||||
|
||||
无论是研发主导的性能相关的A/B测试,还是产品主导的业务相关的A/B测试,其实很多时候都没有经过严谨的推敲,往往需要通过反反复复多次测试才能得到一个“结论”,而且还无法保证是可信的。所以无论是A/B测试,还是日常的灰度,都需要有明确的预期,真正去推敲里面的每一个环节。不要每次测试发布后,才发现实验设置不合理,或者发现这里那里漏了好几个数据打点,再反反复复进行修改,对参与测试的所有人来说都非常痛苦。
|
||||
|
||||
另外一方面,我们对某个事情的看法并不会一成不变。即使我是Tinker的作者,我也认为它只是某一阶段为了解决特定需求的产物。但是无论开发模式怎么改变,我们对质量和效率的追求是不会改变的。
|
||||
|
||||
课后作业
|
||||
|
||||
你的应用是否是A/B测试的狂热分子?对于A/B测试,你有哪些好的或者坏的的经历?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的课后作业是,思考自己的产品或者公司在灰度发布过程中存在哪些痛点?还有哪些优化的空间?请在留言中写下自己的心得体会。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
184
专栏/Android开发高手课/30数据评估(上):如何实现高可用的上报组件?.md
Normal file
184
专栏/Android开发高手课/30数据评估(上):如何实现高可用的上报组件?.md
Normal file
@ -0,0 +1,184 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 数据评估(上):如何实现高可用的上报组件?
|
||||
无论是“高效测试”中的实时监控,还是“版本发布”中的数据校验平台,我都多次提到了数据的重要性。
|
||||
|
||||
对于数据评估,我们的期望是“又快又准”。“快”,表示数据的时效性。我们希望在1小时内,甚至1分钟内就可以对数据进行评估,而不需要等上1天或者几天。“准”,表示数据的准确性,保证数据可以反映业务的真实情况,不会因为数据不准确导致做出错误的产品决策。
|
||||
|
||||
但是“巧妇难为无米之炊”,数据平台的准确性和时效性依赖客户端数据采集和上报的能力。那应该如何保证客户端上报组件的实时性和准确性?如何实现一个“高可用”的上报组件呢?
|
||||
|
||||
统一高可用的上报组件
|
||||
|
||||
可能有同学会疑惑,究竟什么是“高可用”的上报组件?我认为至少需要达到三个目标:
|
||||
|
||||
|
||||
数据不会丢失。数据不会由于应用崩溃、被系统杀死这些异常情况而导致丢失。
|
||||
|
||||
实时性高。无论是前台进程还是后台进程,所有的数据都可以在短时间内及时上报。
|
||||
|
||||
高性能。这里主要有卡顿和流量两个维度,应用不能因为上报组件的CPU和I/O过度占用导致卡顿,也不能因为设计不合理导致用户的流量消耗过多。
|
||||
|
||||
|
||||
但是数据的完整性、实时性和性能就像天平的两端,我们无法同时把这三者都做到最好。因此我们只能在兼顾性能的同时,尽可能地保证数据不会丢失,让上报延迟更小。
|
||||
|
||||
在“网络优化”中,我不止一次的提到网络库的统一。网络库作为一个重要的基础组件,无论是应用内不同的业务,还是Android和iOS多端,都应该用同一个网络库。
|
||||
|
||||
同理,上报组件也是应用重要的基础组件,我们希望打造的是统一并且高可用的上报组件。
|
||||
|
||||
|
||||
|
||||
一个数据埋点的过程,主要包括采样、存储、上报以及容灾这四个模块,下面我来依次拆解各个模块,一起看看其中的难点。
|
||||
|
||||
|
||||
|
||||
1. 采样模块
|
||||
|
||||
某些客户端数据量可能会非常大,我们并不需要将它们全部都上报到后台。比如说卡顿和内存这些性能数据,我们只需要抽取小部分用户统计就可以了。
|
||||
|
||||
采样模块是很多同学在设计时容易忽视的,但它却是所有模块中最为复杂的一项,需要考虑下面一些策略的选择。
|
||||
|
||||
|
||||
|
||||
大多数的组件采用的都是PV次数采样,这样的确是最简单的。但是我们更多是在性能数据埋点上采样,为了降低用户的影响面,我更加倾向于使用UV采样的方式。而且为了可以让更多的用户上报,我也希望每天都可以更换一批新的用户。
|
||||
|
||||
最终我选择的方案是“UV采样 + 用户标识随机 + 每日更换用户”的方式,但是采样还需要满足三个标准。
|
||||
|
||||
|
||||
准确性。如果配置了1%的采样比例,需要保证某一时刻只有1%的用户会上报这个数据。
|
||||
|
||||
均匀性。如果配置了1%的采样比例,每天都会更换不同的1%用户来上报这个数据。
|
||||
|
||||
切换的平滑性。用户的切换需要平滑,不能在用一个时间例如12点,所有用户同时切换,这样会导致后台数据不连贯。
|
||||
|
||||
|
||||
实现上面这三个标准并不容易,在微信中我们采用了下面这个算法:
|
||||
|
||||
// id:用户标识,例如微信号、QQ号
|
||||
id_index = Hash(id) % 采样率倒数
|
||||
time_index = (unix_timestamp / (24*60*60)) % 采样率倒数
|
||||
上报用户 =(id_index == time_index)
|
||||
|
||||
|
||||
每个采样持续24小时,使整个切换可以很平滑,不会出现所有用户同时在0点更换采样策略。有些用户在早上10点切换,有些用户在11点切换,会分摊到24小时中。并且从一个小时或者一天的维度来看,也都可以保证采样是准确的。
|
||||
|
||||
不同的埋点可以设置不同的采样率,它们之间是独立的、互不影响的。当然除了采样率,在采样策略里我们还可以增加其他的控制参数,例如:
|
||||
|
||||
|
||||
上报间隔:可以配置每个埋点的上报间隔,例如1秒、1分钟、10分钟、60分钟等。
|
||||
|
||||
上报网络:控制某些点只允许WiFi上传。
|
||||
|
||||
|
||||
2. 存储模块
|
||||
|
||||
对于存储模块,我们的目标是在兼顾性能的同时,保证数据完全不会丢失。那应该如何实现呢?我们首先要思考进程和存储模式的选择。
|
||||
|
||||
|
||||
|
||||
业内最常见的上报组件是“单进程写 + 文件存储 + 内存缓存”,虽然这种方式实现最为简单,但是无论是跨进程的IPC调用堆积(IPC调用总是很慢的)还是内存缓存,都可能会导致数据的丢失。
|
||||
|
||||
回顾一下在“I/O优化”中,我列出的mmap、内存与写文件的性能对比数据。
|
||||
|
||||
|
||||
|
||||
你可以看到mmap的性能非常不错,所以我们最终选择的是 “多进程写 + mmap”的方案,并且完全抛弃了内存缓存。不过mmap的性能也并不完美,它在某些时刻依然会出现异步落盘,所以每个进程mmap操作需要放到单独的线程中处理。
|
||||
|
||||
|
||||
|
||||
“多进程写 + mmap”的方案可以实现完全无锁、无IPC并且数据基本不会丢失,看上去简直完美,但是真正实现时是不是像图中那么简单呢?肯定不会那么简单,因为我们需要考虑埋点数据的聚合以及上报数据优先级。
|
||||
|
||||
|
||||
埋点数据的聚合。为了减少上报的数据量,尤其是部分性能埋点,我们需要支持聚合上报。大部分组件都是使用上报时聚合的方式,但是这样无法解决存储时的数据量问题。由于我们使用的是mmap,可以像操作内存一样操作文件中的数据,可以实现性能更优的埋点数据的聚合功能。
|
||||
|
||||
上报数据优先级。很多上报组件埋点时都会使用一个是否重要的参数,对于重要的数据采用直接落地的方式。对于我们的方案来说,已经默认所有的数据都是重要的。关于上报数据的优先级,我建议使用上报间隔来区分,例如1分钟、10分钟或者1小时。
|
||||
|
||||
|
||||
|
||||
|
||||
对于一些敏感数据,可能还需要支持加密。对于加密的数据,我建议使用单独的另一个mmap文件存储。
|
||||
|
||||
为什么上面我说的是数据基本不会丢失,而不是完全不会丢失呢?因为当数据还没有mmap落盘,也就是处于采样、存储内部逻辑时,这个时候如果应用崩溃依然会造成数据丢失。为了减少这种情况发生,我们做了两个优化。
|
||||
|
||||
|
||||
精简处理逻辑。尽量减少每个埋点的处理耗时,每个埋点的数据处理时间需要压缩到0.1毫秒以内。
|
||||
|
||||
KillProcess等待。在应用主动KillProcess之前,需要调用单独的函数,先等待所有队列中的埋点处理完毕。
|
||||
|
||||
|
||||
3. 上报模块
|
||||
|
||||
对于上报模块,我们不仅需要满足上报实时性,还需要合理地优化流量的使用,主要需要考虑的策略有:
|
||||
|
||||
|
||||
|
||||
为了解决后台进程的上报实时性问题,我们采用了单进程上报的策略,我推荐使用保活能力比较强的进程作为唯一的上报进程。为了更加精细地控制上报间隔,我们采用更加复杂的班车制度模式。
|
||||
|
||||
后来经过仔细思考,最终的上报模块采用“多进程写 + 单进程上报”。这里有一个难点,那就是如何及时的收集所有已经停止的班车,会不会出现多进程同步的问题呢?我们是通过Linux的文件rename的原子性以及FileObserver机制,实现了一套完全无锁、高性能的文件同步模型。
|
||||
|
||||
|
||||
|
||||
每个进程在对应优先级的文件“停车”的时候,负责把文件rename到上报数据存放的目录中。因为rename是原子操作,所以不用担心两个进程会同时操作到同一个文件。而对应的上报进程只需要监听上报数据目录的变化,就可以实现文件状态的同步。这样就避免了多进程同步操作同一个文件的问题,整个过程也无需使用到跨进程的锁。
|
||||
|
||||
当然上报模块里的坑还有很多很多,例如合并上报文件时应该优先选择高优先级的文件;对于上报的包大小,在移动网络需要设置的比WiFi小一些,而不同优先级的文件需要合并组包,尽量吃满带宽;而且在弱网络的时候,我们需要把数据包设置得更小一些,先上报最高优先级的数据。
|
||||
|
||||
4. 容灾模块
|
||||
|
||||
虽然我们设计得上报模块已经很强大,但是如果使用者调用不合理,也可能会导致严重的性能问题。我曾经遇到过,某个同学在一个for循环连续埋了一百万个点;还有一次是某个用户因为长期没有网络,导致本地堆积了大量的数据。
|
||||
|
||||
一个强大的组件,它还需要具备容灾的能力,本地一般可以有下面这些策略。
|
||||
|
||||
|
||||
|
||||
容灾模块主要是保证即使出现开发者使用错误、组件内部异常等情况,也不会给用户的存储空间以及流量造成严重问题。
|
||||
|
||||
数据自监控
|
||||
|
||||
通过“多进程写 + mmap + 后台进程上报 + 班车模式”,我们实现了一套完全无锁、数据基本不会丢失、无跨进程IPC调用的高性能上报组件,并且通过容灾机制,它还可以实现异常情况的自动恢复。
|
||||
|
||||
那线上效果是不是真的这么完美?我们怎样确保上报组件的数据可靠性和时效性呢?答案依然是监控,我们需要建立一套完善的自监控体系,为后续进一步优化提供可靠的数据支撑。
|
||||
|
||||
1. 质量监控
|
||||
|
||||
上报组件的核心数据指标主要包括以下几个:
|
||||
|
||||
|
||||
|
||||
当然,如果我们追求更高的实时性,可以选择计算小时到达率,甚至是分钟到达率。
|
||||
|
||||
2. 容灾监控
|
||||
|
||||
当客户端出现容灾处理时,我们也会将数据单独上报到后台监控起来。
|
||||
|
||||
|
||||
|
||||
除了异常情况的监控,我们还希望将用户每日使用的移动流量和WiFi流量做更加细粒度的分区间监控,例如0~1MB的占比、1~5MB的占比等。
|
||||
|
||||
总结
|
||||
|
||||
网络和数据都是非常重要的基础组件,今天我们一起打造了一款跨平台、高可用的上报组件。这也是目前比较先进的方案,在各方面的质量指标都比传统的方案有非常大的提升。
|
||||
|
||||
|
||||
|
||||
当然真正落实到编码,这里面还有非常多的细节需要考虑,也还有大大小小很多暗坑。而且虽然我们使用C++实现,但是也还需要处理不同平台的些许差异,比如iOS根本不需要考虑多进程问题等。
|
||||
|
||||
在实践中我的体会是,当我们亲自动手去实现一个网络库或者上报组件的时候,才会深深体会到把一个新东西做出来并不困难,但是如果想要做到极致,那必然需要经过精雕细琢,更需要经过长时间的迭代和优化。
|
||||
|
||||
课后作业
|
||||
|
||||
你所在的公司,目前正在使用哪个数据上报组件?它存在哪些问题呢?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的课后作业是,在实现方案中我故意隐去了两个细节点,这里把它们当作课后作业留给你,请你在留言中写下自己的答案。
|
||||
|
||||
1. 采样策略的更新。当我们服务器采样策略更新的时候,如果不使用推送,怎样保证新的采样策略可以以最快速度在客户端生效?-
|
||||
2. 埋点进程突然崩溃。你有没有想到,如果Process A突然崩溃,那哪个进程、在什么时机、以哪种方式,应该负责把Process A对应的埋点数据及时rename到上报数据目录?
|
||||
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
196
专栏/Android开发高手课/31数据评估(下):什么是大数据平台?.md
Normal file
196
专栏/Android开发高手课/31数据评估(下):什么是大数据平台?.md
Normal file
@ -0,0 +1,196 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 数据评估(下):什么是大数据平台?
|
||||
数据是连接产品和用户的桥梁,它反映了用户对产品的使用情况,是我们作出业务决策的重要依据。虽然通过“高可用的上报组件”,可以从源头上保障数据采集的准确性和实时性,但是随着App业务迭代的复杂化,经常会出现遗漏埋点、错误埋点、多端埋点不统一等情况,影响了业务数据的稳定性。
|
||||
|
||||
我见过很多团队的埋点文档管理得非常不规范,有的还在使用Excel来管理埋点文档,经常找不到某些埋点的定义。而随着埋点技术和流程的成熟,我们需要有一整套完整的方案来保证数据的稳定性。
|
||||
|
||||
那埋点应该遵循什么规范?如何实现对埋点整个流程的引导和监控?埋点管理、埋点开发、埋点测试验证、埋点数据监控…怎样打造一站式的埋点平台?在埋点平台之上,大数据平台又是什么样的呢?
|
||||
|
||||
埋点的基础知识
|
||||
|
||||
我们知道,一个业务埋点的上线需要经历需求、开发、测试等多个阶段,会涉及产品、开发和测试多方协作,而对于大型团队来说,可能还要加上专门的数据团队。
|
||||
|
||||
对于传统埋点来说,错埋、漏埋这样的问题总会反反复复出现。为了排查和解决数据的准确性问题,参与的各方团队都要耗费大量的精力。特别是如果埋点一旦出现问题,我们还需要依赖App发布新版本,可见埋点的修复周期长,而且成本也非常巨大。
|
||||
|
||||
那应该如何解决这个问题呢?请先来思考一下,我们应该如何实现一个正确的埋点。
|
||||
|
||||
|
||||
|
||||
如果想实现一个正确的埋点,必须要满足上面的这四个条件,需要有非常严格的埋点流程管理。因此,你需要做到:
|
||||
|
||||
|
||||
统一的埋点规范。应用甚至是整个公司内部,从日志的格式、参数的含义都要有统一的规则。
|
||||
|
||||
统一的埋点流程。在整个埋点过程,产品、开发、测试和数据团队都要肩负起各自的职责,一起通力协作,通过统一、规范的流程来实现正确的埋点。
|
||||
|
||||
|
||||
通过统一的埋点规范和流程,希望可以减少埋点开发的成本,保障数据的准确性。下面我们一起来看看具体应该如何实践。
|
||||
|
||||
1. 统一埋点规范
|
||||
|
||||
在打开淘宝的主页时,不知道你有没有注意到URL后面会带有一个SPM的参数。
|
||||
|
||||
|
||||
https://www.taobao.com/?spm=a21bo.2017.201857.3.5af911d9ycCIDq
|
||||
|
||||
|
||||
这个SPM代表什么含义呢?SPM全称是Super Position Model,也就是超级位置模型。简单来说,它是阿里内部统一的埋点规范协议,无论H5还是Native的Android和iOS,都要遵循这套规范。
|
||||
|
||||
正如上面的链接一样,SPM由A.B.C.D四段构成,各分段分别代表的含义如下。
|
||||
|
||||
A:站点/业务, B:页面, C:页面区块, D:区块内点位
|
||||
|
||||
注:a21bo.2017.201857.3.5af911d9ycCIDq一共有5位,这是网站特有的,最后一位分配的是一个随机特征码,只是用来保证每次点击SPM值的唯一性。
|
||||
|
||||
|
||||
SPM主要有页面访问、控件点击以及曝光三种类型的事件,它可以用来记录了用户点击或者查看当前页面的具体信息,还可以推算出用户来自上一个页面的哪个位置。基于SPM规范,淘宝可以得到用户每个页面PV、点击率、停留时长、转化率、用户路径等各种维度的指标。
|
||||
|
||||
|
||||
|
||||
对于埋点规范来说,从公共参数到内部的各个业务参数,我们都需要定义完整的日志格式。目前,SPM这套规范已经推广到阿里整个集团以及外部的合作伙伴中,这样通过各个部门、各个客户端的规范统一,不仅降低了内部学习和沟通协作的成本,而且对后续的数据存储、校验、分析都会带来极大的便利。
|
||||
|
||||
“一千个读者心中有一千个哈姆雷特”,每个公司的情况可能不一定相同,所以也不能保证阿里的埋点规范适合所有的企业。但是无论我们最终决定使用哪种规范,对于公司内部,至少是应用内部来说,埋点规范应该是统一的。
|
||||
|
||||
关于SPM规范,如果你想了解更多,可以参考《SPM参数有什么作用》和《阿里巴巴的日志采集分享》。
|
||||
|
||||
2. 统一埋点流程
|
||||
|
||||
埋点的整个过程涉及产品、开发、测试、数据团队等多个团队,如果内部没有完善的流程规范,非常容易出现“四国大混战”。在出现数据问题的时候,也常常会出现互相推卸责任的情况。
|
||||
|
||||
“无规矩不成方圆”,我们需要制定统一的埋点流程,严格规范整个埋点过程的各个步骤以及每个参与者在相应步骤的分工和责任。
|
||||
|
||||
|
||||
|
||||
|
||||
需求阶段。在需求评审阶段,产品需要列出具体的埋点需求。如果有数据团队的话,产品的埋点需求需要数据团队review,测试同时也需要根据产品的埋点需求制定出对应的埋点测试方案。这个阶段主要由产品负责,需要保证需求方案和测试方案都是OK的。
|
||||
|
||||
开发阶段。在开发阶段,开发人员根据产品的埋点需求文档,根据具体的埋点规则在客户端中埋点。开发完成后,需要在本地自测通过。这个阶段由开发负责。
|
||||
|
||||
测试阶段。在测试阶段,测试人员根据产品的埋点需求和规则,通过之前指定的测试方案进行埋点的本地验收。这个阶段由测试负责。
|
||||
|
||||
灰度发布阶段。在灰度发布阶段,测试人员负责对埋点建立线上的监控,查看线上的数据是否符合埋点需求和规则,产品人员需要关心埋点数据是否是符合预期。这个阶段主要是测试负责,但是产品也同样需要参与。
|
||||
|
||||
|
||||
通过统一埋点流程,我们明确规定了埋点各个阶段的任务与职责,这样可以减少埋点的成本、降低出错的概率。
|
||||
|
||||
3. 埋点方式
|
||||
|
||||
代码埋点、可视化埋点、声明式埋点、无痕埋点,对于埋点的方式,业界似乎有非常多的流派。在《美团点评前端无痕埋点实践》和《网易HubbleData之Android无埋点实践》都将埋点方式归为以下三类:
|
||||
|
||||
|
||||
代码埋点。在需要埋点的节点调用接口直接上传埋点数据,友盟、百度统计等第三方数据统计服务商大都采用这种方案。
|
||||
|
||||
可视化埋点。通过可视化工具配置采集节点,在前端自动解析配置并上报埋点数据,从而实现所谓的“无痕埋点”, 代表方案有已经开源的Mixpanel。
|
||||
|
||||
无痕埋点。它并不是真正不需要埋点,而是自动采集全部事件并上报埋点数据,在后端数据计算时过滤出有用的数据,代表方案有国内的GrowingIO。
|
||||
|
||||
|
||||
我们平常使用最多的就是“代码埋点”方式,而对于“可视化埋点”和“无痕埋点”,它们都需要实现埋点的自动上报,需要实现事件的自动拦截。
|
||||
|
||||
怎么理解呢?你来回想一下,对于SPM方案中的页面切换事件,我们可以通过监听Activity或者Fragment的切换实现。那怎样自动监听控件的点击和曝光事件呢?
|
||||
|
||||
以监听点击事件为例,一般有下面几种方法:
|
||||
|
||||
|
||||
插桩替换。对于控件的点击,我们可以通过ASM全局将View.onClickListener中的onClick方法覆写成我们自己的Proxy实现,在内部添加埋点代码。
|
||||
|
||||
Hook替换。通过Java反射,从RootView开始,递归遍历所有的控件View对象,并Hook它对应的OnClickListener对象,同样将它替换成我们的Proxy实现。
|
||||
|
||||
AccessibilityDelegate机制。通过AccessibilityDelegate,我们可以检测到控件点击、选中、滑动、文本变化等状态。借助AccessibilityDelegate,当控件触发点击行为时,通过具体的AccessibilityEvent回调添加埋点代码。
|
||||
|
||||
dispatchTouchEvent机制。dispatchTouchEvent方法是系统点击事件的分发函数,通过重写这些函数,就可以实现对所有点击事件的监听。
|
||||
|
||||
|
||||
大数据平台
|
||||
|
||||
虽然我们有了统一的埋点规范和流程,但是整个流程依然是依赖人工手动的。就以埋点需求管理为例,很多团队还在使用Excel来管理,随着不断的修改,文档会越来越复杂,这样也不利于对历史进行跟踪。
|
||||
|
||||
那怎样将埋点管理、埋点开发、埋点测试验证、埋点数据监控打造成一站式的埋点平台呢?
|
||||
|
||||
1. 埋点一站式平台
|
||||
|
||||
埋点一站式平台可以实现管理埋点定义的可视化,辅助开发和测试定位埋点相关的问题。并且自动化验证本地和线上的埋点数据,以及自动分析和告警,也可以减少埋点开发和验证的成本,提升数据质量。
|
||||
|
||||
|
||||
|
||||
如上图所示,它主要由四个子平台组成。
|
||||
|
||||
|
||||
埋点管理平台。对应用的整个埋点方案进行统一管理,包括埋点的各个字段的定义和规则,例如对于QQ号这个字段来说,要求是纯数字而且非空的。对于SPM规范,埋点管理平台也会记录每个页面对应的名称,例如淘宝首页会用a123来表示。
|
||||
|
||||
埋点开发辅助平台。开发辅助平台是为了提升开发埋点的效率,例如我前面说到的可视化埋点。或者通过埋点管理平台的字段和规则,自动生成代码,开发者可以一键导入埋点定义的类,只需要在代码中添加调用即可。
|
||||
|
||||
埋点验证平台。验证平台非常非常重要,对于开发人员的埋点测试和测试人员的本地验收,我们可以通过扫码或者其他方式,切换成数据的实时上传模式。埋点验证平台会拉取配置平台的埋点定义和规则,将客户端上报的数据进行实时展示和规则校验。比如说某个埋点漏了一个字段、多了一个字段,又或者是违反了预设的规则,例如QQ号有字母、数值为空等。
|
||||
|
||||
|
||||
因为手工测试不一定可以覆盖所有的场景,所以我们还需要依赖自动化和灰度验证。它们整体思路还是一致的,只是借助的是线上的非实时通道,每小时或者每日定期输出数据验证的报告。
|
||||
|
||||
|
||||
|
||||
|
||||
埋点监控平台。监控的目标是保证整个数据链路的健壮性,这里包括对客户端“高可用上报组件”的监控,例如上一期讲到的质量监控和容灾监控。还有对后端数据解析、存储、分析的监控,例如总日志量、日志异常量、日志的丢失量等。
|
||||
|
||||
|
||||
不知道你是否注意到了,埋点管理平台还会对采样策略进行管理。回到专栏上一期我留给你课后作业的问题,当我们服务器采样策略更新的时候,如果不使用推送,怎样保证新的采样策略可以以最快速度在客户端生效呢?
|
||||
|
||||
|
||||
|
||||
其实非常简单,当用户更改了某个埋点的采样配置时,埋点配置平台会将采样策略版本号自增,然后将最新的策略以及版本号推送到数据采集模块。埋点SDK每次上报都会带上自己本地的策略版本号,如果本地的策略版本号小于服务器的版本号,那么数据采集模块会直接把最新的策略在回包中返回。
|
||||
|
||||
这种方式保证只要客户端有任意一个埋点上报成功,都可以拿到最新的采样策略。事实上,很多其他的配置都是采用类似的方式更新。
|
||||
|
||||
2. 数据产品
|
||||
|
||||
埋点的一站式平台,也只是数据平台的一小部分,它负责保证上报数据的准确性。按照我的理解,整个大数据平台的简化架构是下面这个样子的。
|
||||
|
||||
|
||||
|
||||
|
||||
采集工具层。应用的上报组件负责数据埋点、日志的组装上报,它需要保证数据的准确性和实时性。
|
||||
|
||||
数据采集层。数据采集层对日志进行清洗、处理,可能还需要和我们的埋点一站式平台进行交互。然后需要根据数据的订阅情况将数据分发到不同的计算模块。
|
||||
|
||||
数据计算层。计算层主要分为离线计算和实时计算两部分,离线计算从数据接收到结果的产出,一般至少需要一个小时以上。而实时计算可以实现秒级、分钟级的计算,一般只会用于核心业务的监控。而且因为计算量的问题,实时计算一般只会计算PV,不会计算UV结果。
|
||||
|
||||
数据服务层。无论是离线计算还是实时计算,我们都会把结果存放到数据的服务层,一般都会使用DB。数据服务层主要是为了屏蔽底层的复杂实现,我们只需要从这里查询最终的计算结果就可以了。
|
||||
|
||||
数据产品层。数据产品一般分为两类,一类是业务型,一类是监控型。业务型一般用来查看和分析业务数据,比如页面的访问、页面的漏斗模型、页面的流向、用户行为路径分析等。监控型主要用来监控业务数据,例如实时的流量监控、或者是非实时的业务数据监控等。
|
||||
|
||||
|
||||
数据服务层是一个非常好的设计,它让整个公司的人都可以非常简便地实现不同类型的数据产品。我们不需要关心下层复杂的数据采集和计算的实现,只要把数据拿出来,做一个满足自己的报表展示系统就可以了。
|
||||
|
||||
对于实时监控,微信的IDKey、阿里的Sunfire都是非常强大的系统,它们可以实现客户端数据分钟级甚至秒级的实时PV监控。
|
||||
|
||||
|
||||
|
||||
对于中小型公司,可能没有能力搭建自己一整套的大数据平台,这个时候可能需要使用第三方的服务,例如阿里云提供了一套OneData服务。
|
||||
|
||||
当然我们也可以搭建一套自己的数据平台,但是对于海量大数据来说,一个稳定、高性能的数据计算层是非常复杂的,我们可以使用外部打包好的数据计算和服务层,例如阿里云的MaxCompute大数据计算服务。接着在数据计算层之上,再来实现符合我们自己需求的数据产品。
|
||||
|
||||
下面是一个简单的数据平台整体架构图,你也可以参考大众点评的实现《UAS:大众点评用户行为系统》。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
在过去的几年里,大数据也是一个经常被提起的概念。对于大数据,或者说与之配套的大数据平台,我自己的体会主要有两点。
|
||||
|
||||
1. 技术变革是为了解决需求。如果淘宝没有面对每天亿级的用户访问数量,没有一次又一次的被卷入数据的黑洞中,也不会有他们对大数据方面所做的各种艰苦努力,技术是为了解决业务场景中的痛点。另一方面看,大数据的确存在门槛,如果在中小型企业可能不一定有这样锻炼的机会。
|
||||
|
||||
2. 基础设施建设没有捷径可走。高可用的上报组件、埋点一站式平台以及各种各样的数据产品,这些基础设施的建设需要有足够的耐心,投入足够的人力、物力。为什么要采用这样的规范和流程?为什么架构会这样设计?虽然这些方案可能不是最优的,但也是通过血与泪、通过大量的实践,慢慢演进得来的。
|
||||
|
||||
课后作业
|
||||
|
||||
你所在的公司,有没有统一的埋点规范和埋点流程?对于数据相关的配套设施建设得怎么样?在数据保障方面遇到了哪些问题?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
在数据平台的建设上面,国际的Facebook、国内的阿里都是做得非常不错的公司,我推荐你看看阿里数据专家们写的一本书《大数据之路 阿里巴巴大数据实践》。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
193
专栏/Android开发高手课/32线上疑难问题该如何排查和跟踪?.md
Normal file
193
专栏/Android开发高手课/32线上疑难问题该如何排查和跟踪?.md
Normal file
@ -0,0 +1,193 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 线上疑难问题该如何排查和跟踪?
|
||||
“95%以上的崩溃都能解决或者规避,大部分的系统崩溃也是如此”,这是我在专栏“崩溃优化”中曾经夸下的海口。
|
||||
|
||||
虽然收集了尽可能丰富的崩溃现场,但总会有一些情况是事先没有预料到的,我们无法直接从崩溃日志里找到原因。事实上我们面临的难题远远不止崩溃,比如说用户投诉文件下载到99%之后无法继续,那如何确定是用户手机网络不好,是后台服务器出错,还是客户端代码的Bug?
|
||||
|
||||
我们的业务逻辑越来越复杂,应用运行的环境也变得越来越复杂,因此在实际工作中总会遇到大大小小的线上疑难问题。对于这些问题,如何将它们“抽丝剥茧”,有哪些武器可以帮助我们更好地排查和跟踪呢?
|
||||
|
||||
用户日志
|
||||
|
||||
对于疑难问题,我们可以把它们分为崩溃和非崩溃两类。一般有哪些传统的排查手段呢?
|
||||
|
||||
|
||||
本地尝试复现。无论是崩溃还是功能性的问题,只要有稳定的复现路径,我们都可以在本地采用各种各样的手段或工具进行反复分析。但是真正的疑难问题往往都很难复现,它们可能跟用户机型、本地存储数据等环境有关。
|
||||
|
||||
发临时包或者灰度包。如果发临时包给用户,整个过程用户配合繁琐,而且解决问题的时间也会很长。很多时候我们根本无法联系到用户,这个时候只能通过发线上灰度包的方式。但是为了一步步缩小问题的范围,我们可能又需要一次次地灰度。
|
||||
|
||||
|
||||
我们多么希望能有一些“武器”,帮助工程师用非常低的成本,在非常短的时间内,尽可能地收集足够丰富的信息,更快速地排查和解决问题。
|
||||
|
||||
1. Xlog
|
||||
|
||||
在日常开发过程中,我们经常会使用Logcat日志来排查定位代码中的问题。
|
||||
|
||||
|
||||
|
||||
对于线上问题,我们也希望可以有用户的完整日志,这样即使问题不能复现,通过日志也可能定位到具体的原因。所谓“养兵千日,用兵一时”,客户端日志只有当出现问题且不容易复现时才会体现出它的重要作用。但是为了保证关键时刻有日志可用,就需要保证程序整个生命周期内都要打日志,所以日志方案的选择至关重要。
|
||||
|
||||
在过去因为性能和可靠性问题,通常我们只针对某少部分人动态打开日志开关。那如何实现一套高性能、日志不会丢失并且安全的日志方案呢?微信在2014年就实现了自己的高性能日志模块Xlog,并且在2016年作为Mars的一部分开源到GitHub。关于Xlog的更多实现细节,你可以参考源码或者会议分享。
|
||||
|
||||
Xlog方案的出现,可以让全量用户全天候打开日志,也不需要担心对应用性能造成太大的影响。但是Xlog只是一个高性能的日志工具,最终是否能解决我们的线上问题,还需要看我们如何去使用它。
|
||||
|
||||
所以微信制定了严格的日志规范,定期对拉取的日志作规则检查,一旦发现有违反规则的情况,会作出一定的处罚。下面是其中的一些日志规范,我选取一些分享给你。
|
||||
|
||||
|
||||
|
||||
日志打点怕打太多也怕太少,担心出现问题没有足够丰富的信息去定位分析问题。应该打多少日志,如何去打日志并没有一个非常严格的准则,这需要整个团队在长期实践中慢慢去摸索。在最开始的时候,可能大家都不重视也不愿意去增加关键代码的日志,但是当我们通过日志平台解决了一些疑难问题以后,团队内部的成功案例越来越多的时候,这种习惯也就慢慢建立起来了。
|
||||
|
||||
2. Logan
|
||||
|
||||
对于移动应用来说,我们可能会有各种各样的日志类型,例如代码日志、崩溃日志、埋点日志、用户行为日志等。由于不同类型的日志都有自己的特点,这样会导致日志比较分散,比如我们要查一个问题,需要在各个不同的日志平台查不同的日志。美团为了解决这个问题,提出了统一日志平台的思路,也在Github开源了自己的移动端基础日志库Logan。
|
||||
|
||||
|
||||
|
||||
Logan整合了各式各样的日志平台,打造成一个统一的日志平台,进一步提升了开发人员查找问题的效率。不过无论是Logan还是Xlog,日志一般会通过下面两种方式上报。
|
||||
|
||||
|
||||
Push拉取。通过推送命令,只拉取特定用户的日志。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
主动上报。在用户反馈问题、出现崩溃等一些预设场景,主动上报日志。
|
||||
|
||||
|
||||
|
||||
|
||||
对于用户日志,我们是否已经做到尽善尽美了?手动埋点的覆盖范围有限,如果关键位置没有预先埋点,那可能就需要重新发包。所以美团在Logan基础上,还推出了Android动态日志系统Holmes。
|
||||
|
||||
|
||||
|
||||
Holmes的实现跟美团的Robust热修复思路差不多,需要对每个方法进行了插桩来记录方法执行路径,也就是在方法的开头插入一段桩代码,当方法运行的时候就会记录方法签名、进程、线程、时间等形成一条完整的执行信息。但是这套方案性能的技术难点比较多,一般只会动态针对出现问题的用户开启。
|
||||
|
||||
虽然这个思路有一定的启发性,但我认为其实并不太实用。一来,对每个方法插桩,会对安装包体积和性能造成很大的影响,导致这套方案过于笨重;二来,很多疑难问题都具有偶发性,当用户出现问题后,再去打开用户日志,可能也不能保证用户的问题可以复现。
|
||||
|
||||
动态调试
|
||||
|
||||
“只要你能在本地复现,我就能解决”,这可能是开发对测试说过最多的话了。在本地,我们可以通过增加日志,或者使用Debugger、GDB等这样的调试工具反复进行验证。
|
||||
|
||||
针对远程用户,如果我们可以做到具备跟本地一样的动态调试能力,想想都觉得激动人心。那有没有方案能实现远程动态调试呢?
|
||||
|
||||
1. 远程调试
|
||||
|
||||
动态调试,又或者是动态跟踪,它们属于高级的调试技术。事实上,它并不是什么新鲜的话题,例如Linux中大名鼎鼎的DTrace和SystemTap、Java的BTrace,都是非常成熟的方案。我推荐你仔细阅读《动态调试漫谈》和《Java动态追踪技术探究》,特别是前者,让我有非常大的收获。
|
||||
|
||||
在Android端,我们能不能实现对用户做动态调试呢?在回答这个问题之前,请先来思考一下平时我们通过Android Studio进行调试的底层原理是什么。
|
||||
|
||||
其实我们的学习委员鹏飞之前已经讲过这块内容了,回到“Android JVM TI机制详解”中说到的Debugger Architecture。Java的调试框架是通过JPDA(Java Platform Debugger Architecture,Java平台调试体系结构),它定义了一套独立且完整的调试体系,主要由以下三部分组成:
|
||||
|
||||
|
||||
JVM TI:Java虚拟机工具接口(被调试者)。
|
||||
JDWP:Java调试协议(通道)。
|
||||
JDI:Java调试接口(调试者)。
|
||||
|
||||
|
||||
|
||||
|
||||
如果你想了解更多关于Java调试框架的信息,可以重新回顾一下“Android JVM TI机制详解”里给出的参考链接。
|
||||
|
||||
对于Android来说,它的调试框架也是在Java调试框架基础上进行的扩展,主要包括Android Studio(JDI)、ddmlib、adb server、adb daemon、Android应用这五个组成部分。
|
||||
|
||||
|
||||
|
||||
如果想要实现对用户的远程调试,我们需要修改其中的两个部分。
|
||||
|
||||
|
||||
JDWP(传输通道)。我们不能使用系统adb的方式,而是希望将用户的调试信息经过网络通道发送给我们。
|
||||
|
||||
JDI(前端展示)。对于客户端调试数据的展示,我们不太容易直接复用Android Studio,需要重新实现自己的数据展示页面。
|
||||
|
||||
|
||||
关于具体实现的细节,你可以参考美团的文章《Android远程调试的探索与实现》,最终整体的流程如下。
|
||||
|
||||
|
||||
|
||||
当然不同于本地Debug包的调试,对于用户调试我们还需要考虑如何突破ProGuard和Debugable的影响。总的来说,这套方案有非常大的技术价值,可以加深我们对Java调试框架的理解。但是它并不实用,因为大部分的场景,我们很难在用户不配合的前提下做好调试。而且调试过程也可能会出现各种各样的情况,并不容易控制。
|
||||
|
||||
不过退而求其次,通过这个思路,我们可以在本地实现“无线调试”(无需adb),又或者是实现对混淆包的调试。
|
||||
|
||||
2. 动态部署
|
||||
|
||||
如果说远程调试并不很实用,那有没有其他方法让用户感知不到我们在进行调试呢?
|
||||
|
||||
用户无感知、代码更新,这不正是动态部署所具备的能力,而且动态部署天生就非常适合使用在疑难问题的排查上。
|
||||
|
||||
|
||||
精细化。通过发布平台,我们可以只选择某些问题用户做动态更新。也可以圈定某一批用户,例如某个问题只在华为的某款机型出现,那我可以只针对华为的这款机型下发。
|
||||
|
||||
场景。对于疑难问题的排查,我们一般只会增加日志或者简单修改逻辑,动态部署在这个场景是完全可以满足的。
|
||||
|
||||
可重复、可回退。对于疑难问题,我们可能需要反复尝试不同的解决思路,动态部署完全可以解决这个需求。而且在问题解决后,我们可以及时将无用的Patch回退。
|
||||
|
||||
|
||||
|
||||
|
||||
我还记得曾经为了解决libhw.so的崩溃问题,我们历经一个月,一共发布了30多个动态部署,反复地增加日志、增加Hook点,最终才得以解决。
|
||||
|
||||
3. 远程控制
|
||||
|
||||
动态部署存在生效时间比较慢(几分钟到十几分钟)、无法覆盖100%用户(修改AndroidManifest或者用户手机没有剩余空间)等问题。对于一些特定问题,我们可以通过下发预设规则的方式来处理。
|
||||
|
||||
网络远程诊断是一个非常经典的例子,假如有个用户反馈某个网页无法打开,我们可以通过本地或者远程下发指令方式,对用户的整个网络请求过程做完整的检测,看看是不是用户的网络问题,是不是DNS的问题,在请求的哪个阶段出错了,错误码是什么等。
|
||||
|
||||
|
||||
|
||||
Mars里面也有一个专门的SDT网络诊断模块,我们可以顺便回顾一下Mars整个知识结构图。
|
||||
|
||||
|
||||
|
||||
除了网络的远程诊断之外,网络疑难问题的排查和跟踪本身就是一个非常大的话题。它涉及业务请求从域名解析/流量调度到业务统一接入,再到业务调用的整个访问链路,属于大网络平台的一环。
|
||||
|
||||
我们可以通过客户端生成的traceId,将统一收集和整合客户端日志、服务端调用日志、自建CDN等日志,建立以用户为维度的监控平台,提供问题定位功能 。例如Google的Dapper、阿里的EagleEye、微信的点击流平台、QQ的全链路监控平台等,都是通过这个思路实现的。
|
||||
|
||||
类似网络远程诊断,又或者是删除某些文件、上报某些信息,这些预设规则是建立在我们已经踩过某个坑,或者更多情况是已经无数次踩到同一个坑,并且忍无可忍,才会搭建一套相应的诊断规则。那我们能不能不通过动态部署,也可以简单的调用某些Java代码呢?
|
||||
|
||||
这个时候就不得不提到非常强大的Lua脚本语言,iOS之前大名鼎鼎的Wax热修复、腾讯Unity3D的热更新方案,都是使用Lua来实现。Lua的VM非常小,只有200KB不到,充分保证了时间和内存开销的可控。我们可以对目标用户下发指令,动态地执行一段代码并将结果上报,或者在方法运行的时候去获取某些对象、参数的快照信息。
|
||||
|
||||
下面是Lua和Android的调用事例。
|
||||
|
||||
// lua脚本函数
|
||||
function setText(textView)
|
||||
tv:setText("set by Lua."..s); //这里的s变量由java注入
|
||||
tv:setTextSize(30);
|
||||
end
|
||||
|
||||
// android调用
|
||||
lua.pushString( "from java" ); //压入欲注入变量的值
|
||||
lua.setGlobal( "s" ); //压入变量名
|
||||
lua.getGlobal( "setText" ); //获取Lua函数
|
||||
lua.pushJavaObject( textView ); //压入参数
|
||||
lua.pcall( 1, 0, 0 ); //执行函数
|
||||
|
||||
|
||||
对于Lua的使用,你可以参考官方文档。为了方便我们在Android更加容易的使用Lua,也有不少开源库对Lua做了更好的封装,例如AndroLua,阿里也有一套基于Lua实现的动态化界面框架LuaViewSDK。
|
||||
|
||||
美团的Holmes也利用Lua增加了DB查询、上报普通文本、ShardPreferences查询、获取Context对象、查询权限、追加埋点到本地、上传文件等综合能力。正因为Lua脚本如此强大,很多大厂App也都在Android中集成Lua。
|
||||
|
||||
总结
|
||||
|
||||
对于美团、支付宝、淘宝这些超级应用来说,不同的平台、不同的业务可能有上千人同时在一个应用上面开发协作。业务量大、多地区协作开发、业务类型多,每当出现问题都会感到耗时耗力,心力交瘁。
|
||||
|
||||
正因为反复“痛过”,才会有了微信的用户日志和点击流平台,才会有美团的Logan和Homles统一日志系统。所谓团队的“提质增效”,就是寻找团队中这些痛点,思考如何去改进。无论是流程的自动化,还是开发新的工具、新的平台,都是朝着这个目标前进。
|
||||
|
||||
课后作业
|
||||
|
||||
在你的工作中,遇到或者解决过哪些经典的疑难问题?还有哪些强大的疑难问题的排查武器?欢迎留言分享给我和其他同学。
|
||||
|
||||
无论是推送拉取用户日志,还是远程调试命令的下发,我们都需要具备区分用户的能力。对于微信这样强登录的应用,我们可以使用微信号作为用户标识。但是用户登陆之前的日志该如何收集呢?
|
||||
|
||||
对于用户唯一标识,Google有自己的最佳实践方案。对于大部分非强登录的应用,搭建自己的用户标识体系非常重要。用户唯一标识需要考虑漂移率、碰撞率以及是否跨应用等因素,业界常用的方案有阿里的UTDID、腾讯MTA ID。
|
||||
|
||||
今天的课后作业是,应用该如何实现自己的用户标识体系,请你在留言中写下自己的答案。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
99
专栏/Android开发高手课/33做一名有高度的移动开发工程师.md
Normal file
99
专栏/Android开发高手课/33做一名有高度的移动开发工程师.md
Normal file
@ -0,0 +1,99 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
33 做一名有高度的移动开发工程师
|
||||
专栏更新至今,不知不觉第二模块“高效开发”也已经更新完了。稳定性、内存、卡顿、I/O、网络,“高质量开发”模块打通了从应用层、Android系统层、Linux内核层再到硬件层的优化路径,帮助我们打通“任督二脉”,成为一名Android开发高手。
|
||||
|
||||
所谓“高效开发”,可以给我们带来了什么呢?移动互联网发展到今天,所有人都说“提质增效”,但是团队效能不是靠我们封装一个工具类或者组件,给其他人低成本复用就够了。持续交付平台、测试平台、发布平台、数据平台、网络平台…我希望你可以跳出客户端的限制,去思考整个产品的研发流程有哪些痛点,不同团队的协作有哪些优化空间,尝试去提升产品的质量和团队的效率。
|
||||
|
||||
我们需要的是多想一步,哪怕只是多思考一小步,对自身的成长可能就价值巨大。想要成为一名全面的“开发高手”,不仅要具备系统性解决应用性能和架构问题的攻坚能力,也要有从全局俯视体系和流程的思维能力。这就是我在“高效开发”里希望带给你的思考,希望你可以成为一名“站在高处”的移动开发工程师。
|
||||
|
||||
成为有高度的移动开发工程师
|
||||
|
||||
在微信的时候,我非常推崇T型技术人才理论,所谓的“T”无非就是横向和纵向两个维度。纵向解决的是深度问题,横向解决的是广度问题。
|
||||
|
||||
一个有高度的移动开发工程师,需要能纵向深入,也要能横向全面地思考每一个问题。比如说团队希望治理数据的准确性和实时性问题,如果站在客户端的角度上看,就是思考如何去实现一套数据不会丢失、实时性高以及高性能的埋点上报组件。我们知道,这里面的进程模式、存储模型、同步机制等都很复杂,要做一个高可用的上报组件确实需要具备一定的技术深度。
|
||||
|
||||
但是如果站在更高的角度上看,你会发现上报组件的优化并不能从根本上解决团队的数据问题。埋点的规范是什么?埋点的流程是什么?产品、研发、数据、测试几个团队对于数据有哪些痛点?我们需要梳理一个埋点从产品定义、客户端埋点开发、测试验证、后端数据处理、数据展示和监控的整个过程。针对团队的数据治理,我们需要体系化的思考每一个点的问题,从更高的角度去全局思考。
|
||||
|
||||
那应该如何来提升自己的高度,站在高处去思考问题呢?下面是我的一些思考。
|
||||
|
||||
1. 从终端到跨端
|
||||
|
||||
在App开发最原始的时代,为了实现代码的内部复用,我们封装了各种各样的Util工具类。随着移动互联网的发展,应用越来越多也越来越复杂,代码还需要在不同应用中复用。这个时候客户端组件呈现出爆发式增长,例如图片库中的Glide、Fresco、Picasso,组件化中Atlas、DroidPlugin、RePlugin等。
|
||||
|
||||
|
||||
|
||||
回想一下,因为当时Android系统的不成熟和不完善,反而造就了一个百花齐放的移动开发时代。在这个时代里,我们总可以找到很多优化的点,并且持续打磨。随着应用业务复杂性和要求的提升,单纯在客户端的单点优化已经满足不了业务的诉求了,比如在直播、小程序这样的复杂场景。
|
||||
|
||||
这个时候我们第一步就要跳出自身客户端的角色限制,从更为全局的角度看问题、思考问题。你需要明白,客户端的实现只是其中一小块内容而已。假如你接到一个提升页面打开速度的任务,极致优化的基础是我们能深入研究浏览器的渲染原理和缓存机制,但是前端和后端能够做些什么,又应该做些什么呢?除此之外,页面哪里产生、如何发布、发布到哪里、如何下载、如何解析、如何渲染、如何衡量和监控页面的性能,这些全部都是我们需要思考的问题。
|
||||
|
||||
|
||||
|
||||
一方面,在你的项目还没有证明它的价值之前,可能很多公司并不愿意投入很多的人力。这个时候我们只能去包办前后端,就像当年微信的日志平台、APM平台,记得还是我们用Tornado先简单搭建起来。等到这个项目证明了它的价值,才会拉更多的人参与进来。所以这就需要我们具备跨端的能力,目标是解决产品的问题,要知道客户端技术并不是唯一的选择。
|
||||
|
||||
另外一方面,针对持续交付平台、测试平台、网络平台、数据平台,很多平台客户端开发者本来就是使用方,我们应该更清楚里面的痛点是什么,有哪些可以改进的地方,所以客户端开发者应该更能主导这些平台的演进。
|
||||
|
||||
2. 从平台到中台
|
||||
|
||||
正如我上面说到的,组件化只是客户端技术最基本的抽象的体现。怎么理解呢?以性能组件为例,虽然我们收集了应用各个维度的性能数据,但是这些数据在后台如何聚合、如何存储、如何分析、如何报警,我们并没提供解决方案。
|
||||
|
||||
每个接入的应用还是要花很大的力气去搭建一整套系统,为了解决这个问题,集成式服务化的建设开始出现,比如以Google的Firebase为代表的各个开发者平台。为了解决应用不同的场景,我们不断地孵化出不同的服务平台。
|
||||
|
||||
|
||||
|
||||
移动开发早就已经过了单兵作战的年代了,客户端单点的深耕细作已经不是唯一考量的因素。有没有配套的服务、服务是不是简单易用,这些因素对于开发者来说越来越重要。特别是对于大厂来说,一个公司有几十上百个应用,对于公共业务需要避免重复劳动。国内蚂蚁的mPaaS、阿里云的EMAS移动开发者平台,都是遵循这样的服务化思路。
|
||||
|
||||
但是平台化是不是就是服务的最终形态呢?你有没有体验过这种的痛苦:一个新应用需要接入公司内部十几个不同的平台,它们的账号信息、注册信息都相互独立,很多功能我们还需要单独去跟每个平台联调测试。为了解决各个平台的割裂,在平台化的基础上,又提出了中台化的思路。
|
||||
|
||||
什么是中台呢?简单的理解就是把这些分散的平台又统一为一个超大的平台。有人会想我们是不是在开历史的倒车?还记得当年我们将一个庞大的系统分拆成各个子平台是多么的艰难。事实上,这里中台的“统一”,更多是面向开发者层面的,例如都使用同一个账号、不需要重复注册、平台之间互相闭环等。
|
||||
|
||||
关于中台的更多资料,你可以参考《从平台到中台【上】》和《从平台到中台【下】》。在国内,阿里的中台是做得最好的。当然腾讯、头条这些公司也都意识到了它的重要性,最近都在积极调整组织架构,成立了专门的中台部门。但是无论是中台还是平台,都是靠无数大大小小的优化点堆积起来得,它们都需要慢慢地积累,很难在非常短的时间内建设得非常完善。
|
||||
|
||||
热点问题答疑
|
||||
|
||||
针对高效开发,我全面介绍了目前各大公司的做法和思路。对于持续交付、测试、发布、数据、网络、日志,它们本身涉及的知识是十分庞大的,所以就正如高质量开发一样,即使专栏的内容不能完全理解,也大可不必焦虑,可以反复多读几次专栏的文章和扩展的参考资料,相信你每次学习都会有不同的收获。
|
||||
|
||||
你可以按照自己的节奏持续地学习下去,这样我相信无论是对你的视野还是对移动开发的理解,都会有很大的收获。但是在向提升高度的方向迈进时,我们或多或少都会有一些疑问,下面我就挑选几个比较重要的问题,和你进一步聊聊我的看法。
|
||||
|
||||
1. 如何提升个人的专注力和效率?
|
||||
|
||||
|
||||
|
||||
人的大脑就像CPU一样,如果频繁切换进程和线程,这个代价是非常大的。一会看一下微信,一会刷一下抖音,一会看一下头条,当我们重新切换回工作线程的时候,起码需要几分钟才能重新进入状态。
|
||||
|
||||
那靠个人的自制力能不能解决这个问题?能,但是非常遗憾的是大部分人都没有这个能力,或者说自制力不够强大。不要给自己被诱惑的机会,因为大部分人都无法承受诱惑。我的建议是,直接卸载掉这些可能影响我们工作的软件(微信可能卸载不了,这也是它强大的地方,笑)。
|
||||
|
||||
2.个人发展与公司平台的关系
|
||||
|
||||
|
||||
|
||||
可能DebugCat同学的疑问你也会有共鸣,我每天的工作就是写写界面、调调动画,面对的都是无休无止的业务,你讲的这些东西虽然高大上,但是并没有机会接触到。
|
||||
|
||||
对于小型团队来说,更多的是拿来主义,去使用一些第三方的平台。对于大型团队来说,可能你才有机会真正参与到这些平台的开发。但是也有人说在小型团队可以独当一面,但在大厂只能做一颗小小的螺丝钉。
|
||||
|
||||
我相信业务和团队这些限制因素的确客观存在,而且对我们的影响的确巨大。但是这并不是决定性的因素,你要问问自己有没有真的去努力。
|
||||
|
||||
如果你在大厂,就应该从客户端到后端,尽可能全面深入研究你参与的模块,多想想如何把你所做的模块优化到极致,并且在巨大的用户量面前依然能够稳定运行。如果你在初创团队,在业余时间也要坚持学习,持续探索自己的技术深度。这样在将来,无论是初创团队内部的晋升,还是跳到大厂,这样努力的经验都可以成为未来无数次面试、加薪的一大亮点。
|
||||
|
||||
总结
|
||||
|
||||
移动开发工程师想要真正站在高处,既需要有技术深度,又要有广度。很明显你下一个问题是:应该先钻研深度,还是扩展广度呢?
|
||||
|
||||
我建议你应该至少先在一个技术领域付出大量的精力,深入钻研透彻,然后再去思考广度的问题。这是因为经验丰富的程序员学新的东西都非常快,因为现在已经不那么容易出现太多全新的技术,所谓的新技术其实都是旧技术的重新组合和微创新。
|
||||
|
||||
成长是没有捷径的,我发现技术圈也有部分人喜欢在论坛写写文章或者出去授课,在业界可能还小有名气,受到不少人追捧。但是只要真正去大厂面试,可能就会被打回原形。我推荐你看看这篇文章,这里把里面的一句话推荐给你:
|
||||
|
||||
|
||||
老老实实看书,踏踏实实做事儿,早日兑现自己曾经吹过的牛逼。
|
||||
|
||||
|
||||
“金三银四”,最近也是找工作的高峰期。从很多同学的面试经历来看,现在只会单纯写业务代码的人找工作特别难,比如很多大厂的面试官都会针对性能优化的细节,考察你是否真正搞懂底层的机制和原理。环境的要求越来越高,所以我们也要积极转变,踏踏实实的学习。最后我也推荐你看看《程序员成长路线》,希望今天讲的这些“大道理”对你有所启发。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
|
||||
|
177
专栏/Android开发高手课/34聊聊重构:优秀的架构都是演进而来的.md
Normal file
177
专栏/Android开发高手课/34聊聊重构:优秀的架构都是演进而来的.md
Normal file
@ -0,0 +1,177 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 聊聊重构:优秀的架构都是演进而来的
|
||||
每个程序员心中都有一个成为架构师的梦想,那成为架构师这个目标是否“遥不可及”呢?从我的工作经历来看,我一共负责过搜狗输入法、微信等4款亿级产品的架构工作,可能有同学会好奇这些大型的App是如何做架构设计的。从我接手的这些应用的现实情况来看,看似光鲜的外表下都有一颗千疮百孔的心:各种日志随便输出、单例满天飞、生命周期混乱、线程乱创建、线程不安全这些问题随处可见。
|
||||
|
||||
所以你可以看到每个大型应用都背负着沉重的历史技术债务,架构师很重要的一项工作就是重构“老态龙钟”的陈旧架构。在接下来的“架构演进”模块中,我们一起学习架构该如何的重构和演进,帮助我们及时偿还这些“历史债务”。
|
||||
|
||||
虽然我们天天都在谈“架构”,那你有没有想过究竟什么是架构呢?
|
||||
|
||||
什么是架构
|
||||
|
||||
什么是架构,每个人都有自己的看法。在我看来,所谓的架构就是面对业务需求场景给出合适的解决方案,使业务能够快速迭代,从而达到“提质增效”的目标。
|
||||
|
||||
我先举个例子,告诉你什么是架构,以及架构的作用。我曾经为了解决UI渲染卡顿这个需求场景,我们设计了异步创建View、异步布局与主线程渲染这个架构。不过架构只是设计的抽象,对于具体的实现,我们可以称之为框架。好的框架可以隐藏大家不需要关心的部分,提升我们的效率。例如Facebook的Litho、微信的Vending,它们通过框架约束和异步来解决Android应用UI线程卡顿问题。
|
||||
|
||||
如果说监控是为了发现问题,核心在于“防”,那好的架构可以直接避免出现问题,所以架构设计的目标在于“治”。为了帮助你更好地理解架构,我们先从Android的架构设计说起。
|
||||
|
||||
1. Android的架构
|
||||
|
||||
在官方文档《平台架构》中,对Android的描述如下:
|
||||
|
||||
|
||||
Android是一种基于Linux的开放源代码软件栈,为广泛的设备和机型而创建。
|
||||
|
||||
|
||||
|
||||
|
||||
在深入Android架构之前,我们先来思考一下Android需要满足的需求场景,也就是各方对它的诉求。
|
||||
|
||||
|
||||
用户。在低内存的设备上也可以流畅地运行各种应用,并且有持久的续航能力。
|
||||
|
||||
开发者。应用开发简便,并且可以在平台上获得收益。
|
||||
|
||||
硬件厂商。无论是芯片厂商还是手机制造商,都可以低成本地适配和升级自己的系统。
|
||||
|
||||
|
||||
回想Android诞生之初,它为了团结一切可以团结的力量,广泛取得硬件厂商、开发者以及用户的支持。所以在做架构设计的时候,就充分考虑到了这些因素。
|
||||
|
||||
|
||||
Java API接口层。长久以来Java一直霸占第一大编程语言宝座,具有广泛的开发者基础。从当时来看,Android选择Java作为接口语言是十分明智的。从现在看,只是没有在Oracle之前抢先收购Sun是比较失策的,导致出现当前的专利诉讼困局。总的来说,开发者通过自身熟悉的Java语言,友好的接口层就可以使用系统和硬件的各种能力,快速搭建出自己的应用。
|
||||
|
||||
硬件抽象层。设备制造商需要为内核开发硬件驱动程序,为了降低设备制造商的开发成本,Android选择成熟、开源的Linux内核,并且将Android打造成一个免费、开源的操作系统,吸引了更多的手机厂商入局。但是芯片厂商和手机厂商还是需要一起做大量的工作才可以更新到最新的系统,在Android 8.0之后,Android新增了HAL硬件抽象层。它向上屏蔽了硬件的具体实现,使小米这些手机厂商可以跳过芯片厂商,单独更新Android的Framework框架。
|
||||
|
||||
应用层。在Android设计之初,它就考虑到移动设备的各种限制。为了用户在低内存设备有更好的体验,同样做了大量的工作。例如设计了基于寄存器架构、可执行文件更小的Dalvik虚拟机以及内核的Low Memory Killer等。为了满足用户的基本需求,Android系统在推出之初就内置许多的基础应用。并且通过吸引更多开发者进入,也在不断地满足用户的各种需求场景。当然,Android也在持续优化Framework和Runtime的性能,例如我前面提到过的黄油计划、耗电优化等。
|
||||
|
||||
|
||||
架构是为了需求场景服务,而Android的架构正是为了更好地满足硬件设备厂商、开发者以及用户而设计的。我非常推荐你看看《关于Android设计及其意义》和《Android技术架构演进与未来》,可以让你从Android系统设计到技术支撑系统发展有更加深刻的理解。
|
||||
|
||||
2. 如何做架构选型
|
||||
|
||||
对于Android开发者来说,很多架构和框架已经非常成熟,通常我们更多面临的问题是如何为自己的应用选择合适的框架。回顾一下专栏前面学习过的内容,其实我们已经做过一次次的选择,例如OkHttp、Cronet、Mars应该选择哪个作为我们的高质量网络库,JSON、Protocol Buffers数据序列化方案该如何选择等。
|
||||
|
||||
网络库、图片库、UI框架、消息通信框架、存储框架,无论是GitHub还是Google官方都有非常多的方案,在选择过程我们主要要考虑下面三个因素:
|
||||
|
||||
|
||||
框架的成熟度。框架是否已经被大量应用所实践,特别是亿级以上的应用。还有就是框架目前是否还在维护、框架的性能如何等。
|
||||
|
||||
工具链的成熟度。配套的工具链是否成熟、完善。例如Flutter作为一门新的技术,从开发、编译、测试到发布,是否有完善的工具链支持。
|
||||
|
||||
人员的学习成本、文档是否完备。结合团队的现状,需要考虑框架的学习成本是否可以接受、学习路径是否平滑、有没有足够的文档和社区支持。
|
||||
|
||||
|
||||
对于架构选型,康威定律是比较重要的准则,这里推荐你看看《从康威定律和技术债看研发之痛》这篇文章,我们的组织架构、代码架构以及流程都应该跟我们团队的规模相匹配。这句话怎么理解呢?就是架构设计或者架构选型不能好高骛远,我们有多大的规模,就做多少的支撑。警惕长期的事情短期做,或者短期的事情长期做。
|
||||
|
||||
微信在2013年就开始了模块化改造,与此同时淘宝则进行了组件化改造。为什么会有这样的差别呢?因为当时微信只有一个团队在开发,Android端也就30人不到。为了代码的隔离,微信将基础组件下沉,放到单独的仓库,由专门的人员负责。对于业务来说,依然只需要保留同一个仓库,只是拆成不同的业务Module。感兴趣的同学可以参考《微信Android模块化架构重构实践》。
|
||||
|
||||
|
||||
|
||||
但对于淘宝来说,当时就有几百人同时在一个应用上面开发,而且这些人分别属于不同的团队,分散在全国各地。所以无论是基于代码的权限保护,还是从开发效率的考量,都要求将所有业务模块隔离开来,也就是每个业务模块都应该是单独的仓库。
|
||||
|
||||
什么是架构演进
|
||||
|
||||
“没有过不去的坎,只有过不完的坎”。在业务发展的过程,总会遇到一些新的问题,而且可能在发展到某一时刻时,一些旧的问题就不复存在了。例如为了兼容Android 4.X,当年我们在架构上做了大量的兼容设计,但是当不再需要兼容4.X设备的时候,这些包袱我们就可以适时抛弃掉。
|
||||
|
||||
1. 为什么要做架构演进
|
||||
|
||||
架构是为了业务需求场景服务,那它也要顺应业务的变化而适时调整。也就是说,架构需要跟随业务的发展而演进。
|
||||
|
||||
“君有疾在腠理,不治将恐深”,微信每年都会经历一次大的重构,因为我们坚持代码架构最终都会腐烂,该推倒了就该重构,不要一直修修补补。架构演进可以给团队带来下面几个变化:
|
||||
|
||||
|
||||
|
||||
|
||||
打破不满。需要打破保守的做法,要积极面对不合理的地方。团队定期需要着手开启重构,将大家平日对代码的不满释放出来。将架构的腐化(效率降低、抱怨上升)转化为架构优化的动力。
|
||||
|
||||
重构信任。重构开发者之间的心态,不定期的推动模块重构。一些问题的解决,往往可以推动更多人去尝试。
|
||||
|
||||
团队培养。重构也是团队进步的机会,让更多的成员掌握架构能力,培养全员架构意识,实现“人人都是架构师”。
|
||||
|
||||
|
||||
但是对于架构演进的过程,我们需要有辨别能力,也就是常说的“技术视野”。这里包括对各种技术栈的选择和比较、架构设计的考虑,要结合业务和团队当前的情况,做出合理的判断,要清楚的知道做什么事情收益最大等。
|
||||
|
||||
这里的反面例子可能就是辛辛苦苦造了一套轮子,结果发现别人早就有了,甚至比我们做得更好。这个问题的原因就在于你的技术视野。在“高质量开发”和“高效开发”模块,我反复地跟你分享目前国内外大厂的最新实践方案,正是希望提升我们的技术视野。特别是“高效开发”模块,可能有同学会认为这些话题太大了,跟自己好像关系不大。其实应用开发流程的每一个步骤都关系到你我,同时又涉及大量的内容,每一块铺开来可能都可以是一个新的专栏。而作为“Android开发高手课”,我更想从顶层给你呈现完整的架构设计,而不是去详细分析某个细节优化点。这些都是希望可以帮你站在高处看问题,全面提升你的技术视野。
|
||||
|
||||
对于技术视野的培养,可能没有太多的捷径,需要我们经过长时间的实践,经历反反复复的挫折,才能从“巨婴”成长为“大师”。
|
||||
|
||||
|
||||
|
||||
在“架构演进”模块,为了进一步帮助我们提升技术视野和架构的能力,我准备了下面这些内容:
|
||||
|
||||
|
||||
GOT Hook、PLT Hook、Inline Hook,我将对比三种Native Hook框架的原理和差异,告诉你应该如何去选择。以及对于Native开发,我还有哪些经验。
|
||||
|
||||
大前端纷纷扰扰,如何演进和选择,如何选择适合我们应用的跨平台和动态化方案。
|
||||
|
||||
技术日新月异,对于新技术我们应该如何考虑是否跟进,Flutter是不是真的可以一统天下。
|
||||
|
||||
在应用开发之外,我们还邀请了三位专家,分别介绍他们在移动游戏、音视频和AI领域开发的经验。对于这些领域,它们的架构是什么样的,我们又该如何学习和转型。
|
||||
|
||||
|
||||
2. 如何做架构演进
|
||||
|
||||
架构演进是必要的,但是我们需要充分认识到困难,真正去做远比想要难多了,特别是其中各种各样的历史包袱问题。
|
||||
|
||||
架构的演进,通常来说具体实践方式就是重构。如果我们下定决心要重构,我有两个小建议送给你:
|
||||
|
||||
|
||||
演进式的,符合团队现状的。我们很难找到一个性能最好、成本最省、时间最快的方案,需要权衡性能、成本、时间三者的关系。如果我们时间充裕,那可以朝着更好的性能目标去努力。但如果时间紧急,我们可以分阶段去重构。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
可度量的,每个阶段都要有成果。架构演进不能一味“憋大招”,最好能分阶段实施,并且每个阶段都要有成果。这样可以让团队成员更快地感受到优化成果,也可以激励更多的人参与到重构的事业中。
|
||||
|
||||
|
||||
|
||||
|
||||
在《Android技术架构演进与未来》一文中,回顾了Android版本的发布时间线。Android系统每年都会发布一个新的版本,每个版本也会有大大小小的重构。重构的目的依然是希望更好地满足用户、开发者以及硬件厂商的诉求。例如为了提升手机的续航能力,我们可以回顾一下Android在耗电优化的演进历程。
|
||||
|
||||
|
||||
|
||||
为了应用程序执行速度更快,Android Runtime也是每个版本都会优化的模块,下面是Android Runtime各个版本的演进历程。
|
||||
|
||||
|
||||
|
||||
对于虚拟机的运行机制与各个版本的差异,也是很多公司在面试时喜欢问的。下面是一些关于虚拟机架构演进比较不错的资料,我把它们分享给你。
|
||||
|
||||
|
||||
What’s new in Android Runtime (Google I/O ‘18)
|
||||
|
||||
Android 8.0中的ART功能改进
|
||||
|
||||
oat格式演进
|
||||
|
||||
|
||||
而Android 8.0的Treble计划,引入了HAL硬件抽象层,解决了硬件厂商升级难的问题。但是即使厂商升级到最新的系统也并不能直接交付给用户,这里还存在应用兼容性的问题。为什么Android P要极力推出Hidden API的限制?这里最初考虑的并不是安全性的问题,而是为了减少每次Android版本升级的兼容性适配时间,让Android版本的发布节奏快起来。
|
||||
|
||||
Hidden API的设计也有出于架构演进的考量,Android不希望出现修改Framework内部任意一个私有方法的时候,都可能会引起外部应用兼容适配,这会对重构带来非常大的包袱。
|
||||
|
||||
Android系统如此,应用的架构演进也是如此。由于组件化带来的各种性能问题,支付宝和淘宝在架构上也顺应了这种变化。在工程结构上,它们依然保留组件在仓库上的代码隔离。但是在最终产物上,组件化已经回归模块化,非核心业务会逐渐迁移到H5或者小程序。
|
||||
|
||||
|
||||
|
||||
无论微信、支付宝、淘宝,大家都想当超级App,努力成为满足用户尽可能多需求的微型操作系统。应用的架构也需要顺应业务形态的转变,在《敏捷开发与动态更新在支付宝 App 内的实践》一文中,也描述了支付宝这几年在架构升级驱动研发方式转变,推荐你仔细读读。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
从初步接触架构设计,到基本掌握架构的精髓,可以说同样也没有捷径可言。架构设计能力的成长是建立在一个又一个坑、一次又一次的重构之上。不过成为架构师这个目标并不“遥不可及”,在日常工作中我们可以反复进行锻炼。
|
||||
|
||||
架构设计不一定是整个应用或者系统的设计,也可以是一个模块或者一个需求的设计。每接手一个需求,我们可以对自己提更高的要求,更加细致地考虑问题。例如如何对现有代码的影响最小,如何快捷清晰的实现功能,在开发过程中如何对组件、控件做更好的封装,如何去优化性能,有没有哪些新的技术可以帮助开发这个需求等。
|
||||
|
||||
课后作业
|
||||
|
||||
一个技术人的一生应该有个代表作,给自己的技术生涯一个交代。在你的工作中,有没有令你感到满意的架构设计(某个应用、某个模块或者某个框架都可以)?你对架构演进有什么看法,又遇到过哪些问题?欢迎留言分享给我和其他同学。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
296
专栏/Android开发高手课/35NativeHook技术,天使还是魔鬼?.md
Normal file
296
专栏/Android开发高手课/35NativeHook技术,天使还是魔鬼?.md
Normal file
@ -0,0 +1,296 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
35 Native Hook 技术,天使还是魔鬼?
|
||||
相信一直坚持学习专栏的同学对Hook一定不会陌生,在前面很多期里我无数次提到Hook。可能有不少同学对于Hook还是“懵懵懂懂”,那今天我们从来头了解一下什么是Hook。
|
||||
|
||||
Hook直译过来就是“钩子”的意思,是指截获进程对某个API函数的调用,使得API的执行流程转向我们实现的代码片段,从而实现我们所需要得功能,这里的功能可以是监控、修复系统漏洞,也可以是劫持或者其他恶意行为。
|
||||
|
||||
相信许多新手第一次接触Hook时会觉得这项技术十分神秘,只能被少数高手、黑客所掌握,那Hook是不是真的难以掌握?希望今天的文章可以打消你的顾虑。
|
||||
|
||||
Native Hook的不同流派
|
||||
|
||||
对于Native Hook技术,我们比较熟悉的有GOT/PLT Hook、Trap Hook以及Inline Hook,下面我来逐个讲解这些Hook技术的实现原理和优劣比较。
|
||||
|
||||
1. GOT/PLT Hook
|
||||
|
||||
在Chapter06-plus中,我们使用了PLT Hook技术来获取线程创建的堆栈。先来回顾一下它的整个流程,我们将libart.so中的外部函数pthread_create替换成自己的方法pthread_create_hook。
|
||||
|
||||
|
||||
|
||||
你可以发现,GOT/PLT Hook主要是用于替换某个SO的外部调用,通过将外部函数调用跳转成我们的目标函数。GOT/PLT Hook可以说是一个非常经典的Hook方法,它非常稳定,可以达到部署到生产环境的标准。
|
||||
|
||||
那GOT/PLT Hook的实现原理究竟是什么呢?你需要先对SO库文件的ELF文件格式和动态链接过程有所了解。
|
||||
|
||||
ELF格式
|
||||
|
||||
ELF(Executableand Linking Format)是可执行和链接格式,它是一个开放标准,各种UNIX系统的可执行文件大多采用ELF格式。虽然ELF文件本身就支持三种不同的类型(重定位、执行、共享),不同的视图下格式稍微不同,不过它有一个统一的结构,这个结构如下图所示。
|
||||
|
||||
|
||||
|
||||
网上介绍ELF格式的文章非常多,你可以参考《ELF文件格式解析》。顾名思义,对于GOT/PLT Hook来说,我们主要关心“.plt”和“.got”这两个节区:
|
||||
|
||||
|
||||
.plt。该节保存过程链接表(Procedure Linkage Table)。
|
||||
|
||||
.got。该节保存着全局的偏移量表。
|
||||
|
||||
|
||||
我们也可以使用readelf -S来查看ELF文件的具体信息。
|
||||
|
||||
链接过程
|
||||
|
||||
接下来我们再来看看动态链接的过程,当需要使用一个Native库(.so文件)的时候,我们需要调用dlopen("libname.so")来加载这个库。
|
||||
|
||||
在我们调用了dlopen("libname.so")之后,系统首先会检查缓存中已加载的ELF文件列表。如果未加载则执行加载过程,如果已加载则计数加一,忽略该调用。然后系统会用从libname.so的dynamic节区中读取其所依赖的库,按照相同的加载逻辑,把未在缓存中的库加入加载列表。
|
||||
|
||||
你可以使用下面这个命令来查看一个库的依赖:
|
||||
|
||||
readelf -d <library> | grep NEEDED
|
||||
|
||||
|
||||
下面我们大概了解一下系统是如何加载的ELF文件的。
|
||||
|
||||
|
||||
读ELF的程序头部表,把所有PT_LOAD的节区mmap到内存中。
|
||||
|
||||
从“.dynamic”中读取各信息项,计算并保存所有节区的虚拟地址,然后执行重定位操作。
|
||||
|
||||
最后ELF加载成功,引用计数加一。
|
||||
|
||||
|
||||
但是这里有一个关键点,在ELF文件格式中我们只有函数的绝对地址。如果想在系统中运行,这里需要经过重定位。这其实是一个比较复杂的问题,因为不同机器的CPU架构、加载顺序不同,导致我们只能在运行时计算出这个值。不过还好动态加载器(/system/bin/linker)会帮助我们解决这个问题。
|
||||
|
||||
如果你理解了动态链接的过程,我们再回头来思考一下“.got”和“.plt”它们的具体含义。
|
||||
|
||||
|
||||
The Global Offset Table (GOT)。简单来说就是在数据段的地址表,假定我们有一些代码段的指令引用一些地址变量,编译器会引用GOT表来替代直接引用绝对地址,因为绝对地址在编译期是无法知道的,只有重定位后才会得到 ,GOT自己本身将会包含函数引用的绝对地址。
|
||||
|
||||
The Procedure Linkage Table (PLT)。PLT不同于GOT,它位于代码段,动态库的每一个外部函数都会在PLT中有一条记录,每一条PLT记录都是一小段可执行代码。 一般来说,外部代码都是在调用PLT表里的记录,然后PLT的相应记录会负责调用实际的函数。我们一般把这种设定叫作“蹦床”(Trampoline)。
|
||||
|
||||
|
||||
PLT和GOT记录是一一对应的,并且GOT表第一次解析后会包含调用函数的实际地址。既然这样,那PLT的意义究竟是什么呢?PLT从某种意义上赋予我们一种懒加载的能力。当动态库首次被加载时,所有的函数地址并没有被解析。下面让我们结合图来具体分析一下首次函数调用,请注意图中黑色箭头为跳转,紫色为指针。
|
||||
|
||||
|
||||
|
||||
|
||||
我们在代码中调用func,编译器会把这个转化为func@plt,并在PLT表插入一条记录。
|
||||
|
||||
PLT表中第一条(或者说第0条)PLT[0]是一条特殊记录,它是用来帮助我们解析地址的。通常在类Linux系统,这个的实现会位于动态加载器,就是专栏前面文章提到的/system/bin/linker。
|
||||
|
||||
其余的PLT记录都均包含以下信息:
|
||||
|
||||
|
||||
跳转GOT表的指令(jmp *GOT[n])。
|
||||
|
||||
为上面提到的第0条解析地址函数准备参数。
|
||||
|
||||
调用PLT[0],这里resovler的实际地址是存储在GOT[2] 。
|
||||
|
||||
|
||||
在解析前GOT[n]会直接指向jmp *GOT[n]的下一条指令。在解析完成后,我们就得到了func的实际地址,动态加载器会将这个地址填入GOT[n],然后调用func。
|
||||
|
||||
|
||||
如果对上面的这个调用流程还有疑问,你可以参考《GOT表和PLT表》这篇文章,它里面有一张图非常清晰。
|
||||
|
||||
|
||||
|
||||
当第一次调用发生后,之后再调用函数func就高效简单很多。首先调用PLT[n],然后执行jmp *GOT[n]。GOT[n]直接指向func,这样就高效的完成了函数调用。
|
||||
|
||||
|
||||
|
||||
总结一下,因为很多函数可能在程序执行完时都不会被用到,比如错误处理函数或一些用户很少用到的功能模块等,那么一开始把所有函数都链接好实际就是一种浪费。为了提升动态链接的性能,我们可以使用PLT来实现延迟绑定的功能。
|
||||
|
||||
对于函数运行的实际地址,我们依然需要通过GOT表得到,整个简化过程如下:
|
||||
|
||||
|
||||
|
||||
看到这里,相信你已经有了如何Hack这一过程的初步想法。这里业界通常会根据修改PLT记录或者GOT记录区分为GOT Hook和PLT Hook,但其本质原理十分接近。
|
||||
|
||||
GOT/PLT Hook实践
|
||||
|
||||
GOT/PLT Hook看似简单,但是实现起来也是有一些坑的,需要考虑兼容性的情况。一般来说,推荐使用业界的成熟方案。
|
||||
|
||||
|
||||
微信Matrix开源库的ELF Hook,它使用的是GOT Hook,主要使用它来做性能监控。
|
||||
|
||||
爱奇艺开源的的xHook,它使用的也是GOT Hook。
|
||||
|
||||
Facebook的PLT Hook。
|
||||
|
||||
|
||||
如果不想深入它内部的原理,我们只需要直接使用这些开源的优秀方案就可以了。因为这种Hook方式非常成熟稳定,除了Hook线程的创建,我们还有很多其他的使用范例。
|
||||
|
||||
|
||||
“I/O优化”中使用matrix-io-canary Hook文件的操作。
|
||||
|
||||
“网络优化”中使用Hook了Socket的相关操作,具体你可以参考Chapter17。
|
||||
|
||||
|
||||
这种Hook方法也不是万能的,因为它只能替换导入函数的方式。有时候我们不一定可以找到这样的外部调用函数。如果想Hook函数的内部调用,这个时候就需要用到我们的Trap Hook或者Inline Hook了。
|
||||
|
||||
2. Trap Hook
|
||||
|
||||
对于函数内部的Hook,你可以先从头想一下,会发现调试器就具备一切Hook框架具有的能力,可以在目标函数前断住程序,修改内存、程序段,继续执行。相信很多同学都会使用调试器,但是对调试器如何工作却知之甚少。下面让我们先了解一下软件调试器是如何工作的。
|
||||
|
||||
ptrace
|
||||
|
||||
一般软件调试器都是通过ptrace系统调用和SIGTRAP配合来进行断点调试,首先我们来了解一下什么是ptrace,它又是如何断住程序运行,然后修改相关执行步骤的。
|
||||
|
||||
所谓合格的底层程序员,对于未知知识的了解,第一步就是使用man命令来查看系统文档。
|
||||
|
||||
|
||||
The ptrace() system call provides a means by which one process (the “tracer”) may observe and control the execution of another process (the “tracee”), and examine and change the tracee’s memory and registers. It is primarily used to implement breakpoint debugging and system call tracing.
|
||||
|
||||
|
||||
这段话直译过来就是,ptrace提供了一种让一个程序(tracer)观察或者控制另一个程序(tracee)执行流程,以及修改被控制程序内存和寄存器的方法,主要用于实现调试断点和系统调用跟踪。
|
||||
|
||||
我们再来简单了解一下调试器(GDB/LLDB)是如何使用ptrace的。首先调试器会基于要调试进程是否已启动,来决定是使用fork或者attach到目标进程。当调试器与目标程序绑定后,目标程序的任何signal(除SIGKILL)都会被调试器做先拦截,调试器会有机会对相关信号进行处理,然后再把执行权限交由目标程序继续执行。可以你已经想到了,这其实已经达到了Hook的目的。
|
||||
|
||||
如何Hook
|
||||
|
||||
但更进一步思考,如果我们不需要修改内存或者做类似调试器一样复杂的交互,我们完全可以不依赖ptrace,只需要接收相关signal即可。这时我们就想到了句柄(signal handler)。对!我们完全可以主动raise signal,然后使用signal handler来实现类似的Hook效果。
|
||||
|
||||
业界也有不少人将Trap Hook叫作断点Hook,它的原理就是在需要Hook的地方想办法触发断点,并捕获异常。一般我们会利用SIGTRAP或者SIGKILL(非法指令异常)这两种信号。下面以SIGTRAP信号为例,具体的实现步骤如下。
|
||||
|
||||
|
||||
|
||||
|
||||
注册信号接收句柄(signal handler),不同的体系结构可能会选取不同的信号,我们这里用SIGTRAP。
|
||||
|
||||
在我们需要Hook得部分插入Trap指令。
|
||||
|
||||
系统调用Trap指令,进入内核模式,调用我们已经在开始注册好的信号接收句柄(signal handler)。
|
||||
|
||||
执行我们信号接收句柄(signal handler),这里需要注意,所有在信号接收句柄(signal handler)执行的代码需要保证async-signal-safe。这里我们可以简单的只把信号接收句柄当作蹦床,使用logjmp跳出这个需要async-signal-safe(正如我在“崩溃分析”所说的,部分函数在signal回调中使用并不安全)的环境,然后再执行我们Hook的代码。
|
||||
|
||||
在执行完Hook的函数后,我们需要恢复现场。这里如果我们想继续调用原来的函数A,那直接回写函数A的原始指令并恢复寄存器状态。
|
||||
|
||||
|
||||
Trap Hook实践
|
||||
|
||||
Trap Hook兼容性非常好,它也可以在生产环境中大规模使用。但是它最大的问题是效率比较低,不适合Hook非常频繁调用的函数。
|
||||
|
||||
对于Trap Hook的实践方案,在“卡顿优化(下)”中,我提到过Facebook的Profilo,它就是通过定期发送SIGPROF信号来实现卡顿监控的。
|
||||
|
||||
3. Inline Hook
|
||||
|
||||
跟Trap Hook一样,Inline Hook也是函数内部调用的Hook。它直接将函数开始(Prologue)处的指令更替为跳转指令,使得原函数直接跳转到Hook的目标函数函数,并保留原函数的调用接口以完成后续再调用回来的目的。
|
||||
|
||||
与GOT/PLT Hook相比,Inline Hook可以不受GOT/PLT表的限制,几乎可以Hook任何函数。不过其实现十分复杂,我至今没有见过可以用在生产环境的实现。并且在ARM体系结构下,无法对叶子函数和很短的函数进行Hook。
|
||||
|
||||
在深入“邪恶的”细节前,我们需要先对Inline Hook的大体流程有一个简单的了解。
|
||||
|
||||
|
||||
|
||||
如图所示,Inline Hook的基本思路就是在已有的代码段中插入跳转指令,把代码的执行流程转向我们实现的Hook函数中,然后再进行指令修复,并跳转回原函数继续执行。这段描述看起来是不是十分简单而且清晰?
|
||||
|
||||
对于Trap Hook,我们只需要在目标地址前插入特殊指令,并且在执行结束后把原始指令写回去就可以了。但是对Inline Hook来说,它是直接进行指令级的复写与修复。怎么理解呢?就相当于我们在运行过程中要去做ASM的字节码修改。
|
||||
|
||||
当然Inline Hook远远比ASM操作更加复杂,因为它还涉及不同CPU架构带来的指令集适配问题,我们需要根据不同指令集来分别进行指令复写与跳转。
|
||||
|
||||
下面我先来简单说明一下Android常见的CPU架构和指令集:
|
||||
|
||||
|
||||
x86和MIPS架构。这两个架构已经基本没有多少用户了,我们可以直接忽视。一般来说我们只关心主流的ARM体系架构就可以了。
|
||||
|
||||
ARMv5和ARMv7架构。它的指令集分为4字节对齐的定长的ARM指令集和2字节对齐的变长Thumb/Thumb-2指令集。Thumb-2指令集虽为2字节对齐,但指令集本身有16位也有32位。其中ARMv5使用的是16位的Thumb16,在ARMv7使用的是32位的Thumb32。不过目前ARMv5也基本没有多少用户了,我们也可以放弃Thumb16指令集的适配。
|
||||
|
||||
ARMv8架构。64位的ARMv8架构可以兼容运行32位,所以它在ARM32和Thumb32指令集的基础上,增加了ARM64指令集。关于它们具体差异,你可以查看ARM的官方文档。
|
||||
|
||||
|
||||
ARM64目前我还没有适配,不过Google Play要求所有应用在2019年8月1日之前需要支持64位,所以今年上半年也要折腾一下。但它们的原理基本类似,下面我以最主流的ARMv7架构为例,为你庖丁解牛Inline Hook。
|
||||
|
||||
ARM32指令集
|
||||
|
||||
ARMv7中有一种广为流传的$PC=$PC+8的说法。这是指ARMv7中的三级流水线(取指、解码、执行),换句话说$PC寄存器总是指向正在取指的指令,而不是指向正在执行的指令。取指总会比执行快2个指令,在ARM32指令集下2个指令的长度为8个字节,所以$PC寄存器的值总是比当前指令地址要大8。
|
||||
|
||||
|
||||
|
||||
是不是感觉有些复杂,其实这是为了引出ARM指令集的常用跳转方法:
|
||||
|
||||
LDR PC, [PC, #-4] ;0xE51FF004
|
||||
$TRAMPOLIN_ADDR
|
||||
|
||||
|
||||
在了解了三级流水线以后,就不会对这个PC-4有什么疑惑了。
|
||||
|
||||
按照我们前面描述的Inline Hook的基本步骤,首先插入跳转指令,跳入我们的蹦床(Trampoline),执行我们实现的Hook后函数。这里还有一个“邪恶的”细节,由于指令执行是依赖当前运行环境的,即所有寄存器的值,而我们插入新的指令是有可能更改寄存器的状态的,所以我们要保存当前全部的寄存器状态到栈中,使用BLX指令跳转执行Hook后函数,执行完成后,再从栈中恢复所有的寄存器,最后才能像未Hook一样继续执行原先函数。
|
||||
|
||||
|
||||
|
||||
在执行完Hook后的函数后,我们需要跳转回原先的函数继续执行。这里不要忘记我们在一开始覆盖的LDR指令,我们需要先执行被我们复写的指令,然后再使用如下指令,继续执行原先函数。
|
||||
|
||||
LDR PC, [PC, #-4]
|
||||
HOOKED_ADDR+8
|
||||
|
||||
|
||||
是不是有一种大功告成的感觉?其实这里还有一个巨大的坑在等着我们,那就是指令修复。前面我提到保存并恢复了寄存器原有的状态,已达到可以继续像原有程序一样的继续执行。但仅仅是恢复寄存器就足够么?显然答案是否定的,虽然寄存器被我们完美恢复了,但是2条备份的指令被移动到了新的地址。当执行它们的时候,$PC寄存器的值是与原先不同的。这条指令的操作如果涉及$PC的值,那么它们将会执行出完全不同的结果。
|
||||
|
||||
到这里我就不对指令修复再深入解析了,感兴趣的同学可以在留言区进行讨论。
|
||||
|
||||
Inline Hook实践
|
||||
|
||||
对于Inline Hook,虽然它功能非常强大,而且执行效率也很高,但是业界目前还没有一套完全稳定可靠的开源方案。Inline Hook一般会使用在自动化测试或者线上疑难问题的定位,例如“UI优化”中说到libhwui.so崩溃问题的定位,我们就是利用Inline Hook去收集系统信息。
|
||||
|
||||
业界也有一些不错的参考方案:
|
||||
|
||||
|
||||
Cydia Substrate。在Chapter3中,我们就使用它来Hook系统的内存分配函数。
|
||||
|
||||
adbi。支付宝在GC抑制中使用的Hook框架,不过已经好几年没有更新了。
|
||||
|
||||
|
||||
各个流派的优缺点比较
|
||||
|
||||
最后我们再来总结一下不同的Hook方式的优缺点:
|
||||
|
||||
1.GOT/PLT Hook是一个比较中庸的方案,有较好的性能,中等的实现难度,但其只能Hook动态库之间的调用的函数,并且无法Hook未导出的私有函数,而且只存在安装与卸载2种状态,一旦安装就会Hook所有函数调用。
|
||||
|
||||
2.Trap Hook最为稳定,但由于需要切换运行模式(R0/R3),且依赖内核的信号机制,导致性能很差。
|
||||
|
||||
3.Inline Hook是一个非常激进的方案,有很好的性能,并且也没有PLT作用域的限制,可以说是一个非常灵活、完美的方案。但其实现难度极高,我至今也没有看到可以部署在生产环境的Inline Hook方案,因为涉及指令修复,需要编译器的各种优化。
|
||||
|
||||
|
||||
|
||||
但是需要注意,无论是哪一种Hook都只能Hook到应用自身的进程,我们无法替换系统或其他应用进程的函数执行。
|
||||
|
||||
总结
|
||||
|
||||
总的来说Native Hook是一门非常底层的技术,它会涉及库文件编译、加载、链接等方方面面的知识,而且很多底层知识是与Android甚至移动平台无关的。
|
||||
|
||||
在这一领域,做安全的同学可能会更有发言权,我来讲可能班门弄斧了。不过希望通过这篇文章,让你对看似黑科技的Hook有一个大体的了解,希望可以在自己的平时的工作中使用Hook来完成一些看似不可能的任务,比如修复系统Bug、线上监控Native内存分配等。
|
||||
|
||||
课后作业
|
||||
|
||||
今天的信息量是不是有点大?关于Native Hook,你对它有什么看法,还有哪些疑问?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
Native Hook技术的确非常复杂,即使我们不懂得它的内部原理,我们也应该学会使用成熟的开源框架去实现一些功能。当然对于想进一步深入研究的同学,推荐你学习下面这些资料。
|
||||
|
||||
|
||||
链接程序和库指南
|
||||
|
||||
程序员的自我修养:链接、装载与库
|
||||
|
||||
链接器和加载器 Linkers and Loaders
|
||||
|
||||
Linux二进制分析 Learning Linux Binary Analysis
|
||||
|
||||
|
||||
如果你对调试器的研究也非常有兴趣,强烈推荐Eli Bendersky写的博客,里面有一系列非常优秀的底层知识文章。其中一些关于debugger的,感兴趣的同学可以去阅读,并亲手实现一个简单的调试器。
|
||||
|
||||
|
||||
how-debuggers-work-part-1
|
||||
|
||||
how-debuggers-work-part-2-breakpoints
|
||||
|
||||
how-debuggers-work-part-3-debugging-information
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
229
专栏/Android开发高手课/36跨平台开发的现状与应用.md
Normal file
229
专栏/Android开发高手课/36跨平台开发的现状与应用.md
Normal file
@ -0,0 +1,229 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
36 跨平台开发的现状与应用
|
||||
在2016年,我参加了两场移动技术大会,在当时的GMTC大会上,原生开发看起来如日中天。
|
||||
|
||||
转眼到了2017年,HTML5性能越来越好,Facebook的React Native、阿里的Weex等跨平台方案在越来越多的公司中实践,微信小程序更是给了原生开发最沉重的一击,许多小公司可能不再需要开发自己的应用。
|
||||
|
||||
“Write once, run anywhere”,人们对跨平台开发的尝试从来没有停止过。特别在这个终端碎片化的时代,一份代码可以在同一个平台不同的系统版本,甚至在不同的平台上运行,对开发者的吸引力越来越大。
|
||||
|
||||
回想一下,HTML5与Native开发的斗争已经持续快十年了,那它们的现状是怎样的呢?React Native和Weex方案有哪些优势,又存在什么问题?小程序是不是真的可以一统江湖?焦虑的Native开发又应该如何在这个潮流之下谋发展呢?
|
||||
|
||||
跨平台开发的现状
|
||||
|
||||
从2017年开始,GMTC“移动技术大会”就更名为“大前端技术大会”。从现在看来,前端开发和Native开发并没有谁取代谁,而是正在融合,融合之后的产物就是所谓的“大前端”。为了顺应这种趋势,很多大公司的组织架构也做了相应的调整,把前端团队和iOS、Android一起合并为大前端团队。
|
||||
|
||||
移动Web技术、React Native和Weex、小程序,它们是目前最常用到的跨平台开发方案,下面我们一起来看看它们的应用现状。当然对于今年最为火热的Flutter技术,专栏后面我会花专门的篇幅去介绍。
|
||||
|
||||
1. Web
|
||||
|
||||
从桌面时代开始,以浏览器为载体的Web技术就具备跨平台、动态更新、扩展性强等优点。随着移动设备性能的增强,Web页面的性能也逐渐变得可以接受。客户端中出现越来越多的内嵌Web页面,很多应用也会把一些功能模块改为Web实现。
|
||||
|
||||
浏览器内核
|
||||
|
||||
一个Web页面是由HTML + CSS + JavaScript组成,通过浏览器内核执行并渲染成开发者预期的界面。
|
||||
|
||||
|
||||
|
||||
浏览器内核主要包括两大块功能,它们分别是:
|
||||
|
||||
|
||||
浏览器引擎。浏览器引擎负责处理HTML和CSS,遵照的是W3C标准。
|
||||
|
||||
JavaScript引擎。JS引擎负责处理JS,遵照的是ECMAScript标准。
|
||||
|
||||
|
||||
它们两者互相独立但又有非常紧密的结合,而且在不同浏览器内核中的实现也是不太一样的。但随着微软的Edge宣布将内核切换成Chromium,目前这个战场主要就剩下苹果和Google两个玩家,它们的浏览器引擎分别是Webkit和Blink(其实Blink也是fork自Webkit),JS引擎分别是JavaScriptCore和V8。
|
||||
|
||||
对于浏览器的渲染流程,可能很多Android开发并没有前端同学熟悉。一般来说,HTML、CSS、JS以及页面用到的一些其他资源(图片、视频、字体等)都需要从网络下载。而HTML会被解析成DOM,CSS会被解析成CSSOM,JS会由JS引擎执行,最后整合DOM和CSSOM之后合成为一棵Render Tree。
|
||||
|
||||
|
||||
|
||||
当然整个浏览器的渲染流程不是三言两语就可以说清楚的,下面是我找的一些不错的参考资料,感兴趣的同学可以深入学习:
|
||||
|
||||
|
||||
浏览器渲染:一颗像素的诞生;强烈推荐看PPT: Life of a Pixel
|
||||
|
||||
浏览器引擎:What is a browser engine?
|
||||
|
||||
浏览器系列教程:How Browsers Work: Behind the scenes of modern web browsers
|
||||
|
||||
Google Web开发者官网:Rendering on the Web
|
||||
|
||||
|
||||
虽然Chromium是开源的,但是因为它的复杂性,国内对它有深入研究的人非常少,而拥有定制修改能力的人更是少之又少。因此这块需要投入大量的人力物力,国内比较有名的是UC浏览器的U4内核以及腾讯浏览器的X5内核。
|
||||
|
||||
性能现状
|
||||
|
||||
基于WebView的H5跨平台方案,优点确实非常明显。但是性能是它目前最大的问题,主要表现在以下两个方面:
|
||||
|
||||
|
||||
启动白屏时间。WebView是一个非常重量级的控件,无论是WebView的初始化,还是整个渲染流程都非常耗时。这导致界面启动的时候会出现一段白屏时间,体验非常糟糕。
|
||||
|
||||
响应流畅度。由于单线程、历史包袱等原因,页面的渲染和JavaScript的执行效率都不如原生。在一些重交互或者动画复杂的场景,H5的性能还无法满足诉求。
|
||||
|
||||
|
||||
所以在移动端H5主要应用在一些交互不太复杂的场景,一般来说即使帧率不如原生,但也基本符合要求。从我个人的感受来看,H5当前最大的问题在于启动的白屏时间。
|
||||
|
||||
对于Android界面启动的过程,我们在窗口动画还没结束的时候,大部分时候就已经完成了页面的渲染。启动一个Activity界面,我们一般要求在300毫秒以内。
|
||||
|
||||
|
||||
|
||||
回顾一下浏览器内核渲染的流程,我们其实可以把整个过程拆成三个部分:
|
||||
|
||||
|
||||
Native时间。主要是Activity、WebView创建以及WebView初始化的时间。虽然首次创建WebView的时间会长一些,但总体Native时间是可控的。
|
||||
|
||||
网络时间。这里包括DNS、TCP、SSL的建连时间和下载主文档的时间。当解析主文档的时候,也需要同步去下载主文档依赖的CSS和JS资源,以及必要的数据。
|
||||
|
||||
渲染时间。浏览器内核构建Render Tree、Layout并渲染到屏幕的时间。
|
||||
|
||||
|
||||
|
||||
|
||||
如上图所示,我们会更加关心用户看到完整的页面的时间T2,这里可以用T2秒开率作为启动速度的衡量标准。
|
||||
|
||||
优化方法
|
||||
|
||||
Native界面的T2秒开率做到90%以上并不困难,相比之下大部分没有经过优化的Web页面的T2秒开率可能都在40%以下,差距还是非常明显的。
|
||||
|
||||
那又应该如何去优化呢?从前端的角度来看,常用的优化方法有:
|
||||
|
||||
|
||||
加快请求速度。整个启动过程中,网络时间是最不可控的。这里的优化方法有很多,例如预解析DNS、减少域名数、减少HTTP请求数、CDN分发、请求复用、懒加载、Gzip压缩、图片格式压缩。
|
||||
|
||||
代码优化。主文档的大小越小越好(要求小于15KB),这里要求我们对HTML、CSS以及JS进行代码优化。以JS为例,前端的库和框架真的太多了,可能一不小心就引入了各种的依赖框架。对于核心页面,我们要求只能使用原生JS或者非常轻量级的JS框架,例如使用只有几KB的Preact代替庞大的React框架。
|
||||
|
||||
SSR。对于浏览器的渲染流程,我上面描述的是CSR渲染模式,在这种模式下,服务器只返回页面的基本框架。事实上还有一种非常流行的SSR(Server Side Rendering)渲染模式,服务器可以一次性生成直接进行渲染的HTML。这样在T2之前,我们可以做到只有一个网络请求,但是带来的代价就是服务器计算资源的增加。一般来说,我们会在服务器前置CDN来解决访问量的问题。
|
||||
|
||||
|
||||
|
||||
|
||||
通过上面的这些优化,特别是SSR这个“终极大招”,页面的T2秒开率达到70%并不是非常困难的事情。
|
||||
|
||||
前端同学能做的都已经做了,接下来我们还可以做些什么呢?这个时候就需要客户端开发登场了。
|
||||
|
||||
|
||||
WebView预创建。提前创建和初始化WebView,以及实现WebView的复用,这块大约可以节省100~200毫秒。
|
||||
|
||||
缓存。H5是有多级的缓存机制,例如Memory Cache存放在内存中,一般资源响应回来就会放进去,页面关闭就会释放。Client Cache也就是客户端缓存,例如我们最常用的离线包方案,提前将需要网络请求的数据下发到客户端,通过拦截浏览器的资源请求实现加载。Http Cache是我们比较熟悉的缓存机制,而Net Cache就是指DNS解析结果的缓存,或预连接的缓存等。
|
||||
|
||||
|
||||
|
||||
|
||||
从性能上看,Memory Cache > Client Cache >= Http Cache > Net Cache。所谓的缓存,就是在用户真正点击打开页面之前,提前把数据、资源下载到本地内存或者磁盘中,并放到内核相应的缓存中。例如即使我们使用了SSR,也可以在用户点击之前,提前把服务器渲染好的HTML下载好,这样用户真正打开页面的时候,可以做到完全没有网络请求。
|
||||
|
||||
通过预请求的优化,即使比较复杂的页面,T2秒开率也可以达到80%以上。但是既然是预请求就会有命中率的问题,服务器也增加了没有真正命中的请求数。所以在客户端性能和服务器压力之间,我们需要找到一个平衡点。
|
||||
|
||||
那还有没有进一步优化的空间?这个时候需要我们进一步往底层走,需要我们有定制修改甚至优化内核的能力。例如很多接口官方的浏览器内核可能并没有暴露,而腾讯和UC的内核里面都会有很多的特殊接口。
|
||||
|
||||
|
||||
托管所有网络请求。我们不仅可以托管浏览器的Get请求,其他的所有Post请求也能接管,这样我们可以做非常多的定制化优化。
|
||||
|
||||
私有接口。我们可以暴露很多浏览器的一些非公开接口。以预渲染为例,我可以指定在内存直接渲染某个页面,当用户真正打开的时候,只需要直接做刷新就可以了,实现真正的“秒开”。
|
||||
|
||||
兼容性和安全。Android的碎片化导致浏览器内核的兼容性实在令人头疼,而且旧版本内核还存在不少的安全漏洞。在应用自带浏览器内核可以解决这些问题,而且高版本的内核特性也会更加完善,例如支持TLS 1.3、QUIC等。但是带来的代价是安装包增大20MB左右,当然我们也可以采用动态下载的方式。
|
||||
|
||||
|
||||
定制的自有页面 + 定制的浏览器内核 + 极致的优化,即使是比较复杂的页面T2秒开率也可以达到90%以上,平均T2时间可以做到400毫秒以下。
|
||||
|
||||
2. React Native和Weex
|
||||
|
||||
基于WebView的H5跨平台方案,经过近乎疯狂的性能优化,看起来性能真的不错了。但是对于一些交互和动画复杂的场景(例如左右滑屏、手势),性能还是无法满足要求。
|
||||
|
||||
Facebook在2015年开源了React Native,它抛弃了WebView,利用JavaScriptCore来做桥接,将JavaScript调用转为Native调用。也就是说,React Native最终会生成对应的自定义原生控件,走的是系统原生的渲染流程。
|
||||
|
||||
而阿里在2016年也开源了Weex,它的思路跟React Native很像,但是上层DSL使用的是Vue。对于Weex和React Native的架构介绍,网上的文章非常多,例如《大前端的下一站何去何从?》和《Weex技术演进》。
|
||||
|
||||
|
||||
|
||||
但是世上哪有十全十美的方案?React Native/Weex方案为了能达到接近原生开发的性能和交互体验,必然要在跨平台和动态性上面做出了牺牲。
|
||||
|
||||
React Native和Weex向上对接了前端生态,向下对接了原生渲染,看起来是非常完美的方案。但是前端和客户端,客户端中的Android和iOS,它们的差异并不那么容易抹平,强行融合就会遇到各种各样的坑。
|
||||
|
||||
“React Native从入门到放弃”是很多开发者的心声,去年Airbnb、Udacity都相继宣布放弃使用React Native。React Native/Weex并没有彻底解决跨平台的问题,而且考虑到对外分享和降级容灾的需要,我们依然需要开发一个H5版本的页面。
|
||||
|
||||
为了解决这个问题,React Native的使用者需要引入一层非常重的中间层,期望在这个中间层中帮助我们去抹平这些差异。例如京东的JDReact、携程的Ctrip React Native。
|
||||
|
||||
|
||||
|
||||
既然React Native和Weex在跨平台上面做了牺牲,那它的性能和交互是不是能直接对齐Native开发呢?非常遗憾, 目前它们的性能我觉得主要还有两个瓶颈。
|
||||
|
||||
|
||||
JS的执行时间。React Native和Weex使用的JavaScriptCore引擎,虽然它每年都在进步,但是JS是解释性的动态语言,它的执行效率相比AOT编译后的Java,性能依然会在几倍以上的差距。
|
||||
|
||||
跨语言的通信成本。既然要对接前端和原生两个生态,就无法避免JS -> C++ -> Java/Objective-C 之间频繁的通信和转换,所以这里面会涉及各种序列化,对性能的影响比较大。
|
||||
|
||||
|
||||
虽然相比H5方案在性能方面有了很大的提升,但是React Native和Weex也要面对启动时间慢、帧率不如原生的性能问题。它属于一种比较中庸的方案,当然也会有自己的应用场景。例如一些二级页面(例如淘宝的分会场),它们的业务也比较重要,但是交互不会特别复杂,同时希望保持一定的动态化能力。
|
||||
|
||||
当然,Facebook已经意识到React Native的种种性能问题,目前正在疯狂重构中,希望让React Native更加轻量化、更适应混合开发,接近甚至达到原生的体验。Facebook现在透漏的信息并不多,感兴趣的同学可以参考《庖丁解牛!深入剖析React Native下一代架构重构》。
|
||||
|
||||
3. 小程序
|
||||
|
||||
2017年初,张小龙宣布微信小程序诞生。如今小程序已经走过了两年,在这两年间,小程序的生态也在健康的发展。
|
||||
|
||||
每一个应用都有成为超级App的梦想,各个大厂纷纷推出自己的小程序框架:微信、厂商、支付宝、今日头条、百度、淘宝、Google Play,小程序这个战场已然是“七国大乱战”。
|
||||
|
||||
|
||||
|
||||
但是小程序并不属于一种跨平台开发方案,大家更看重的是它的渠道优势,考虑如何通过微信、支付宝这些全民App获得更多的流量和用户。从技术上看,小程序的框架技术也是开放的,我们可以采用H5方案,也可以采用React Native和Weex,甚至是Flutter。
|
||||
|
||||
从实践上看,我们一起来看看已经正式上线的微信小程序、快应用、支付宝小程序以及百度小程序的差异(技术方面大家公开得并不多,可以参考《支付宝小程序框架》)。
|
||||
|
||||
|
||||
|
||||
我们可以看到除了独树一帜的快应用,其他小程序的技术方案基本都跟随了微信。但是考虑到H5在一些场景的性能问题,利用浏览器内核提供的同层渲染能力,在WebView之上支持一些原生的控件。如果哪一天微信小程序支持了所有的原生控件,那也就成为了另外一套React Native/Weex方案。
|
||||
|
||||
“神仙打架,百姓遭殃”,如果我们想从所有的小程序厂商上面获得流量,那就要开发七个不同的小程序。不过幸运的是,支付宝小程序和快应用也希望已有的微信小程序能快速迁移到自己平台上,所以它们的DSL设计都参考了微信的语法,可以说微信推出的DSL已然成为了事实标准。
|
||||
|
||||
|
||||
|
||||
如上图所示,我们希望有一套可以整合所有小程序框架的解决方案,一次开发就可以生成不同的小程序。滴滴的Chameleon和京东的Taro都致力于解决这个问题,目前它们都已经在GitHub上开源。
|
||||
|
||||
跨平台开发的应用
|
||||
|
||||
从移动开发诞生之初,跨平台就已经是大家前赴后继不断追求的目标。我们可以看看nwind在2015年写的一篇文章《聊聊移动端跨平台开发的各种技术》。如今四年过去了,大部分观点依然成立,并且从最后Dart的介绍中,我们甚至可以看到现在Flutter的雏形。
|
||||
|
||||
1. 跨平台开发的场景
|
||||
|
||||
Android、iOS、PC,不同的平台人们的操作习惯、喜好都不尽相同。对于大公司来说,完全的跨平台开发可能是一个伪命题,不同的平台应用的UI和交互都不太一样。
|
||||
|
||||
那我们对跨平台苦苦追寻了那么多年,希望得什么呢?以我的经验来看,跨平台主要的应用场景有:
|
||||
|
||||
|
||||
部分业务。某个业务或者页面的跨平台共享,有的时候我们还希望可以做到跨应用。例如“全民答题”的时候,可以看到这个功能可以运行在头条系的各个应用中。一个公司共用同一套跨平台方案有非常重大的意义,业务可以在不同的应用中尝试。
|
||||
|
||||
核心功能。C++才是生命力最顽强的跨平台方案,大公司也将越来越多的核心模块往底层迁移,例如网络库、数据上报、加解密、音视频等。
|
||||
|
||||
|
||||
2. 跨平台开发对比
|
||||
|
||||
H5的跨平台方案只要投入不太高的开发成本,就能开发出性能、功能还不错的应用。但是如果想做到极致优化,很容易发现开发者可控的东西实在比较少,性能和功能都依赖浏览器的支持。
|
||||
|
||||
这个时候如果想走得更远,我们不仅需要了解浏览器的内部机制,可能还需要具备定制、修改浏览器内核的能力,这也是阿里、腾讯、头条和百度都要组建内核团队的原因。
|
||||
|
||||
原生开发则相反,刚开始要投入很高的开发成本,但是一旦开始有产出之后,开发者能够有更的发挥空间,而React Native和Weex方案更是希望打造兼顾跨平台、开发成本以及性能的全方位解决方案。
|
||||
|
||||
|
||||
|
||||
从目前来看,每一种方案都有着自己的使用场景,无论是React Natve还是H5,都无法完全取代Native开发。当然这里也有一个例外,那就是如果我们不再开发应用,全面投向小程序。小程序跟原生开发的竞争,更多的是在渠道层面的竞争。
|
||||
|
||||
总结
|
||||
|
||||
现在好像有个观点说“Android开发没人要”,大家都想转去做大前端开发,是不是真的是这样呢?事实上,无论我们使用哪一种跨平台方案,它们最终都要运行在Android平台上。崩溃、内存、卡顿、耗电这些问题依然存在,而且可能会更加复杂。而且从H5极致体验优化的例子来看,很多优化是需要深入研究平台特性和系统底层机制,我们在“高质量开发”中学到的底层和系统相关的知识依然很重要。
|
||||
|
||||
对开发者来说,唯一不变的就是学习能力。掌握了学习能力和钻研的精神,就能够应对这些趋势变化。无论移动开发未来如何变化,哪怕有一天AI真的能够自动写代码,具备应变能力的人也丝毫不会惧怕的。
|
||||
|
||||
课后作业
|
||||
|
||||
跨平台开发也是一个很大很大的话题,今天我只能算是抛砖引玉。对于跨平台开发,你有什么看法?在你的应用中,使用了哪种跨平台开发方式?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
175
专栏/Android开发高手课/37移动开发新大陆:工作三年半,移动开发转型手游开发.md
Normal file
175
专栏/Android开发高手课/37移动开发新大陆:工作三年半,移动开发转型手游开发.md
Normal file
@ -0,0 +1,175 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 移动开发新大陆:工作三年半,移动开发转型手游开发
|
||||
|
||||
你好,我是张绍文。15年认识庆文的时候,他还在微信读书负责Android端的性能优化工作。某一天,他跟我说想转岗去尝试一下游戏开发,当时我脑海里浮现了两个想法,一个是游戏部门传说中60个月的年终奖,看起来游戏是一个非常有“钱途”的方向;另外一个还是担忧,抛弃掉Android开发多年的积累,转去完全不熟悉的游戏领域,他是否可以胜任。
|
||||
|
||||
两年过去了,庆文以他的个人经历亲身证明了两件事情:-
|
||||
第一,游戏开发并没有想象中那么困难。当年他是Android版微信读书的技术核心,如今在新的岗位上依然也是技术核心。“一通则百通”,技术是相通的,最珍贵的是我们的学习能力和钻研精神。-
|
||||
第二,客户端平台知识依然非常重要。手游虽然有非常独立的开发体系,但是它还是运行在Android或者iOS系统之上。App开发需要用到的很多技术,游戏开发也需要用到。作为Android开发,我们一层一层往底层走的优化能力,反而是其他大部分游戏开发所不具备的,也是我们的优势所在。
|
||||
|
||||
在我看来,无论移动的未来如何发展,不管是大前端的天下,还是转向手游、IoT、AI、音视频等其他方向,今天我们所熟悉的Android平台知识以及学习这些知识所沉淀下来的能力和方法,都是最宝贵的财富。下面我们一起来看看庆文同学的移动开发转型手游开发的那些事。
|
||||
|
||||
|
||||
你好,我是李庆文,来自腾讯旗下一间手游工作室,应绍文的邀请,在《Android开发高手课》里和你聊聊手游开发那些事。
|
||||
|
||||
我在2017年4月,从App客户端开发转岗成为一名“用命创造快乐”的手游客户端开发。在转为手游开发之前,虽然玩游戏会被游戏精美的画面吸引,但是并不清楚游戏是如何将这些精细的画面呈现出来,觉得游戏开发好像一个神秘的组织一样,不知道游戏开发团队的同学每天在做什么。如果你也对游戏开发有些好奇,不知道是不是也有我之前同样的困惑?现在我以一个亲历者的身份,跟你讲讲转向手游开发的那事儿。
|
||||
|
||||
揭开手游开发的面纱
|
||||
|
||||
一般来说,一个手游项目组包括制作人、策划组、美术组、运营组、程序组、音频组。其中游戏核心部分完全由项目组自己完成,部分美术、音频可以交给外包来完成。
|
||||
|
||||
策划组分为玩法策划、数值策划,是整个游戏的灵魂,一个游戏的核心玩法是否好玩、周边系统能否更好地支撑核心玩法,是整个游戏成败的关键。有些团队也会把运营组放到策划组当中,运营同学主要的工作职责是外部合作、策划游戏内的活动、根据活动数据对游戏玩法或者活动进行相应调整,希望可以尽量延长游戏生命周期并提高游戏收入。
|
||||
|
||||
美术组包括原画师、视觉设计、2D/3D视觉特效设计,其中负责视觉特效的同学与负责程序的同学沟通较多,主要是因为程序需要与动画配合,比如某个动画播放到某一个关键帧之后,程序要处理某个特定逻辑。
|
||||
|
||||
App开发的程序组同学关注的更多是手机系统的架构,希望尽量多了解系统能为App提供的接口或者能力。而手游的程序组同学更多是关注游戏引擎的架构和如何在引擎的基础上优化游戏性能。
|
||||
|
||||
下面我以Cocos引擎为例,带你看看手游的开发流程以及手游是如何运行的。
|
||||
|
||||
1. 游戏架构
|
||||
|
||||
以我的游戏项目为例,游戏整体架构如下图所示。
|
||||
|
||||
|
||||
|
||||
其中:
|
||||
|
||||
|
||||
资源更新模块让游戏能够不断发布Bugfix和Features。
|
||||
|
||||
配置系统提供给策划和运营同学,主要是编辑XLS文件用于配置游戏内的关卡、活动等。策划同学的配置会在游戏编译过程中自动转表,根据配置文件生成Lua文件。
|
||||
|
||||
Services管理着游戏内的全部数据。
|
||||
|
||||
网络模块负责与后台建立Socket并通信。
|
||||
|
||||
游戏内使用MVC模型控制各个游戏内模块的跳转。
|
||||
|
||||
在单个功能模块中,逻辑与动画分离,逻辑层产生动画事件,动画层消费动画事件并让游戏内的精灵根据事件执行特定动作。
|
||||
|
||||
|
||||
2. 游戏场景设计
|
||||
|
||||
Cocos采用节点树形结构来管理游戏对象,一个游戏可以划分为不同的场景(Scene),一个场景又可以分为不同的层(Layer),一个层又可以拥有任意个精灵(Sprite)。游戏里关卡、周边系统的切换也就是一个一个场景的切换,就像在电影中变换舞台和场地一样,由导演(Director)负责不同游戏场景之间的切换。一个Cocos游戏的基本结构如下:
|
||||
|
||||
|
||||
|
||||
在同一个场景中的多个精灵可以通过执行动作(Action)来实现缩放、移动、旋转,从而达到让游戏“动起来”的目的。
|
||||
|
||||
Cocos引擎中,坐标系X轴向右、Y轴向上,Z轴(Z-Order)垂直于手机屏幕指向屏幕外,由于是2D游戏,所以Z轴只用作控制游戏元素的前后顺序。同一个场景中两个重叠或者部分重叠的 Sprite,Z-Order大的精灵会遮挡住Z-Order小的精灵,相同Z-Order的精灵,后添加到场景中的会遮挡住先添加的。
|
||||
|
||||
|
||||
|
||||
有了树形结构和Z-Order,就能利用Cocos引擎构建出任意2D游戏场景了。
|
||||
|
||||
3. 手游的运行流程
|
||||
|
||||
游戏的主Activity在onCreate时候创建一个GLSurfaceView,它继承自View,是引擎用于绘制游戏内容的。同时GLSurfaceView也接受玩家的点击事件,用于引擎与玩家的交互。
|
||||
|
||||
GLSurfaceView创建之后,引擎开始执行main.lua脚本,导演调用runWithScene接口,游戏就进入到第一个“场景”中了。GLSurfaceView中维护了一个while循环,循环中发生的事件(比如SurfaceView创建、宽高发生变化等)通过Renderer接口传递给外部。Renderer其中一个接口就是onDrawFrame,Cocos会在onDrawFrame接口实现中根据游戏设置的帧率,每间隔一段事件通知一次导演执行一帧游戏循环。
|
||||
|
||||
|
||||
|
||||
借用《我所理解的Cocos2d-x》书中的单帧处理流程图片,你可以看到在每帧中:
|
||||
|
||||
|
||||
先响应用户输入,因为用户输入可能影响到当前帧接下来的游戏逻辑。
|
||||
|
||||
根据每个精灵设置的动画,计算精灵的位置、缩放等属性。
|
||||
|
||||
执行游戏逻辑,比如更新玩家得分、修改当前游戏状态等。在这里开发者依然可以更改精灵的各种属性。
|
||||
|
||||
遍历UI树,根据之前对精灵属性的设置,生成绘制命令,交给OpenGL绘制,最后把渲染的内容显示到屏幕上。
|
||||
|
||||
|
||||
游戏与App开发的异同
|
||||
|
||||
1.关于热更新
|
||||
|
||||
热更新是App开发和游戏开发都避不开的话题,但是App的热更新与游戏的热更新有着本质的区别。App开发的热更新涉及整个系统层面的实现细节,比如我在老东家工作期间实现的热更新框架,需要了解的内容非常多,包括Dex文件的加载过程、SO文件查找过程和APK编译过程的详细细节,每个不小心都可能导致热更新不生效甚至导致严重的外网Bug。
|
||||
|
||||
然而使用脚本的游戏引擎是天生支持热更新的。手游热更新,也叫作资源更新,更新内容包括代码、纹理图片、界面等。
|
||||
|
||||
以Cocos引擎为例,Cocos引擎的核心是C++实现的,对外提供了JS、Lua接口。业务开发过程中绝大部分代码是Lua代码,只有涉及系统相关接口,比如支付、音频播放、WebView等,才需要写一部分系统相关的代码。Lua代码经过编译之后生成的二进制代码是“.luac”文件,并通过luaL_loadbuffer接口来加载一段“.luac”的文件内容。Cocos引擎在初始化过程中会设置“代码查找路径”,加载Lua代码的时候会挨个遍历每一个被设置进来的路径,直到找到或者找不到对应的“.luac”文件。而热更新只需要下载“.luac”文件,放到优先的查找路径中,游戏重启之后引擎就会优先加载新的代码啦。
|
||||
|
||||
当然还有另外一种更为激进的方式,不需要重启游戏就能达到热更新的目的。
|
||||
|
||||
Cocos以LuaEngine为入口,执行Lua代码驱动C++部分的图形库、音频库、物理引擎等。LuaEngine缓存了通过luaL_loadbuffer接口加载起来的代码,只要重启整个LuaEngine清理掉缓存的代码,就能在后续需要执行代码的时候去重新加载代码文件。这样就能做到不重启游戏也能达到真正“热”更新的目的。
|
||||
|
||||
其实Unity引擎也是类似,可以使用xLua等类似的技术,让Unity引擎也能开心地写Lua脚本。
|
||||
|
||||
对于热更新图片,只需要清理掉引擎缓存的图片缓存即可。这样引擎在下次使用该图片渲染界面是,发现在缓存中查找不到该图片,自然就会去加载新的图片了。
|
||||
|
||||
2. 游戏也需要优化安装包大小
|
||||
|
||||
在App开发过程中,优化安装包的大小是一个不可避免的话题。压缩图片、代码,插件动态下载等众多实现方案也都为大家所熟知。手游安装包大小可能并不像App安装包要求那么严格,热门游戏比如《绝地求生》《王者荣耀》,安装包大小几乎都接近2GB。虽然玩家对游戏安装包大小不是特别在意,但有意识的减小安装包大小也是很有必要的,毕竟安装包大小会影响游戏转化率,进而影响游戏收入。
|
||||
|
||||
手游安装包中,图片资源占了安装包体积的绝大部分,以PNG图片为主。通常直接压缩PNG图片是有损压缩,经过游戏引擎渲染,游戏界面会出现很多噪点。这里有一种分离Alpha通道的压缩方案,可以供你参考。32位透明PNG图片包含了四个通道:RGBA,其中每个通道占8 Bit。把RGBA四个通道中的Alpha通道数据拆分出来存储为一张PNG8图片,剩下的RGB数据存储为一张JPG格式图片。JPG格式的图片在保证高压缩率的同时也能保证图片质量,以此达到压缩图片大小的目的。这种方案的压缩率大概在70%左右。
|
||||
|
||||
在游戏运行过程中,使用纹理之前先把JPG和PNG8文件都读取到内存,将他们包含的RGB和Alpha数据重新合并后,交给OpenGL用于渲染。这个压缩方案简单易操作,并且运行时不需要额外的第三方库支持。
|
||||
|
||||
3. 游戏的性能优化
|
||||
|
||||
性能是开发者永远离不开的话题。手游和App开发一样,也需要关注游戏的各方面性能指标,比如内存、帧率等。
|
||||
|
||||
内存
|
||||
|
||||
|
||||
纹理压缩,减少纹理占用的内存。我前面提到的安装优化包大小中压缩图片,也有助于减少内存占用。在美术同学能接受的前提下,降低图片深度也是快速优化内存的一个方法。
|
||||
|
||||
使用精灵池,尽量复用已经创建的精灵,减少屏幕中精灵创建的个数,及时回收不用的内存。
|
||||
|
||||
切换场景时尽快释放上一个场景的无用纹理。
|
||||
|
||||
|
||||
帧率
|
||||
|
||||
|
||||
减少Draw Call。游戏引擎每次准备数据并发送给GPU去渲染的过程,称为一次Draw Call。Cocos把使用相同贴图、blendFunc、Shader的精灵合并到同一个渲染命令中提交给GPU。我们常说的合图就是把很多小图片合并到一张大图片中,这样当屏幕中有多个连续渲染的精灵用到同一个大图,CPU就会(有可能)把它们合并之后统一提交给GPU。另外Cocos合并渲染命令是根据精灵在场景中的顺序来做的,最终的合并效果不一定是最优的。我们可以实现自己更优化的合并逻辑。
|
||||
|
||||
减少屏幕内需要渲染的精灵个数。如果屏幕中有“成组”的元素需要绘制,比如排行榜中每个玩家的Item,在Cocos中可以使用CCRenderTexture,把每个“成组”的元素绘制到CCRenderTexture绑定的纹理,然后用CCRenderTexture替代原有的Item。这与Android开发中自己实现View的onDraw函数类似,并且这也是减少Draw Call的一种方式。
|
||||
|
||||
分帧。真的遇到CPU密集型的任务时,例如同屏幕确实需要大量精灵,可以把它们拆分在不同帧里,尽量保证游戏内每一帧在规定时间内完成,否则会出现卡帧现象。
|
||||
|
||||
|
||||
App开发转手游开发的思考
|
||||
|
||||
刚刚过去的2018年是游戏比较惨淡的一年,游戏版号从暂停审批到现在终于慢慢开始放出,简直是千军万马过独木桥。但从目前来看,2018年确实也像某些大咖说的那样,可能是游戏行业未来几年最好的一年。国内拿不到版号的游戏只能考虑出海来寻求出路,但是出海需要在海外当地运营或者找海外代理。自己运营可能会遇到水土不服,画风或者游戏内容也可能不满足当地玩家的需求;找海外代理,却可能面临连游戏代码都被代理商卖掉的风险。
|
||||
|
||||
手游早已经结束了野蛮生长的年代,6年前可能随便一款游戏都可能成为爆款,类似捕鱼达人、保卫萝卜,它们诞生在Android、iOS手机爆发的那几年。而现在的游戏玩家需要的是更新颖的玩法和更加精细的游戏体验。例如MOBA类的《王者荣耀》、SLG类的《乱世王者》,甚至细分领域的《恋与制作人》,也都在各自的玩法上不断打磨,让玩家心甘情愿“氪金”。如果想要转向这个赢者通吃的行业,确实需要谨慎,提前考察好目标团队是否符合你的职业发展、是否有盈利能力,尽量避免踩坑。
|
||||
|
||||
1. App开发转手游开发,可行吗?
|
||||
|
||||
2018年和2019年,市场对App开发的岗位需求量已经不像几年前那么大,而且也有一些同学也咨询过我App开发转手游开发是否合适。关于这个问题,我的回答是:可以转到手游开发岗位,而且难度并没有想象中那么大。
|
||||
|
||||
手游开发跟App开发相比,只不过是换了工作内容,关注的领域不同而已,基本原理是相通的。App开发需要用到的很多技术,游戏开发依然也需要用到,比如卡顿分析、网络、I/O优化。而且游戏的大部分核心逻辑库是与平台无关的,所以不必担心适应不了新的工作内容。下面我来总结一下转到手游开发的优势和劣势。
|
||||
|
||||
|
||||
优势
|
||||
|
||||
|
||||
对手机系统有深入了解,能迅速切入一部分游戏团队内的工作内容。比如我刚刚转到手游团队时解决的第一个问题:游戏在Android系统上JNI找不到函数的Crash稳稳占据了每个版本的前三名,版本Crash率由于这个问题而一直保持在1%左右。当时由于团队对系统没有深入了解,所以并没有很好的解决办法。这个问题其实读一遍Android系统查找Native函数的代码,基本就能找到解决方案了。
|
||||
|
||||
|
||||
劣势
|
||||
|
||||
|
||||
毕竟是两个不同的开发方向,转方向的同学也需要迎接压力。与毕业后直接进入游戏行业的同学相比,“半路出家”的同学相对缺乏原始技术积累,需要从基础的概念开始学起,比如上面提到的Draw Call、合图、OpenGL、Shader,需要消耗大量精力去学习。所以需要转方向的同学足够自律,主动补齐这些技术短板。
|
||||
|
||||
2. 转方向后的技术路线
|
||||
|
||||
有些同学说App开发者的技术路线宽,担心手游开发者将来技术路线会越来越窄,将来不好找其他工作。有这个担心是正常的,手游开发岗位的市场需求量比移动开发需求量小太多,但这不应该成为害怕转手游开发的理由。个人觉得需要从一个更宏观的角度看待这两个不同的开发岗位,不要钻在技术的牛角尖里无法自拔。技术通道无风险,而更应该担心的是游戏的政策风险。
|
||||
|
||||
小游戏在2017年和2018年真是火了一把,鉴于小游戏入门简单、上手快,想要转方向的同学可以先用小游戏来练手。“麻雀虽小,五脏俱全”,看明白一个小游戏的执行流程之后,相信你对游戏开发也就会有一个感性的认识了。
|
||||
|
||||
最后祝各位开发者游戏愉快!
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
198
专栏/Android开发高手课/38移动开发新大陆:Android音视频开发.md
Normal file
198
专栏/Android开发高手课/38移动开发新大陆:Android音视频开发.md
Normal file
@ -0,0 +1,198 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
38 移动开发新大陆:Android音视频开发
|
||||
|
||||
你好,我是张绍文。俊杰目前负责微信的音视频开发工作,无论是我们平常经常使用到的小视频,还是最新推出的即刻视频,都是出自他手。俊杰在音视频方面有非常丰富的经验,今天有幸请到他来给我们分享他个人对Android音视频开发的心得和体会。
|
||||
|
||||
在日常生活中,视频类应用占据了我们越来越多的时间,各大公司也纷纷杀入这个战场,不管是抖音、快手等短视频类型,虎牙、斗鱼等直播类型,腾讯视频、爱奇艺、优酷等长视频类型,还是Vue、美拍等视频编辑美颜类型,总有一款适合你。
|
||||
|
||||
未来随着5G普及以及网络资费的下降,音视频的前景是非常广阔的。但是另一方面,无论是音视频的编解码和播放器、视频编辑和美颜的各种算法,还是视频与人工智能的结合(AI剪片、视频修复、超清化等),它们都涉及了方方面面的底层知识,学习曲线比较陡峭,门槛相对比较高,所以也造成了目前各大公司音视频相关人才的紧缺。如果你对音视频开发感兴趣,我也非常建议你去往这个方向尝试,我个人是非常看好音视频开发这个领域的。
|
||||
|
||||
当然音视频开发的经验是靠着一次又一次的“填坑”成长起来的,下面我们一起来看看俊杰同学关于音视频的认识和思考。
|
||||
|
||||
|
||||
大家好,我是周俊杰,现在在微信从事Android多媒体开发。应绍文的邀请,分享一些我关于Android音视频开发的心得和体会。
|
||||
|
||||
不管作为开发者还是用户,现在我们每天都会接触到各种各样的短视频、直播类的App,与之相关的音视频方向的开发也变得越来越重要。但是对于大多数Android开发者来说,从事Android音视频相关的开发可能目前还算是个小众领域,虽然可能目前深入这个领域的开发者还不是太多,但这个方向涉及的知识点可一点都不少。
|
||||
|
||||
音视频的基础知识
|
||||
|
||||
1. 音视频相关的概念
|
||||
|
||||
说到音视频,先从我们熟悉也陌生的视频格式说起。
|
||||
|
||||
对于我们来说,最常见的视频格式就是MP4格式,这是一个通用的容器格式。所谓容器格式,就意味内部要有对应的数据流用来承载内容。而且既然是一个视频,那必然有音轨和视轨,而音轨、视轨本身也有对应的格式。常见的音轨、视轨格式包括:
|
||||
|
||||
|
||||
视轨:H.265(HEVC)、H.264。其中,目前大部分Android手机都支持H.264格式的直接硬件编码和解码;对于H.265来说,Android 5.0以上的机器就支持直接硬件解码了,但是对于硬件编码,目前只有一部分高端芯片可以支持,例如高通的8xx系列、华为的98x系列。对于视轨编码来说,分辨率越大性能消耗也就越大,编码所需的时间就越长。
|
||||
|
||||
音轨:AAC。这是一种历史悠久音频编码格式,Android手机基本可以直接硬件编解码,几乎很少遇到兼容性问题。可以说作为视频的音轨格式,AAC已经非常成熟了。
|
||||
|
||||
|
||||
对于编码本身,上面提到的这些格式都是有损编码,因此压缩编码本身还需要一个衡量压缩之后,数据量多少的指标,这个标准就是码率。同一个压缩格式下,码率越高质量也就越好。更多Android本身支持的编解码格式,你可以参考官方文档。
|
||||
|
||||
小结一下,要拍摄一个MP4视频,我们需要将视轨 + 音轨分别编码,然后作为MP4的数据流,再合成出一个MP4文件。
|
||||
|
||||
2. 音视频编码的流程
|
||||
|
||||
接下来,我们再来看看一个视频是怎么拍摄出来的。首先,既然是拍摄,少不了跟摄像头、麦克风打交道。从流程来说,以H.264/AAC编码为例,录制视频的总体流程是:
|
||||
|
||||
|
||||
|
||||
我们分别从摄像头/录音设备采集数据,将数据送入编码器,分别编码出视轨/音轨之后,再送入合成器(MediaRemuxer或者类似mp4v2、FFmpeg之类的处理库),最终输出MP4文件。接下来,我主要以视轨为例,来介绍下编码的流程。
|
||||
|
||||
首先,直接使用系统的MediaRecorder录制整个视频,这是最简单的方法,直接就能输出MP4文件。但是这个接口可定制化很差,比如我们想录制一个正方形的视频,除非摄像头本身支持宽高一致的分辨率,否则只能后期处理或者各种Hack。另外,在实际App中,除非对视频要求不是特别高,一般也不会直接使用MediaRecorder。
|
||||
|
||||
视轨的处理是录制视频中相对比较复杂的部分,输入源头是Camera的数据,最终输出是编码的H.264/H.265数据。下面我来介绍两种处理模型。
|
||||
|
||||
|
||||
|
||||
第一种方法是利用Camera获取摄像头输出的原始数据接口(例如onPreviewFrame),经过预处理,例如缩放、裁剪之后,送入编码器,输出H.264/H.265。
|
||||
|
||||
摄像头输出的原始数据格式为NV21,这是YUV颜色格式的一种。区别于RGB颜色,YUV数据格式占用空间更少,在视频编码领域使用十分广泛。
|
||||
|
||||
一般来说,因为摄像头直接输出的NV21格式大小跟最终视频不一定匹配,而且编码器往往也要求输入另外一种YUV格式(一般来说是YUV420P),因此在获取到NV21颜色格式之后,还需要进行各种缩放、裁剪之类的操作,一般会使用FFmpeg、libyuv这样的库处理YUV数据。
|
||||
|
||||
最后会将数据送入到编码器。在视频编码器的选择上,我们可以直接选择系统的MediaCodec,利用手机本身的硬件编码能力。但如果对最终输出的视频大小要求比较严格的话,使用的码率会偏低,这种情况下大部分手机的硬件编码器输出的画质可能会比较差。另外一种常见的选择是利用x264来进行编码,画质表现相对较好,但是比起硬件编码器,速度会慢很多,因此在实际使用时最好根据场景进行选择。
|
||||
|
||||
除了直接处理摄像头原始数据以外,还有一种常见的处理模型,利用Surface作为编码器的输入源。
|
||||
|
||||
|
||||
|
||||
对于Android摄像头的预览,需要设置一张Surface/SurfaceTexture来作为摄像头预览数据的输出,而MediaCodec在API 18+之后,可以通过createInputSurface来创建一张Surface作为编码器的输入。这里所说的另外一种方式就是,将摄像头预览Surface的内容,输出到MediaCodec的InputSurface上。
|
||||
|
||||
而在编码器的选择上,虽然InputSurface是通过MediaCodec来创建的,乍看之下似乎只能通过MediaCodec来进行编码,无法使用x264来编码,但利用PreviewSurface,我们可以创建一个OpenGL的上下文,这样所有绘制的内容,都可以通过glReadPixel来获取,然后再讲读取数据转换成YUV再输入到x264即可(另外,如果是在GLES 3.0的环境,我们还可以利用PBO来加速glReadPixles的速度)。
|
||||
|
||||
|
||||
|
||||
由于这里我们创建了一个OpenGL的上下文,对于目前的视频类App来说,还有各种各样的滤镜和美颜效果,实际上都可以基于OpenGL来实现。
|
||||
|
||||
而至于这种方式录制视频具体实现代码,你可以参考下grafika中示例。
|
||||
|
||||
视频处理
|
||||
|
||||
1. 视频编辑
|
||||
|
||||
在当下视频类App中,你可以见到各种视频裁剪、视频编辑的功能,例如:
|
||||
|
||||
|
||||
裁剪视频的一部分。
|
||||
|
||||
多个视频进行拼接。
|
||||
|
||||
|
||||
对于视频裁剪、拼接来说,Android直接提供了MediaExtractor的接口,结合seek以及对应读取帧数据readSampleData的接口,我们可以直接获取对应时间戳的帧的内容,这样读取出来的是已经编码好的数据,因此无需重新编码,直接可以输入合成器再次合成为MP4。
|
||||
|
||||
|
||||
|
||||
我们只需要seek到需要裁剪原视频的时间戳,然后一直读取sampleData,送入MediaMuxer即可,这是视频裁剪最简单的实现方式。
|
||||
|
||||
但在实践时会发现,seekTo并不会对所有时间戳都生效。比如说,一个4min左右的视频,我们想要seek到大概2min左右的位置,然后从这个位置读取数据,但实际调用seekTo到2min这个位置之后,再从MediaExtractor读取数据,你会发现实际获取的数据上可能是从2min这里前面一点或者后面一点位置的内容。这是因为MediaExtractor这个接口只能seek到视频关键帧的位置,而我们想要的位置并不一定有关键帧。这个问题还是要回到视频编码,在视频编码时两个关键帧之间是有一定间隔距离的。
|
||||
|
||||
|
||||
|
||||
如上图所示,关键帧被成为I帧,可以被认为是一帧没有被压缩的画面,解码的时候无需要依赖其他视频帧。但是在两个关键帧之间,还存在这B帧、P帧这样的压缩帧,需要依赖其他帧才能完整解码出一个画面。至于两个关键帧之间的间隔,被称为一个GOP ,在GOP内的帧,MediaExtractor是无法直接seek到的,因为这个类不负责解码,只能seek到前后的关键帧。但如果GOP过大,就会导致视频编辑非常不精准了(实际上部分手机的ROM有改动,实现的MediaExtractor也能精确seek)。
|
||||
|
||||
既然如此,那要实现精确裁剪也就只能去依赖解码器了。解码器本身能够解出所有帧的内容,在引入解帧之后,整个裁剪的流程就变成了下面的样子。
|
||||
|
||||
|
||||
|
||||
我们需要先seek到需要位置的前一I帧上,然后送入解码器,解码器解除一帧之后,判断当前帧的PTS是否在需要的时间戳范围内,如果是的话,再将数据送入编码器,重新编码再次得到H.264视轨数据,然后合成MP4文件。
|
||||
|
||||
上面是基础的视频裁剪流程,对于视频拼接,也是类似得到多段H.264数据之后,才一同送入合成器。
|
||||
|
||||
另外,在实际视频编辑中,我们还会添加不少视频特效和滤镜。前面在视频拍摄的场景下,我们利用Surface作为MediaCodec的输入源,并且利用Surface创建了OpenGL的上下文。而MediaCodec作为解码器的时候,也可以在configure的时候,指定一张Surface作为其解码的输出。大部分视频特效都是可以通过OpenGL来实现的,因此要实现视频特效,一般的流程是下面这样的。
|
||||
|
||||
|
||||
|
||||
我们将解码之后的渲染交给OpenGL,然后输出到编码器的InputSurface上,来实现整套编码流程。
|
||||
|
||||
2. 视频播放
|
||||
|
||||
任何视频类App都会涉及视频播放,从录制、剪辑再到播放,构成完整的视频体验。对于要播放一个MP4文件,最简单的方式莫过于直接使用系统的MediaPlayer,只需要简单几行代码,就能直接播放视频。对于本地视频播放来说,这是最简单的实现方式,但实际上我们可能会有更复杂的需求:
|
||||
|
||||
|
||||
需要播放的视频可能本身并不在本地,很多可能都是网络视频,有边下边播的需求。
|
||||
|
||||
播放的视频可能是作为视频编辑的一部分,在剪辑时需要实时预览视频特效。
|
||||
|
||||
|
||||
对于第二种场景,我们可以简单配置播放视频的View为一个GLSurfaceView,有了OpenGL的环境,我们就可以在这上实现各种特效、滤镜的效果了。而对于视频编辑常见的快进、倒放之类的播放配置,MediaPlayer也有直接的接口可以设置。
|
||||
|
||||
更为常见的是第一种场景,例如一个视频流界面,大部分视频都是在线视频,虽然MediaPlayer也能实现在线视频播放,但实际使用下来,会有两个问题:
|
||||
|
||||
|
||||
通过设置MediaPlayer视频URL方式下载下来的视频,被放到了一个私有的位置,App不容易直接访问,这样会导致我们没法做视频预加载,而且之前已经播放完、缓冲完的视频,也不能重复利用原有缓冲内容。
|
||||
|
||||
同视频剪辑直接使用MediaExtractor返回的数据问题一样,MediaPlayer同样无法精确seek,只能seek到有关键帧的地方。
|
||||
|
||||
|
||||
对于第一个问题,我们可以通过视频URL代理下载的方式来解决,通过本地使用Local HTTP Server的方式代理下载到一个指定的地方。现在开源社区已经有很成熟的项目实现,例如AndroidVideoCache。
|
||||
|
||||
而对于第二个问题来说,没法精确seek的问题在有些App上是致命的,产品可能无法接受这样的体验。那同视频编辑一样,我们只能直接基于MediaCodec来自行实现播放器,这部分内容就比较复杂了。当然你也可以直接使用Google开源的ExoPlayer,简单又快捷,而且也能支持设置在线视频URL。
|
||||
|
||||
看似所有问题都有了解决方案,是不是就万事大吉了呢?
|
||||
|
||||
常见的网络边下边播视频的格式都是MP4,但有些视频直接上传到服务器上的时候,我们会发现无论是使用MediaPlayer还是ExoPlayer,似乎都只能等待到整个视频都下载完才能开始播放,没有达到边下边播的体验。这个问题的原因实际上是因为MP4的格式导致的,具体来看,是跟MP4格式中的moov有关。
|
||||
|
||||
|
||||
|
||||
MP4格式中有一个叫作moov的地方存储这当前MP4文件的元信息,包括当前MP4文件的音轨视轨格式、视频长度、播放速率、视轨关键帧位置偏移量等重要信息,MP4文件在线播放的时候,需要moov中的信息才能解码音轨视轨。
|
||||
|
||||
而上述问题发生的原因在于,当moov在MP4文件尾部的时候,播放器没有足够的信息来进行解码,因此视频变得需要直接下载完之后才能解码播放。因此,要实现MP4文件的边下边播,则需要将moov放到文件头部。目前来说,业界已经有非常成熟的工具,FFmpeg跟mp4v2都可以将一个MP4文件的moov提前放到文件头部。例如使用FFmpeg,则是如下命令:
|
||||
|
||||
ffmpeg -i input.mp4 -movflags faststart -acodec copy -vcodec copy output.mp4
|
||||
|
||||
|
||||
使用-movflags faststart,我们就可以把视频文件中的moov提前了。
|
||||
|
||||
另外,如果想要检测一个MP4的moov是否在前面,可以使用类似AtomicParsley的工具来检测。
|
||||
|
||||
在视频播放的实践中,除了MP4格式来作为边下边播的格式以外,还有更多的场景需要使用其他格式,例如m3u8、FLV之类,业界在客户端中常见的实现包括ijkplayer、ExoPlayer,有兴趣的同学可以参考下它们的实现。
|
||||
|
||||
音视频开发的学习之路
|
||||
|
||||
音视频相关开发涉及面很广,今天我也只是简单介绍一下其中基本的架构,如果想继续深入这个领域发展,从我个人学习的经历来看,想要成为一名合格的开发者,除了基础的Android开发知识以外,还要深入学习,我认为还需要掌握下面的技术栈。
|
||||
|
||||
语言
|
||||
|
||||
|
||||
C/C++:音视频开发经常需要跟底层代码打交道,掌握C/C++是必须的技能。这方面资料很多,相信我们都能找到。
|
||||
|
||||
ARM NEON汇编:这是一项进阶技能,在视频编解码、各种帧处理低下时很多都是利用NEON汇编加速,例如FFmpeg/libyuv底层都大量利用了NEON汇编来加速处理过程。虽说它不是必备技能,但有兴趣也可以多多了解,具体资料可以参考ARM社区的教程。
|
||||
|
||||
|
||||
框架
|
||||
|
||||
|
||||
FFmpeg:可以说是业界最出名的音视频处理框架了,几乎囊括音视频开发的所有流程,可以说是必备技能。
|
||||
|
||||
libyuv:Google开源的YUV帧处理库,因为摄像头输出、编解码输入输出也是基于YUV格式,所以也经常需要这个库来操作数据(FFmpeg也有提供了这个库里面所有的功能,在libswscale都可以找到类似的实现。不过这个库性能更好,也是基于NEON汇编加速)。
|
||||
|
||||
libx264/libx265:目前业界最为广泛使用的H.264/H.265软编解码库。移动平台上虽然可以使用硬编码,但很多时候出于兼容性或画质的考虑,因为不少低端的Android机器,在低码率的场景下还是软编码的画质会更好,最终可能还是得考虑使用软编解码。
|
||||
|
||||
OpenGL ES:当今,大部分视频特效、美颜算法的处理,最终渲染都是基于GLES来实现的,因此想要深入音视频的开发,GLES是必备的知识。另外,除了GLES以外,Vulkan也是近几年开始发展起来的一个更高性能的图形API,但目前来看,使用还不是特别广泛。
|
||||
|
||||
ExoPlayer/ijkplayer:一个完整的视频类App肯定会涉及视频播放的体验,这两个库可以说是当下业界最为常用的视频播放器了,支持众多格式、协议,如果你想要深入学习视频播放处理,它们几乎也算是必备技能。
|
||||
|
||||
|
||||
从实际需求出发,基于上述技术栈,我们可以从下面两条路径来深入学习。
|
||||
|
||||
1. 视频相关特效开发
|
||||
|
||||
直播、小视频相关App目前越来越多,几乎每个App相关的特效,往往都是利用OpenGL本身来实现。对于一些简单的特效,可以使用类似Color Look Up Table的技术,通过修改素材配合Shader来查找颜色替换就能实现。如果要继续学习更加复杂的滤镜,推荐你可以去shadertoy学习参考,上面有非常多Shader的例子。
|
||||
|
||||
而美颜、美型相关的效果,特别是美型,需要利用人脸识别获取到关键点,对人脸纹理进行三角划分,然后再通过Shader中放大、偏移对应关键点纹理坐标来实现。如果想要深入视频特效类的开发,我推荐可以多学习OpenGL相关的知识,这里会涉及很多优化点。
|
||||
|
||||
2. 视频编码压缩算法
|
||||
|
||||
H.264/H.265都是非常成熟的视频编码标准,如何利用这些视频编码标准,在保证视频质量的前提下,将视频大小最小化,从而节省带宽,这就需要对视频编码标准本身要有非常深刻的理解。这可能是一个门槛相对较高的方向,我也尚处学习阶段,有兴趣的同学可以阅读相关编码标准的文档。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
132
专栏/Android开发高手课/39移动开发新大陆:边缘智能计算的趋势.md
Normal file
132
专栏/Android开发高手课/39移动开发新大陆:边缘智能计算的趋势.md
Normal file
@ -0,0 +1,132 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
39 移动开发新大陆: 边缘智能计算的趋势
|
||||
|
||||
你好,我是张绍文。今天文章的作者黄振是算法领域的专家,过去曾经和他合作过移动端的AI项目,无论是他的算法水平,还是工程上的能力,都让我深感佩服。今天我非常幸运地请到他来给我们分享关于移动边缘智能计算的认识。
|
||||
|
||||
如果说过去几年移动开发是那头在风口上的猪,现在这个风口明显已经转移到AI,最明显的是国内应届生都纷纷涌向了AI方向。那作为移动开发,我们如何在AI浪潮之下占据自己的一席之地?
|
||||
|
||||
以我个人看法来看,即使目前AI在移动端的落地场景有限,但是AI这个“坑”我们是一定要跳的。我们可以尝试使用TensorFlow这些框架去做一些简单的Demo,逐步加深对深度学习的理解。
|
||||
|
||||
Google的TensorFlow Lite、Facebook的Cafe2、腾讯的NCNN和FatherCNN、小米的MACE、百度的Paddle-Mobile,各个公司都开源了自己的移动深度学习框架。移动端未来必然是深度学习一个非常重要的战场,移动开发们需要发挥自己的平台特长。我们对于这些移动深度学习框架的优化应该更有发言权,要做到既有算法思维,也有更加强大的工程能力。就像之前黄振合作的项目中,整个框架的性能优化也是由我们来主导,使用了大量ARM NEON指令和汇编进行优化。
|
||||
|
||||
|
||||
大家好,我是黄振,目前在一家大型互联网公司从事算法工作。我一直在关注边缘智能计算,正好过去也开展过移动端机器学习的项目,期间也接触了不少Android开发的同学。在一起合作期间,我也在思考AI和Android开发可以相结合的点,也看到团队中不少Android开发同学展现出的机器学习技术的开发能力。正好应绍文邀请,在Android开发专栏和你分享一下我对移动端机器学习的认识,希望对你有所帮助。
|
||||
|
||||
目前,技术的发展有两个趋势。一个趋势是,随着5G网络的发展,物联网使“万物互联”成为可能。物联网时代,也是云计算和边缘计算并存的世界,且边缘计算将扮演重要的角色。边缘计算是与云计算相反的计算范式,指的是在网络边缘节点(终端设备)进行数据处理和分析。边缘计算之所以重要,原因在于:首先,在物联网时代,将有数十亿的终端设备持续采集数据,带来的计算量将是云计算所不能承受的;其次,终端设备的计算能力不断提高,并不是所有的计算都需要在云端完成;再有,将数据传回云端计算再将结果返回终端的模式,不可避免存在时延,不仅影响用户体验,有些场景也是不可接受的;最后,随着用户对数据安全和隐私的重视,也会要求在终端进行数据加工和计算。
|
||||
|
||||
另一个趋势是,人工智能技术的迅速发展。最近几年,以深度学习为代表的人工智能技术取得了突破性的进展,不仅在社会上引起了人们的兴趣和关注,同时也正在重塑商业的格局。人工智能技术的重大价值在于,借助智能算法和技术,将之前必须人工才能完成的工作进行机器化,用机器的规模化解放人工,突破人力生产要素的瓶颈,从而大幅提高生产效率。在商业社会,“规模化”技术将释放巨大的能量、创造巨大的价值,甚至重塑商业的竞争,拓展整个商业的外部边界。
|
||||
|
||||
边缘计算既然有计算能力,在很多应用场景中就会产生智能计算的需求,两者结合在一起,就形成了边缘智能计算。
|
||||
|
||||
移动端机器学习的开发技术
|
||||
|
||||
目前,移动端机器学习的应用技术主要集中在图像处理、自然语言处理和语音处理。在具体的应用上,包括但不限于视频图像的物体检测、语言翻译、语音助手、美颜等,在自动驾驶、教育、医疗、智能家居和物联网等方面有巨大的应用需求。
|
||||
|
||||
1. 计算框架
|
||||
|
||||
因为深度学习算法具有很多特定的算法算子,为了提高移动端开发和模型部署的效率,各大厂都开发了移动端深度学习的计算框架,例如谷歌的TensorFlow Lite、Facebook的Caffe2等,Android开发的同学还是挺有必要去了解这些计算框架的。为了培养兴趣这方面的兴趣并获得感性的认识,你可以挑选一个比较成熟的大厂框架,例如TensorFlow Lite,在手机开发一个物体检测的Demo练练手。
|
||||
|
||||
在实际项目中,TensorFlow Lite和Caffe2等通常运行比较慢。在网上可以很容易找到各个计算框架的对比图。如果要真正入门的话,在这里我推荐大家使用NCNN框架。NCNN是腾讯开源的计算框架,优点比较明显,代码结构清晰、文件不大,而且运行效率高。强烈推荐有兴趣的Android开发同学把源码阅读几遍,不仅有助于理解深度学习常用的算法,也有助于理解移动端机器学习的计算框架。
|
||||
|
||||
阅读NCNN源码抓住三个最基础的数据结构,Mat、Layer和Net。其中,Mat用于存储矩阵的值,神经网络中的每个输入、输出以及权重,都是用Mat来存储的。Layer实际上表示操作,所以每个Layer都必须有前向操作函数(forward函数),所有算子,例如,卷积操作(convolution)、LSTM操作等都是从Layer派生出来的。Net用来表示整个网络,将所有数据节点和操作结合起来。
|
||||
|
||||
在阅读NCNN源码的同时,建议大家也看些关于卷积神经网络算法的入门资料,会有助于理解。
|
||||
|
||||
2. 计算性能优化
|
||||
|
||||
在实际项目中,如果某个路径成为时间开销的瓶颈,通常可以将该节点用NDK去实现。但在通常情况下,移动端机器学习的计算框架已经是NDK实现的,这时改进的方向是采用ARM NEON指令+汇编进行优化。
|
||||
|
||||
NEON是适用于ARM Cortex-A系列处理器的一种128Bit SIMD(Single Instruction, Multiple Data,单指令、多数据)扩展结构。ARM NEON指令之所有能达到性能优化的目的,关键就在于单指令多数据。如下图所示:
|
||||
|
||||
|
||||
|
||||
两个操作数各有128Bit,各包含4个32Bit的同类型的寄存器,通过如下所示的一条指令,就可以实现4个32Bit数据的并行计算,从而达到性能优化的效果。
|
||||
|
||||
VADDQ.S32 Q0,Q1,Q2
|
||||
|
||||
|
||||
我们曾经用NDK的方式实现了深度学习算法中的PixelShuffle操作,后来采用ARM NEON+汇编优化的方式,计算的效率提升40倍,效果还是很显著的。
|
||||
|
||||
如果还想进一步提高计算的性能,可以采用Int 8量化的方法。在深度学习里面,大多数运算都是基于Float 32类型进行的,Float 32是32Bit,128Bit的寄存器一次可以存储4个Float 32类型数据;相比之下,Int 8类型的数据可以存储16个。结合前面提到的单指令多数据,如果采用Int 8类型的数据,单条指令可以同时执行16个Int 8数据的运算,从而大大提高了并行性。
|
||||
|
||||
但是,将Float 32类型的数值量化到Int 8表达,不可避免会影响到数据的精度,而且量化的过程也是需要时间开销的,这些都是需要注意的地方。关于量化的方法,感兴趣的同学可以阅读这篇论文。
|
||||
|
||||
如果设备具有GPU,还可以应用OpenCL进行GPU加速,例如小米开源的移动端机器学习框架MACE。
|
||||
|
||||
移动端机器学习的算法技术
|
||||
|
||||
对于刚开始学习算法的同学,我一直主张不要把算法想象得太复杂,也不要想得太数学化,否则容易让人望而生畏。数学是思维逻辑的表达,我们希望用数学帮助我们理解算法。我们要能够达到对算法的直观理解,从直观上知道和理解为什么这个算法会有这样的效果,只有这样才算是真正掌握了算法。相反,对于一个算法,如果你只记住了数学推导,没形成直观的理解,是不能够灵活应用的。
|
||||
|
||||
1. 算法设计
|
||||
|
||||
深度学习的图像处理具有很广的应用,而且比较直观和有趣,建议你可以从深度学习的图像处理入手。深度学习的图像处理,最基本的知识是卷积神经网络,所以你可以先学习卷积神经网络。网上有很多关于卷积神经网络的介绍,这里就不赘述了。
|
||||
|
||||
理解卷积神经网络关键是理解卷积神经网络的学习机制,理解为什么能够学习。想要理解这一点,首先需要明确两个关键点“前向传播”和“反向传播”。整个神经网络在结构和激活函数确定之后,所谓“训练”或者“学习”的过程,其实就是在不断地调整神经网络的每个权重,让整个网络的计算结果趋近于期望值(目标值)。前向传播是从输入端开始计算,目标是得到网络的输出结果,再将输出结果和目标值相比较,得到结果误差。之后将结果误差沿着网络结构反向传播拆解到每个节点,得到每个节点的误差,然后根据每个节点的误差调整该节点的权重。“前向传播”的目的是得到输出结果,“反向传播”的目的是通过反向传播误差来调整权重。通过两者互相交替迭代,希望达到输出结果和目标值一致。
|
||||
|
||||
理解卷积神经网络之后,我们可以动手实现手写字体识别的示例。掌握之后,可以接着学习深度学习里的物体检测算法,例如YOLO、Faster R-CNN等,最后可以动手用TensorFlow都写一遍,跑跑训练数据、调调参数,边动手边理解。在学习过程中,要特别留意算法模型的设计思想和解决思路。
|
||||
|
||||
2. 效果优化
|
||||
|
||||
效果优化指提升算法模型的准确率等指标,通常的方式有以下几种:
|
||||
|
||||
|
||||
优化训练数据
|
||||
|
||||
优化算法设计
|
||||
|
||||
优化模型训练方式
|
||||
|
||||
|
||||
优化训练数据
|
||||
|
||||
因为算法模型是从训练数据中学习的,模型无法学习到训练数据之外的模式。所以,在选择训练数据时要特别小心,必须使得训练数据包含实际场景中会出现的模式。精心挑选或标注训练数据,会有效提升模型的效果。训练数据的标注对效果非常重要,以至于有创业公司专门从事数据标注,还获得了不少融资。
|
||||
|
||||
优化算法设计
|
||||
|
||||
根据问题采用更好的算法模型,采用深度学习模型而不是传统的机器学习模型,采用具有更高特征表达能力的模型等,例如使用残差网络或DenseNet提高网络的特征表达能力。
|
||||
|
||||
优化模型训练方式
|
||||
|
||||
优化模型训练方式包括采用哪种损失函数、是否使用正则项、是否使用Dropout结构、使用哪种梯度下降算法等。
|
||||
|
||||
3. 计算量优化
|
||||
|
||||
虽然我们在框架侧做了大量的工作来提高计算性能,但是如果在算法侧能够减少计算量,那么整体的计算实时性也会提高。从模型角度,减少计算量的思路有两种,一种是设计轻量型网络模型,一种是对模型进行压缩。
|
||||
|
||||
轻量型网络设计
|
||||
|
||||
学术界和工业界都设计了轻量型卷积神经网络,在保证模型精度的前提下,大幅减少模型的计算量,从而减少模型的计算开销。这种思路的典型代表是谷歌提出的MobileNet,从名字上也可以看出设计的目标是移动端使用的网络结构。MobileNet是将标准的卷积神经网络运算拆分成Depthwise卷积运算和Pointwise卷积运算,先用Depthwise卷积对输入各个通道分别进行卷积运算,然后用Pointwise卷积实现各个通道间信息的融合。
|
||||
|
||||
模型压缩
|
||||
|
||||
模型压缩包括结构稀疏化和蒸馏两种方式。
|
||||
|
||||
在逻辑回归算法中,我们通过引入正则化,使得某些特征的系数近似为0。在卷积神经网络中,我们也希望通过引入正则化,使得卷积核的系数近似为0。与普通的正则化不同的是,在结构稀疏化中,我们希望正则化实现结构性的稀疏,比如某几个通道的卷积核的系数全部近似为0,从而可以将这几个通道的卷积核剪枝掉,减少不必要的计算开销。
|
||||
|
||||
蒸馏方法有迁移学习的意思,就是设计一个简单的网络,通过训练的方式,使得该简单的网络具有目标网络近似的表示能力,从而达到“蒸馏”的效果。
|
||||
|
||||
Android开发同学的机会
|
||||
|
||||
移动端机器学习的计算框架和算法,前者负责模型计算的性能,减少时间开销;后者主要负责模型的精度,还可以通过一些算法设计减少算法的计算量,从而达到减少时间开销的目的。
|
||||
|
||||
需要注意的是,在移动端机器学习中,算法模型的训练通常是在服务器端进行的。目前,终端设备通常不负责模型的训练。在使用时,由终端设备加载训练结果模型,执行前向计算得到模型的计算结果。
|
||||
|
||||
但是前面讲了那么多行业趋势和机器学习的基本技术,那对于移动开发的同学来说,如何进入这个“热门”的领域呢?移动端机器学习是边缘智能计算范畴的一个领域,而且移动端开发是Android开发同学特别熟悉的领域,所以这也是Android开发同学的一个发展机会,转型进入边缘智能计算领域。Android开发同学可以发挥自己的技术专业优势,先在边缘计算的终端设备程序开发中站稳脚跟,在未来的技术分工体系中有个坚固的立足点;同时,逐步学习深度学习算法,以备将来往前迈一步,进入边缘智能计算领域,创造更高的技术价值。
|
||||
|
||||
可能在大部分情况下,Android开发同学在深度学习算法领域,跟专业的算法同学相比,不具有竞争优势,所以我们千万不要放弃自己所专长的终端设备的开发经验。对大多数Android开发同学而言,“专精Android开发 + 懂深度学习算法”才是在未来技术分工中,创造最大价值的姿势。
|
||||
|
||||
对于学习路径,我建议Android开发同学可以先学习卷积神经网络的基础知识(结构、训练和前向计算),然后阅读学习NCNN开源框架,掌握计算性能的优化方法,把开发技术掌握好。同时,可以逐步学习算法技术,主要学习各种常见的深度学习算法模型,并重点学习近几年出现轻量型神经网络算法。总之,Android开发同学要重点掌握提高计算实时性的开发技术和算法技术,兼顾学习深度学习算法模型。
|
||||
|
||||
基于前面的描述,我梳理了移动端机器学习的技术大图供你参考。图中红圈的部分,是我建议Android开发同学重点掌握的内容。
|
||||
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
173
专栏/Android开发高手课/40动态化实践,如何选择适合自己的方案?.md
Normal file
173
专栏/Android开发高手课/40动态化实践,如何选择适合自己的方案?.md
Normal file
@ -0,0 +1,173 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
40 动态化实践,如何选择适合自己的方案?
|
||||
在专栏第36期《跨平台开发的现状与应用》中,我分享了H5、React Native/Weex、小程序这几种常见的跨平台开发方式。站在开发的角度,虽然跨平台的开发效率要比Native开发更高,但是这并不是大前端在国内盛行的最主要原因。
|
||||
|
||||
相比跨平台能力,国内对大前端的动态化能力更加偏执。这是为什么呢?移动互联网已经发展十年了,随着业务成熟和功能的相对稳定,整体重心开始偏向运营,强烈的运营需求对客户端架构和发布模式都提出了更高的要求。如果每个修改都需要经历开发、上线、版本覆盖等漫长的过程,根本无法达到快速响应的要求。
|
||||
|
||||
所以H5、React Native/Weex、小程序在国内的流行,可以说是动态化能力远比跨平台能力重要。那我们应该选择哪一种动态化方式呢?正如我在跨平台开发所说的,目前这几种方案或多或少都还存在一些性能问题,如果一定要使用Native开发方式,又有哪些动态化方案?今天我们一起来学习应该如何选择适合自己的动态化方案。
|
||||
|
||||
动态化实践的背景
|
||||
|
||||
前几天在朋友圈看到说淘宝iOS客户端上一个版本的更新,已经是两个多月前的事情了。淘宝作为一个业务异常庞大且复杂的电商平台,这样的发布节奏在过去是很难想象的。
|
||||
|
||||
|
||||
|
||||
而现在即使不通过发布新版本,我们也能实现各式各样的运营活动和个性化推荐。依赖客户端的动态化能力,我们不需要等待应用商店审核,也无须依赖用户的主动更新,产品在快速迭代的同时,也有着非常强大的试错能力。
|
||||
|
||||
1. 常见的动态化方案
|
||||
|
||||
移动端动态化方案在最近几年一直是大家关注的重点,虽然它已经发展了很多年,但是每年都会有新的变化,这里我们先来看看各大公司有哪些已知的动态化方案。
|
||||
|
||||
|
||||
|
||||
在2018年北京QCon大会上,美团工程师分享了他们在动态化的实践《美团客户端动态化实践》。美团作为一个强运营的应用,对动态化有非常强烈的诉求,也有着非常丰富的实践经验,他们将动态化方案分为下面四种类型。
|
||||
|
||||
|
||||
|
||||
|
||||
Web容器增强。基于H5实现,但是还有离线包等各种优化手段加持,代表方案有PWA、腾讯的VasSonic、淘宝的zCache以及大部分的小程序方案。
|
||||
|
||||
虚拟运行环境。使用独立的虚拟机运行,但最终使用原生控件渲染,代表方案有React Native、Weex、快应用等。
|
||||
|
||||
业务插件化。基于Native的组件化开发,这种方式在淘宝、支付宝、美团、滴滴、360等航母应用上十分常见。代表方案有阿里的Atlas、360的RePlugin、滴滴的VirtualAPK等。除此之外,我认为各个热修复框架应该也属于业务插件化的一种类型,例如微信的Tinker、美团的Robust、阿里的AndFix。
|
||||
|
||||
布局动态化。插件化或者热修复虽然可以做到页面布局和数据的动态修改,但是代价巨大,而且也不容易实现个性化运营。为了实现“千人千面”,淘宝和美团的首页结构都可以通过动态配置更新。代表的方案有阿里的Tangram、Facebook的Yoga。
|
||||
|
||||
|
||||
2. 动态化方案的选择
|
||||
|
||||
四大动态化方案哪家强,我们又应该如何选择?在回答这个问题之前,我们先来看看它们的差别。
|
||||
|
||||
|
||||
|
||||
目前我们还无法找到一种“十全十美”的动态化方案,每种方案都有自己的优缺点和对应的使用场景。比如Web容器增强方案在动态化能力、开发效率上有着非常大的优势,但稳定性和流畅度差强人意。恰恰相反,布局动态化方案在性能上面有非常不错的表现,但是在动态化能力和开发效率上面却受到不少限制。
|
||||
|
||||
所以说动态方案的选择,我们需要考虑下面这些因素。
|
||||
|
||||
|
||||
业务类型。主要考虑业务的重要性、交互是否复杂、对性能的要求、是否长期迭代等因素。
|
||||
|
||||
团队技术栈和代码的历史包袱。在选择方案的时候,也需要结合团队的技术栈现状以及代码的历史包袱综合考虑。以微信为例,作为一个强交互的IM应用,团队基本以Native开发为主,而且微信基本没有太多运营上的需求,所以当时在动态化方案上只使用了Tinker。当然团队的技术栈并不是永恒不变,有了微信小程序之后,内部的一些业务也尝试使用小程序来改造。
|
||||
|
||||
|
||||
|
||||
|
||||
最终无论我们选择哪种动态化类型,我都建议公司内部同一种动态化类型都使用同一个方案,这样在统一技术栈的同时,也可以实现代码在不同业务之间的迁移。比如阿里内部的虚拟运行环境统一使用Weex,一个业务在手淘的效果不错,也可以快速迁移到飞猪、天猫等其他应用中,实现应用的流量矩阵。
|
||||
|
||||
同样对于运营活动也是如此,阿里内部有一个叫PopLayer的组件,它可以在任意Native页面(这个页面甚至可以是Browser)弹出H5的部署容器,可以在无需发版的情况下对已有的Native界面上浮出透明浮层,并且可以不影响Native页面本身的交互。这样做活动我们不需要在客户端提前一两个月开发代码,而且同一个活动也可以快速在公司内部的各个应用中上线。
|
||||
|
||||
Native动态化方案
|
||||
|
||||
Web容器增强和虚拟运行环境方案通过独立的Runtime和JS-SDK来桥接Native模块,而业务插件化则通过插件化框架和接口能力直接调用。相比之下前者更加抽象而不易造成代码混乱,这也是目前各大公司逐渐开始“去插件化”的原因。
|
||||
|
||||
|
||||
|
||||
最近两年,大前端开发越演越烈,传统的Native动态化方案是否还存在价值,它又该何去何从?热修复、插件化这些方案的未来又将如何演进呢?
|
||||
|
||||
1. 热修复和插件化
|
||||
|
||||
2016年在开源Tinker的时候有两件事情是超出我预料的,一个是热修复在国内竟然有那么大的反响,另外一个就是它竟然如此的“坑坑不息”。
|
||||
|
||||
从《Tinker:技术的初心与坚持》一文中,你可以看到过去我们踩过的一小部分坑,但非常不幸的是,填坑之路至今依然没有结束。每次Android新版本发布,我们就像迎来期末考试一样步步惊心。
|
||||
|
||||
|
||||
|
||||
曾经微信希望使用Tinker来代替版本发布,在热修复的基础上实现四大组件的代理。但是Android P私有API限制的出现,基本打消了这个念头。热修复不能代替版本发布,但是我们可以通过它来实现一些应用商店不支持的功能,例如精准的灰度人数控制、渠道和用户属性选择、整包的A/B测试等。
|
||||
|
||||
另一方面,热修复给国内的Android生态也带来一些不太好的影响,比如增加用户ROM体积占用、App启动变慢15%、OTA首次卡顿等。特别是Android Q之后,动态加载的Dex都只使用解释模式执行,会加剧对启动性能的影响。因为性能的问题,目前大公司基本暂停了全量用户的热修复,只使用热修复用于灰度和测试。
|
||||
|
||||
热修复如此,插件化也是如此。笨重的插件化框架不仅影响应用的启动速度,而且多团队协作的时候并没有想象得那么和谐,接口混乱、仓库不好管理、编译速度慢这些问题都会存在。插件化回归模块化和组件化,这也是目前各大公司都在逐步推进的事情。
|
||||
|
||||
前一阵子,徐川在《移动开发的罗曼蒂克消亡史 》一文中回顾了热修复和插件化的前世今生。时间一转三年过去了,对于曾经参与这个浪潮的一份子来说,我可以做的只是顺应潮流的变化。
|
||||
|
||||
|
||||
|
||||
热修复的未来
|
||||
|
||||
Tinker设计之初参考了Instant Run的编译方案,但是正如专栏第26期《关于编译,你需要了解什么?》中所说的,Google在Android Studio 3.5之后,对于Android 8.0以上的设备将会使用Apply Changes替代之前的Instant Run方案。
|
||||
|
||||
|
||||
|
||||
Apply Changes不再使用插入PathClassloader的方式,而是使用我们已经多次讨论过的JVM TI。在Android 8.0之后,JVM TI开始逐渐支持ClassTransform和ClassRedefine这两个接口,它们可以允许虚拟机在运行时动态修改类,实现运行时的动态字节码编织。事实上这个技术在JVM就已经非常成熟,Java服务端利用这两个接口实现了类似热部署、远程调试、动态追踪等能力,具体你可以参考《Java动态追踪技术探究》。
|
||||
|
||||
那热修复的未来将要走向何方?本来我对热修复的未来是非常悲观的,但是Android Q给了我一个很大的惊喜。我们知道,Android P在中国有非常多的应用出现了兼容性问题,其中大部分是热修复、插件化以及加固等原因造成的(Google提供的数据是43%的兼容性问题由这三个问题造成)。
|
||||
|
||||
为了解决这个问题,并且减少我们对私有API的调用,Google在Android P新增了AppComponentFactory API,并且在Android Q增加了替换Classloader的接口instantiateClassloader。在Android Q以后,我们可以实现在运行时替换已经存在ClassLoader和四大组件。中国热修复的先驱们用自己的“牺牲”,总算换来了Google官方的支持。我们使用Google官方API就可以实现热修复,这样以后Android版本再升级也不用担惊受怕了。移动开发的罗曼蒂克并没有消亡,Native的热修复再次迎来了春天。
|
||||
|
||||
|
||||
|
||||
插件化的未来
|
||||
|
||||
对于插件化的未来,我们需要思考如何“回归官道”。Google在2018年推出了Android App Bundles,它可以实现模块的动态下载,但是与插件化不同的是,它并不支持四大组件代理的能力。
|
||||
|
||||
|
||||
|
||||
但是Android App Bundles方案依赖Play Service,在国内我们根本无法使用。爱奇艺的Qigsaw可能对我们有所启发,它基于Android App Bundles实现(支持动态更新,但是不支持四大组件代理),同时完全仿照AAB提供的Play Core Library接口加载插件,如果有国际化需求的公司可以在国内版和国际版上无缝切换。这种方案不仅可以使用Google提供的编译工具链,也支持国际国内双轨,相当于Google为我们维护整个组件化框架,在国内只需要实现自己的“Play Service”即可。
|
||||
|
||||
当然和热修复一样,如果使用AppComponentFactory API,我们也可以实现插件化的四大组件代理。但是具体实现上依然需要在AndroidManifest中预先注册四大组件,然后具体的替换规则可以在我们自定义的AppComponentFactory实现类中埋好。
|
||||
|
||||
|
||||
|
||||
以Activity替换为例,我们可以将某些类名的Activity替换成其他的Activity,新的Activity可以在补丁中,也可以在其他插件中。
|
||||
|
||||
|
||||
|
||||
热修复和插件化作为Native动态化方案,它们有一定的局限性。随着移动技术的发展,部分功能可能会被替换成小程序等其他动态化方案。但是从目前来看,它们依然有非常大的存在价值和使用场景。
|
||||
|
||||
2. 布局动态化
|
||||
|
||||
正如上文所说,像淘宝、美团首页这些场景,我们对性能要求非常高,这里只能使用Native实现。但是首页也是流量的聚集地,“提增长、提留存、提转化”都要求我们有强大的运营能力。最近两年,淘宝、天猫一直推行“千人千面”,每个用户看到的主页布局、内容可能都不太一样。
|
||||
|
||||
布局动态化正是在这个背景之下应运而生,在我看来,布局动态化需要具备下面三个能力。
|
||||
|
||||
|
||||
|
||||
|
||||
UI容器化。能够动态地新增、调整UI界面而无需发版。
|
||||
|
||||
能力接口化。点击、跳转等通用能力可以通过路由协议对外提供,满足UI容器化后的调用需求。
|
||||
|
||||
数据通道化。数据上报也可以通过字段配置,实现客户端根据配置自动上报。
|
||||
|
||||
|
||||
在具体的实践上,天猫开源的Tangram是一个不错的选择。但是Tangram的整体方案会相对复杂一些,我们也可以基于底层的VirtualView做二次开发。
|
||||
|
||||
|
||||
|
||||
总的来说,布局动态化相比虚拟运行环境来说,它不仅实现了UI的动态新增和修改,也有着良好的体验和性能,同时接入和学习成本也比较低。
|
||||
|
||||
总结
|
||||
|
||||
“路漫漫其修远兮,吾将上下而求索”,我们对动态化实践的探索一直没有停止。今年,Flutter也强势地杀入了这个“战场”,那Flutter在跨平台和动态化方面表现如何,我们将在专栏下一期中揭晓。
|
||||
|
||||
动态化如今在国内是炙手可热的研究方向,虽然每个公司都强行造了自己的轮子,但是动态化方案目前还有很多没有解决的问题。所以在我们解决这些问题的过程中,也还会不断演变出其他的各种新方案。
|
||||
|
||||
现在各种类型的动态化方案,目前都能找到自己的应用场景。移动技术在快速地发展,我们无法准确预料到未来,比如说在Android P我们正准备放弃热修复的时候,Android Q又使它重新焕发了青春。但是我们坚信,无论未来采用何种方案,都是为了给用户更好的体验,同时让业务可以更快地迭代,并在不断地尝试中,给用户提供更好的产品。
|
||||
|
||||
课后作业
|
||||
|
||||
对于动态化实践,你有什么看法?在你的应用中,使用了哪种动态化方式?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
跨平台和动态化可以说是大前端时代最大的两个特点,也是每年技术大会的重点。今天的课后作业是仔细阅读下面大会的分享内容,学习各大公司在大前端的实践经验,并留言写写你的心得体会。
|
||||
|
||||
|
||||
2018年北京QCon:美团《美团客户端动态化实践》。
|
||||
|
||||
2018年GMTC:闲鱼《基于Google+Flutter的移动端跨平台应用实践》。
|
||||
|
||||
2018年GMTC:京东《当插件化遇上Android+P》。
|
||||
|
||||
2018年GMTC:小米《快应用开发与实现指南》。
|
||||
|
||||
2018年GMTC:绿色守护《Android+研发的昨天、今天与明天》。
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
153
专栏/Android开发高手课/41聊聊Flutter,面对层出不穷的新技术该如何跟进?.md
Normal file
153
专栏/Android开发高手课/41聊聊Flutter,面对层出不穷的新技术该如何跟进?.md
Normal file
@ -0,0 +1,153 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
41 聊聊Flutter,面对层出不穷的新技术该如何跟进?
|
||||
“天下苦秦久矣”,不管是H5、React Native,还是过去两年火热的小程序,这些跨平台方案在性能和稳定性上总让我们诟病不已。最明显的例子是React Native已经发布几年了,却一直还处在Beta阶段。
|
||||
|
||||
Flutter作为今年最火热的移动开发新技术,从我们首次看到Beta测试版,到2018年12月的1.0正式版,总共才经过了9个多月。Flutter在保持原生性能的前提下实现了跨平台开发,而且更是成为Google下一代操作系统Fuchsia的UI框架,为移动技术的未来发展提供了非常大的想象空间。
|
||||
|
||||
高性能、跨平台,而且更是作为Google下一个操作系统的重要部分,Flutter已经有这么多光环加身,那我们是否应该立刻投身这个浪潮之中呢?新的技术、新的框架每一年都在不断涌现,我们又应该如何跟进呢?
|
||||
|
||||
Flutter的前世今生
|
||||
|
||||
大部分所谓的“新技术”最终都会被遗忘在历史的长河中,面对新技术,我们首先需要持怀疑态度,在决定是否跟进之前,你需要了解它的方方面面。下面我们就一起来看看Flutter的前世今生。
|
||||
|
||||
Flutter的早期开发者Eric Seidel曾经参加过一个访谈What is Flutter,在这个访谈中他谈到了当初为什么开发Flutter,以及Flutter的一些设计原则和方向。-
|
||||
-
|
||||
|
||||
|
||||
Eric Seidel和Flutter早期的几位开发人员都是来自Chrome团队,他们在排版和渲染方面具有非常丰富的经验。那为什么要去开发Flutter?一直以来他们都为浏览器的性能而感到沮丧,有一天他们决定跳出Web的范畴,在Chromium基础上通过删除大量的代码,抛弃Web的兼容性,竟然发现性能是之前的20倍。对此我也深有感触,最近半年也一直在做主App的Lite版本,安装包也从42MB降到8MB,通过删除大量的历史业务,性能比主包要好太多太多。
|
||||
|
||||
正如访谈中所说,Flutter是一个Google内部孵化多年的产品,从它开发之初到现在,一直秉承着两个最重要的设计原则:
|
||||
|
||||
|
||||
性能至上。内置布局和渲染引擎,使用Skia通过GPU做光栅化。选择Dart语言作为开发语言,在发布正式版本时使用AOT编译,不再需要通过解析器解释执行或者JIT。并且支持Tree Shaking无用代码删除,减少发布包的体积。
|
||||
|
||||
效率至上。在开发阶段支持代码的Hot Reload,实现秒级编译更新。重视开发工具链,从开发、调试、测试、性能分析都有完善的工具。内置Runtime实现真正的跨平台,一套代码可以同时生成Android/iOS应用,降低开发成本。
|
||||
|
||||
|
||||
那为什么要选择Dart语言?“Dart团队办公室离Flutter团队最近”肯定是其中的一个原因,但是Dart也为Flutter追求性能和效率的道路提供了大量的帮助,比如AOT编译、Tree Shaking、Hot Reload、多生代无锁垃圾回收等。正因为这些特性,Flutter在筛选了多种语言后,最终选择了Dart。这里也推荐你阅读Why Flutter Uses Dart这篇文章。
|
||||
|
||||
总的来说,Flutter内置了布局和渲染引擎,使用Dart作为开发语言,采用React方式编写UI,支持一套代码在多端运行的框架。但是正如专栏前面所说的,大前端时代的核心诉求是跨平台和动态化,下面我们就一起来看看Flutter在这两方面的表现。
|
||||
|
||||
1. Flutter的跨平台开发
|
||||
|
||||
虽然React Native/Weex使用了系统原生UI组件,通过原生渲染的方式来提升渲染速度和UI流畅度,但是因为JS执行效率、JSBridge的通信代价等因素,性能依然存在瓶颈,而且我们也无法抹平不同系统的平台差异,因此这样的跨平台方案注定艰难。
|
||||
|
||||
|
||||
|
||||
正如Eric Seidel在访谈所说,Flutter是从浏览器引擎简化而来,无论是它的布局引擎(例如也是使用CSS Flexbox布局),还是渲染流水线的设计,都跟浏览器都有很多相似之处。但是它抛弃了浏览器沉重的历史包袱和Web的兼容性,实现了在保持性能的前提下的跨平台开发。
|
||||
|
||||
回想一下在专栏第37期《工作三年半,移动开发转型手游开发》中,我们还描述过另外一套内置Runtime的跨平台方案:Cocos引擎。
|
||||
|
||||
|
||||
|
||||
在我看来,Cocos和Unity这些游戏引擎才是最早并且成熟的跨平台框架,它们对性能的要求也更加苛刻。即使是“王者荣耀”和“吃鸡”这么复杂的游戏,我们也可以做得非常流畅。
|
||||
|
||||
Flutter和游戏引擎一样,也提供了一套自绘界面的UI Toolkit。游戏引擎虽然能实现跨平台开发,但它致力于服务更有“钱途”的游戏开发。游戏引擎对于App开发,特别是混合开发支持并不完善。
|
||||
|
||||
如下图所示,我们可以看到三种跨平台方案的对比,Flutter可以说是三者中最为轻量的。
|
||||
|
||||
|
||||
|
||||
2. Flutter的动态化实践
|
||||
|
||||
在专栏第40期《动态化实践,如何选择适合自己的方案》中,我提到了一个观点:“相比跨平台能力,国内对大前端的动态化能力更加偏执”。
|
||||
|
||||
在性能、跨平台、动态性这个“铁三角”中,我们不能同时将三个都做到最优。如果Flutter在性能、跨平台和动态性都比浏览器更好,那就不会出现Flutter这个框架了,而是成为Chromium的一个跨时代版本。
|
||||
|
||||
|
||||
|
||||
浏览器选择的是跨平台和动态性,而Flutter选择的就是性能和跨平台。Flutter正是牺牲了Web的动态性,使用Dart语言的AOT编译,使用只有5MB的轻量级引擎,才能实现浏览器做不到的高性能。Flutter的第一波受众是Android上使用Java、Kotlin,iOS上使用Objective-C、Swift的Native开发。未来Flutter是否能以此为突破口,进一步蚕食Web领域的份额,现在还不得而知。
|
||||
|
||||
那Flutter是否支持动态更新呢?由于编译成AOT代码,在iOS是绝对不可以动态更新的。对于Android,Flutter的动态更新能力其实Tinker就已经实现了。从官方的提供方案来看,其实也只是替换下面的变化文件,可以说是Tinker的简化版。
|
||||
|
||||
|
||||
|
||||
官方的动态修复方案可以说是非常鸡肋的,并且也是要求应用重启才能生效。如果同时修改了Native代码,这也是不支持的。
|
||||
|
||||
更进一步说,Flutter在Google Play上是否允许动态更新也是抱有疑问的。从Google Play上面的开发者政策中心规定上看,在Google Play也是不允许动态更新可执行文件。
|
||||
|
||||
|
||||
|
||||
从《从Flutter的编译模式》一文中,我们可以通过flutter build apk --build-shared-library将Dart代码编译成app.so。无论是libflutter.so,还是app.so(其实vm_*、isolate_*也是可执行文件)的动态更新,都违反了Google Play的政策。
|
||||
|
||||
|
||||
|
||||
当然不排除Google为了推广Flutter,为它的动态更新开绿灯。最近我也在咨询Google Play的政策组,目前还没有收到答复,如果后续有进一步的结果,我也可以同步给各位同学。
|
||||
|
||||
总的来说,Flutter的动态化能力理论上只能通过JIT编译模式解决,但是这会带来性能和代码体积的巨大影响。当然,闲鱼也在探索一套Flutter的布局动态化方案,你可以参考文章《Flutter动态化的方案对比及最佳实现》。
|
||||
|
||||
面对新技术,该如何选择
|
||||
|
||||
通过上面的学习,我们总算对Flutter的方方面面都有所了解。可以说Flutter是一个性能和效率至上,但是动态化能力非常有限的框架。
|
||||
|
||||
目前闲鱼App、美团外卖、今日头条App、爱奇艺开播助手、网易新闻客户端、京东JDFlutter、马蜂窝旅游App,都分享过他们在使用Flutter的一些心得体会。如果有兴趣接入Flutter,非常推荐你认真看看前人的经验和教训。
|
||||
|
||||
无论是Flutter,还是其他新的技术,在抉择是否跟进的时候,我们需要考虑以下一些因素:
|
||||
|
||||
|
||||
收益。接入新的技术或者框架,给我们带来什么收益,例如稳定性、性能、效率、安全性等方面的提升。
|
||||
|
||||
迁移成本。如果想得到新技术带来的收益,需要我们付出什么代价,例如新技术的学习成本、原来架构的改造成本等。
|
||||
|
||||
成熟度。简单来说,就是这个新技术是否靠谱。跟选择开源项目一样,团队规模、能力是否达标、对项目是否重视都是我们需要考虑的因素。
|
||||
|
||||
社区氛围。主要是看跟进这个技术的人够不够多、文档资料是否丰富、遇到问题能否得到帮助等。
|
||||
|
||||
|
||||
1. 对于Flutter,我是怎么看的
|
||||
|
||||
Flutter是一个非常有前景的技术,这一点是毋庸置疑的。我曾经专门做过一次全面的评估分析,但是得出的结论是暂时不会在我负责的应用中接入,主要原因如下。
|
||||
|
||||
|
||||
|
||||
目前我还没有跟进Flutter的核心原因在于收益不够巨大,如果有足够大的收益,其他几个因素都不是问题。而我负责的应用目前使用H5、小程序作为跨平台和动态化方案,通过极致优化后性能基本可以符合要求。
|
||||
|
||||
从另外一方面来说,新技术的学习和引入,无论是对历史代码、架构,还是我们个人的知识体系,都是一次非常好的重构机会。我非常希望每过一段时间,可以引入一些新的东西,打破大家对现有架构的不满。
|
||||
|
||||
新的技术或多或少有很多不完善的地方,这是挑战,也是机会。通过克服一个又一个的困难和挑战,并且在过程中不断地总结和沉淀,我们最终可能还收获了团队成员技术和其他能力的成长。以闲鱼为例,他们在Flutter落地的同时,不仅将他们的经验总结成几十篇非常高质量的文章,而且也参加了QCon、GMTC等一些技术大会,同时开源了fish-redux、FlutterBoost等几个开源库,Flutter也一下成为了闲鱼的技术品牌。
|
||||
|
||||
可以相信,在过去一年,闲鱼团队在共同攻坚Flutter一个又一个难题的过程中,无论是团队的士气还是团队技术和非技术上的能力,都会有非常大的进步。
|
||||
|
||||
2. 对于Flutter,大家又是怎么看的
|
||||
|
||||
由于我还并没有在实际项目中使用Flutter,所以在写今天的文章之前,我也请教了很多有实际应用经验的朋友,下面我们一起来看看他们对Flutter又是怎么看的。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
曾几何时,我们一直对Chromium庞大的代码无从入手。而Flutter是一个完整而且比WebKit简单很多的引擎,它内部涉及从CPU到GPU,从上层到硬件的一系列知识,源码中有非常多值得我们挖掘去学习和研究的地方。
|
||||
|
||||
未来Flutter应用在小程序中也是一个非常有趣的课题,我们可以直接使用或者参考Flutter实现一个小程序渲染引擎。这样还可以带来另外一个好处,一个功能(例如微信的“附近的餐厅”)在不成熟的时候可以先以小程序的方式尝试,等到这个功能稳定之后,我们又可以无成本地转化为应用内的代码。
|
||||
|
||||
Dart语言从2011年启动以来,一直想以高性能为卖点,试图取代JavaScript,但是长期以来在Google外部使用得并不多。那在Flutter这个契机下,它未来是否可以实现弯道超车呢?这件事给我最大的感触是,机会可能随时会出现,但是需要我们时刻准备好。
|
||||
|
||||
“打铁还需自身硬”,我们还是要坚持修炼内功。对于是否要学习Flutter,我的答案是“多说无益,实践至上”。
|
||||
|
||||
课后作业
|
||||
|
||||
对于Flutter,你有什么看法?你是否准备在你的应用中跟进?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
Flutter作为今年最为火热的技术,里面有非常多的机遇,可以帮我们打造自己的技术品牌(例如撰写文章、参加技术会议、开源你的项目等)。对于Flutter的学习,你可以参考下面的一些资料。
|
||||
|
||||
|
||||
万物之中,源码最美
|
||||
|
||||
Flutter官方文档
|
||||
|
||||
闲鱼的Flutter相关文章
|
||||
|
||||
各大应用的使用总结:闲鱼App、美团外卖、今日头条App、爱奇艺开播助手、网易新闻客户端、京东JDFlutter、马蜂窝旅游App
|
||||
|
||||
阿里Flutter开发者帮助App:flutter-go
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
79
专栏/Android开发高手课/42Android开发高手课学习心得.md
Normal file
79
专栏/Android开发高手课/42Android开发高手课学习心得.md
Normal file
@ -0,0 +1,79 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
42 Android开发高手课学习心得
|
||||
|
||||
你好,我是张绍文。专栏更新至今,转眼间最后一个模块“架构演进”也已经 更新完了。从“Android开发高手课”筹备到现在将近的八个月里,非常感谢我们的学习委员孙鹏飞,不管是文章答疑还是练习Sample,鹏飞为这个专栏付出了太多太多,这里再次衷心地跟他说一声谢谢。
|
||||
|
||||
无论是我还是鹏飞,在写这个专栏的同时,也是重塑自身知识架构的过程。在专栏的最后一篇文章,我特意邀请鹏飞跟大家分享他的学习心得,也算是跟大家做一个小小的告别。鹏飞对于Android系统框架、虚拟机、Linux等底层知识非常熟悉,那他是如何做到的呢?不得不说的是,鹏飞是一个非常自律(+宅)的人,拥有坚持每天早上7点钟起床学习一个半小时的可怕技能。
|
||||
|
||||
最近鹏飞也准备奔向新的工作岗位,在此也祝愿他在新岗位上能够发挥所长。接下来我们一起来听听鹏飞学习专栏的心得和思考。
|
||||
|
||||
|
||||
大家好,我是孙鹏飞。“Android开发高手课”接近尾声,今天我来从一个学习者的角度,对专栏做一下总结。专栏涵盖了Android开发的方方面面的知识,有技术干货、心得体会、问题答疑和部分科普性质的文章,从内容上来说和平时大家看到的教程不太一样,没有过多介绍Android基础,更多的是提供新的思路和分享经验。很多同学感觉学习比较吃力,其实我也是如此,在开始准备这门课程的时候,很多东西也是从零开始学起。对于一个新的概念,从模糊到清晰的过程不会那么容易,它可能涉及Android和Linux很多细枝末节的知识点,需要阅读大量资料给予支撑才能理解。
|
||||
|
||||
下面我就总结一下我学习专栏的过程和一些思考,希望对你有所启发。
|
||||
|
||||
关于“高手课”的思考
|
||||
|
||||
最近我了解到一些同学对C++和Linux感觉不知道如何入手,这里说一下我的学习过程以及相关的学习资料。你可以跟着《C++ Primier Plus》学习一下基本语法和标准库里的函数使用,比如字符串操作、内存分配、I/O操作等,这里有一个很好的网站学习C/C++语言,包括最新的特性都有介绍。
|
||||
|
||||
|
||||
|
||||
Linux应用开发的部分,可以一步步的参考《UNIX环境高级编程》这本书,熟悉一遍标准I/O库、系统数据文件和信息、进程环境、进程控制、进程关系、信号、线程、线程控制、守护进程,以及各种I/O、进程间通信、网络IPC的使用。同时也可以参考《Linux/UNIX系统编程手册》,这本书相对介绍得更详细一些。在了解了Linux应用开发相关内容后,可以深入了解一下Linux内核相关的知识,我推荐《Linux技术内幕》,这本书是国人写的,语言相对于一些翻译书要通顺一些,而且还有大量的图解;缺点是由于篇幅限制,有一部分内容没有介绍。
|
||||
|
||||
从专栏中你可以看到有些内容涉及ARM的汇编,ARM的汇编资料相对少一些,而且实践起来不是很方便,这里推荐一个网站Writing ARM Assembly,可以学习到基本的汇编操作。关于实践可以使用VisUAL工具来进行实验,这个工具可以逐步执行并可以实时观察寄存器和内存里的值。关于汇编相关的内容,我认为更多的时候只需要看懂即可,在实际中使用的地方可能并不是很多。
|
||||
|
||||
Android的更新迭代是很快的,在快速的更新中我们需要保持对Android新特性的了解,还要掌握兼容问题的处理方法,。现在官方文档更新的速度还是很快的,中文文档更新速度也很快,大部分功能和API新特性的描述都可以在Android官网上找到很详细的文档,比如Android Q功能和API ,并且每个版本官方都会提供很详细的API差异报告,这个报告将修改、新增的API都统计在一起,我们可以在这个表里发现一些新功能。
|
||||
|
||||
|
||||
|
||||
关于虚拟机方面的更新,我更多是参考官方的提交记录来查看。具体可以看Android Gerrit,这里有每一条提交记录,并且可以查看很详细的Code Diff,提交记录的描述一般都很清晰,所以可以很好的帮助我们理解代码,也可以发现一些新的工具和功能。
|
||||
|
||||
|
||||
|
||||
专栏学习心得
|
||||
|
||||
我经常听到很多同学说一个技术很难,其实我觉得大多时候不是技术难,而是应该说复杂。技术大多都是演进而来,其实这种演进和我们业务需求迭代的过程很类似。比如内存管理,从固定分区、动态分区机制到分段机制,然后发展成现在的分页机制,硬件也随之演进,都算是一个迭代过程。理解分页机制其实不难,但这个知识点所涉及的内容就很繁琐和复杂了,比如了解系统如何管理内存需要了解内存布局,就会涉及内存划分;内存划分又涉及CPU的运行模式,然后是物理内存和虚拟内存如何映射,会涉及页表的翻译、物理页面的分配和释放、伙伴系统算法;为了解决伙伴系统的内存碎片化问题,又衍生出迁移类型等。
|
||||
|
||||
这一系列涉及的内容就非常复杂,但每一项单拆出来去看,一层层去学习和补充,就会感觉容易很多。这一点其实在业务开发上也有体现,我们刚接手一个复杂的业务,代码庞大,注释和文档都很少,但在一段时间后你还是会对整个业务有或多或少的认识,在接到新的业务的时候也没有觉得难到无从下手,顶多是觉得复杂。底层的系统、框架也是如此,这是一个由点到面的过程。
|
||||
|
||||
忙碌状态下的学习
|
||||
|
||||
鸿洋写过一篇文章《嗷嗷加班,如何保持学习能力》,讲在繁忙的工作状态下如何保持学习,我看过之后也很有感触。在平时零散的时间里我们看到一篇技术文章,并不是阅读收藏后就结束了,这样你可能会在很短的时间里就忘掉了文章的内容。他将阅读一篇文章分成以下几个步骤:提取这篇文章要解决的问题;然后概括一下涉及的技术点;提取重点内容,比如问题发生的缘由、有哪几种解决方法。总体来说,这个方法是为了在短时间内提取出重点内容,然后记录下来后面再进行复习。所以我们都需要多记录、多复习,可以培养使用一些工具来帮助自己养成习惯。
|
||||
|
||||
逃离舒适区
|
||||
|
||||
并不是说换一个更忙碌的工作就是逃离舒适区,而是在平时工作和学习过程中保持一种焦虑感,但这种焦虑感不是迷茫和恐慌,而是清晰地认识到自己的不足,然后在工作和业余的时间里填补自己,当你集中注意力攻克一个难点的时候,你会发现这是一件有趣的事。我身边有很多同学都在持续地学习,每个人都有不同的目标,比如学到什么程度、应用到什么地方等,需要一定的压力才能产生比较好的效果,否则很容易迷失丢掉重点。我以前所在的团队有一个学习计划表,每个同学调研一个方向,每周周会的时候都会抽出一定时间去做技术分享,我觉得这是一个很好的方法,人在有一定紧张情绪的情况下注意力会相当集中,这个和考试前、面试前学习效率会很高的道理是一样的。不过我们也不能太过焦虑,我也经常会有焦虑感,我的解决方法是定制计划,半年或者一年,在一段时间专注完成一件事,你会看到自己成长了很多。
|
||||
|
||||
刻意练习
|
||||
|
||||
也许我们在公司里平时做的业务需求都是缝缝补补,并没有涉及很复杂的内容,大量重复的工作会让人觉得无法提升自己的技术。而平时自己学到的知识在一定时间之后可能就生疏了,而且有些技术可能从原理上看相对简单,但在实现的过程中会遇到各种问题,比如插件化和热修复,这样的技术如果不上手去实践就只能停留在理论层面。对于这种情况,可以采用一种刻意练习的方法,在知晓原理后自己尝试去实现一个类似的框架,在这个过程中你会得到比阅读源码和文章更多的实践经验,可以大大加深对一个知识的理解,也可以锻炼自己的框架设计能力。
|
||||
|
||||
阅读源码
|
||||
|
||||
别人写的源码分析文章我一般看得比较少,除非是自己遇到了很难理解的部分,大部分的内容都可以直接在代码里获取到。而且很多源码的分析文章就很少,比如Inline Hook ,但是框架实现比较多,那么就只能从代码里获取相关内容。针对一个功能的框架可以去找一些相同功能的多个框架进行对比,看一下实现方式是否有不同之处,同时比较一下每个实现方式的优缺点,并记录一下每个框架所使用的技术。在理解一个框架的实现后,还是建议你去自己写一个类似的框架,因为在自己的实现过程中会遇到各种问题,可以从其他框架里寻找一下方案,然后自己总结一下,这样可以了解这个框架的优势之处,如果在面试的时候被问到一个框架的优缺点,这样也可以有自己的理解,而不是网上帖子的统一描述。
|
||||
|
||||
全栈发展
|
||||
|
||||
在所谓的互联网寒冬下,需要持续关注其他方向的技术,提升自身竞争力。在前端的趋势热度和各个公司的发展方向中,“大前端”已经成为必要的技能,这也可以从各大公司的招聘方向中看出来。因此我们在平时学习过程中,可以更多关注一下大前端、跨平台、Flutter相关的内容。现在很多公司和部门讲究中台化和前后端闭环管理,对于后台和前端的技术都要大概有所了解才能掌握整个业务的动态,只关注一块技术在中国的互联网环境下不太利于自身的发展。换句话讲,我们关注得越多,机会也就更多一些。
|
||||
|
||||
个人能力的提升
|
||||
|
||||
这里的能力更多是指软实力的提升,一个是技术视野,也就是一个业务系统的全局把控,将一个自底向上的思维方式发展为从上到下的抽象能力;再有就是以技术价值为导向。以前我总是深入一些技术细节,只是觉得比较有趣,但很少考虑这个技术点能带来什么“价值”。其实在工作晋升和面试的过程中,通常关注更多的是“价值”,我们一般总说业务迭代、模块开发,但很少谈及所做功能的价值,只是觉得技术实现比较简单并没有什么可以讲的,其实我们可以从以下几个方面进行总结。
|
||||
|
||||
首先,我们开发了一个新功能、做了一些改进、引入了一些技术等,可能我们大多在做这些工作,也就是实现了一个业务需求,保证了业务功能的使用,这是功能产生的价值。在做这个需求功能的时候,我们有没有考虑过扩展性、重用性、维护性、性能、稳定性、高可用性等呢?性能的提升给用户带来体验上的价值;可扩展性、重用性带来开发效率的提升;稳定性减少了维护的成本等,这些都是技术产出的价值。我们可以更进一步从业务的角度上看,比如完成这个业务给用户体验提升了多少?促进了多少用户增长、提高了多少用户活跃度、降低了多少成本?由于我们在每个业务开发的时候,都会有一些数据统计的埋点,因此在平时的时候我们要多关注这种业务相关的数据。一般产品同学都会有各种数据报表,我们可以将他们总结起来作为自己完成一个业务所产出的价值。
|
||||
|
||||
写在最后
|
||||
|
||||
最后感谢各位同学能一直跟随“Android开发高手课”学习到最后,相信你一定也从专栏里学到了对自己有价值的新知识。我同样也是从专栏上线,随着专栏更新一点点学习到现在,从专栏里学到了很多思路和方法,也巩固了很多基础知识。但更多的基础知识专栏无法详细呈现,所以还需要我们以此为起点,自己在课下扩展开来,多去思考、多做总结。
|
||||
|
||||
最最后我想说,每个人在突破自己技术瓶颈时都会经历一段痛苦的时光,只有我们具有坚定的信念,并努力坚持下去,相信我等你回过头来再看曾经认为难以理解的技术和知识时,你会有一种阔然开朗、融会贯通的感觉,这就是成长和进步所带来最大的成就感。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
|
||||
|
369
专栏/Android开发高手课/AndroidJVMTI机制详解(内含福利彩蛋).md
Normal file
369
专栏/Android开发高手课/AndroidJVMTI机制详解(内含福利彩蛋).md
Normal file
@ -0,0 +1,369 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
Android JVM TI机制详解(内含福利彩蛋)
|
||||
你好,我是孙鹏飞。
|
||||
|
||||
在专栏卡顿优化的分析中,绍文提到可以利用JVM TI机制获得更加非常丰富的顿现场信息,包括内存申请、线程创建、类加载、GC信息等。
|
||||
|
||||
JVM TI机制究竟是什么?它为什么如此的强大?怎么样将它应用到我们的工作中?今天我们一起来解开它神秘的面纱。
|
||||
|
||||
JVM TI介绍
|
||||
|
||||
JVM TI全名是Java Virtual Machine Tool Interface,是开发虚拟机监控工具使用的编程接口,它可以监控JVM内部事件的执行,也可以控制JVM的某些行为,可以实现调试、监控、线程分析、覆盖率分析工具等。
|
||||
|
||||
JVM TI属于Java Platform Debugger Architecture中的一员,在Debugger Architecture上JVM TI可以算作一个back-end,通过JDWP和front-end JDI去做交互。需要注意的是,Android内的JDWP并不是基于JVM TI开发的。
|
||||
|
||||
从Java SE 5开始,Java平台调试体系就使用JVM TI替代了之前的JVMPI和JVMDI。如果你对这部分背景还不熟悉,强烈推荐先阅读下面这几篇文章:
|
||||
|
||||
|
||||
深入 Java 调试体系:第 1 部分,JPDA 体系概览
|
||||
|
||||
深入 Java 调试体系:第 2 部分,JVMTI 和 Agent 实现
|
||||
|
||||
深入 Java 调试体系:第 3 部分,JDWP 协议及实现
|
||||
|
||||
深入 Java 调试体系:第 4 部分,Java 调试接口(JDI)
|
||||
|
||||
|
||||
虽然Java已经使用了JVM TI很多年,但从源码上看在Android 8.0才集成了JVM TI v1.2,主要是需要在Runtime中支持修改内存中的Dex和监控全局的事件。有了JVM TI的支持,我们可以实现很多调试工具没有实现的功能,或者定制我们自己的Debug工具来获取我们关心的数据。
|
||||
|
||||
现阶段已经有工具使用JVM TI技术,比如Android Studio的Profilo工具和Linkedin的dexmaker-mockito-inline工具。Android Studio使用JVM TI机制实现了实时的内存监控,对象分配切片、GC事件、Memory Alloc Diff功能,非常强大;dexmaker使用该机制实现Mock final methods和static methods。
|
||||
|
||||
1. JVM TI支持的功能
|
||||
|
||||
在介绍JVM TI的实现原理之前,我们先来看一下JVM TI提供了什么功能?我们可以利用这些功能做些什么?
|
||||
|
||||
线程相关事件 -> 监控线程创建堆栈、锁信息
|
||||
|
||||
|
||||
ThreadStart :线程在执行方法前产生线程启动事件。
|
||||
|
||||
ThreadEnd:线程结束事件。
|
||||
|
||||
MonitorWait:wait方法调用后。
|
||||
|
||||
MonitorWaited:wait方法完成等待。
|
||||
|
||||
MonitorContendedEnter:当线程试图获取一个已经被其他线程持有的对象锁时。
|
||||
|
||||
MonitorContendedEntered:当线程获取到对象锁继续执行时。
|
||||
|
||||
|
||||
类加载准备事件 -> 监控类加载
|
||||
|
||||
|
||||
ClassFileLoadHook:在类加载之前触发。
|
||||
|
||||
ClassLoad:某个类首次被加载。
|
||||
|
||||
ClassPrepare:某个类的准备阶段完成。
|
||||
|
||||
|
||||
异常事件 -> 监控异常信息
|
||||
|
||||
|
||||
Exception:有异常抛出的时候。
|
||||
|
||||
ExceptionCatch:当捕获到一个异常时候。
|
||||
|
||||
|
||||
调试相关
|
||||
|
||||
|
||||
SingleStep:步进事件,可以实现相当细粒度的字节码执行序列,这个功能可以探查多线程下的字节码执行序列。
|
||||
|
||||
Breakpoint:当线程执行到一个带断点的位置,断点可以通过JVMTI SetBreakpoint方法来设置。
|
||||
|
||||
|
||||
方法执行
|
||||
|
||||
|
||||
FramePop:当方法执行到retrun指令或者出现异常时候产生,手动调用NofityFramePop JVM TI函数也可产生该事件。
|
||||
|
||||
MethodEntry:当开始执行一个Java方法的时候。
|
||||
|
||||
MethodExit:当方法执行完成后,产生异常退出时。
|
||||
|
||||
FieldAccess:当访问了设置了观察点的属性时产生事件,观察点使用SetFieldAccessWatch函数设置。
|
||||
|
||||
FieldModification:当设置了观察点的属性值被修改后,观察点使用SetFieldModificationWatch设置。
|
||||
|
||||
|
||||
GC -> 监控GC事件与时间
|
||||
|
||||
|
||||
GarbageCollectionStart:GC启动时。
|
||||
|
||||
GarbageCollectionFinish:GC结束后。
|
||||
|
||||
|
||||
对象事件 -> 监控内存分配
|
||||
|
||||
|
||||
ObjectFree:GC释放一个对象时。
|
||||
|
||||
VMObjectAlloc:虚拟机分配一个对象的时候。
|
||||
|
||||
|
||||
其他
|
||||
|
||||
|
||||
NativeMethodBind:在首次调用本地方法时或者调用JNI RegisterNatives的时候产生该事件,通过该回调可以将一个JNI调用切换到指定的方法上。
|
||||
|
||||
|
||||
通过上面的事件描述可以大概了解到JVM TI支持什么功能,详细的回调函数参数可以从JVM TI规范文档里获取到,我们可以通过这些功能实们定制的性能监控、数据采集、行为修改等工具。
|
||||
|
||||
2. JVM TI实现原理
|
||||
|
||||
JVM TI Agent的启动需要虚拟机的支持,我们的Agent和虚拟机运行在同一个进程中,虚拟机通过dlopen打开我们的Agent动态链接库,然后通过Agent_OnAttach方法来调用我们定义的初始化逻辑。
|
||||
|
||||
JVM TI的原理其实很简单,以VmObjectAlloc事件为例,当我们通过SetEventNotificationMode函数设置JVMTI_EVENT_VM_OBJECT_ALLOC回调的时候,最终会调用到art::Runtime::Current() -> GetHeap() -> SetAllocationListener(listener);
|
||||
|
||||
在这个方法中,listener是JVM TI实现的一个虚拟机提供的art::gc::AllocationListener回调,当虚拟机分配对象内存的时候会调用该回调,源码可见heap-inl.h#194,同时在该回调函数里也会调用我们之前设置的callback方法,这样事件和相关的数据就会透传到我们的Agent里,来实现完成事件的监听。
|
||||
|
||||
类似atrace和StrictMode,JVM TI的每个事件都需要在源码中埋点支持。感兴趣的同学,可以挑选一些事件在源码中进一步跟踪。
|
||||
|
||||
JVM TI Agent开发
|
||||
|
||||
JVM TI Agent程序使用C/C++语言开发,也可以使用其他支持C语言调用语言开发,比如Rust。
|
||||
|
||||
JVM TI所涉及的常量、函数、事件、数据类型都定义在jvmti.h文件中,我们需要下载该文件到项目中引用使用,你可以从Android项目里下载它的头文件。
|
||||
|
||||
JVM TI Agent的产出是一个so文件,在Android里通过系统提供的Debug.attachJvmtiAgent方法来启动一个JVM TI Agent程序。
|
||||
|
||||
static fun attachJvmtiAgent(library: String, options: String?, classLoader: ClassLoader?): Unit
|
||||
|
||||
|
||||
library是so文件的绝对地址。需要注意的是API Level为28,而且需要应用开启了android:debuggable才可以使用,不过我们可以通过强制开启debug来在release版里启动JVM TI功能。
|
||||
|
||||
Android下的JVM TI Agent在被虚拟机加载后会及时调用Agent_OnAttach方法,这个方法可以当作是Agent程序的main函数,所以我们需要在程序里实现下面的函数。
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM *vm, char *options,void *reserved)
|
||||
|
||||
|
||||
你可以在这个方法里进行初始化操作。
|
||||
|
||||
通过JavaVM::GetEnv函数拿到jvmtiEnv*环境指针(Environment Pointer),通过该指针可以访问JVM TI提供的函数。
|
||||
|
||||
jvmtiEnv *jvmti_env;jint result = vm->GetEnv((void **) &jvmti_env, JVMTI_VERSION_1_2);
|
||||
|
||||
|
||||
通过AddCapabilities函数来开启需要的功能,也可以通过下面的方法开启所有的功能,不过开启所有的功能对虚拟机的性能有所影响。
|
||||
|
||||
void SetAllCapabilities(jvmtiEnv *jvmti) {
|
||||
jvmtiCapabilities caps;
|
||||
jvmtiError error;
|
||||
error = jvmti->GetPotentialCapabilities(&caps);
|
||||
error = jvmti->AddCapabilities(&caps);
|
||||
}
|
||||
|
||||
|
||||
GetPotentialCapabilities函数可以获取当前环境支持的功能集合,通过jvmtiCapabilities结构体返回,该结构体里标明了支持的所有功能,可以通过jvmti.h来查看,大概内容如下。
|
||||
|
||||
typedef struct {
|
||||
unsigned int can_tag_objects : 1;
|
||||
unsigned int can_generate_field_modification_events : 1;
|
||||
unsigned int can_generate_field_access_events : 1;
|
||||
unsigned int can_get_bytecodes : 1;
|
||||
unsigned int can_get_synthetic_attribute : 1;
|
||||
unsigned int can_get_owned_monitor_info : 1;
|
||||
......
|
||||
} jvmtiCapabilities;
|
||||
|
||||
|
||||
然后通过AddCapabilities方法来启动需要的功能,如果需要单独添加功能,则可以通过如下方法。
|
||||
|
||||
jvmtiCapabilities caps;
|
||||
memset(&caps, 0, sizeof(caps));
|
||||
caps.can_tag_objects = 1;
|
||||
|
||||
|
||||
到此JVM TI的初始化操作就已经完成了。
|
||||
|
||||
所有的函数和数据结构类型说明可以在这里找到。下面我来介绍一些常用的功能和函数。
|
||||
|
||||
1. JVM TI事件监控
|
||||
|
||||
JVM TI的一大功能就是可以收到虚拟机执行时候的各种事件通知。
|
||||
|
||||
首先通过SetEventCallbacks方法来设置目标事件的回调函数,如果callbacks传入nullptr则清除掉所有的回调函数。
|
||||
|
||||
jvmtiEventCallbacks callbacks;
|
||||
memset(&callbacks, 0, sizeof(callbacks));
|
||||
|
||||
callbacks.GarbageCollectionStart = &GCStartCallback;
|
||||
callbacks.GarbageCollectionFinish = &GCFinishCallback;
|
||||
int error = jvmti_env->SetEventCallbacks(&callbacks, sizeof(callbacks));
|
||||
|
||||
|
||||
设置了回调函数后,如果要收到目标事件的话需要通过SetEventNotificationMode,这个函数有个需要注意的地方是event_thread,如果参数event_thread参数为nullptr,则会全局启用改目标事件回调,否则只在指定的线程内生效,比如很多时候对于一些事件我们只关心主线程。
|
||||
|
||||
jvmtiError SetEventNotificationMode(jvmtiEventMode mode,
|
||||
jvmtiEvent event_type,
|
||||
jthread event_thread,
|
||||
...);
|
||||
typedef enum {
|
||||
JVMTI_ENABLE = 1,//开启
|
||||
JVMTI_DISABLE = 0 .//关闭
|
||||
} jvmtiEventMode;
|
||||
|
||||
|
||||
以上面的GC事件为例,上面设置了GC事件的回调函数,如果想要在回调方法里接收到事件则需要使用SetEventNotificationMode开启事件,需要说明的是SetEventNotificationMode和SetEventCallbacks方法调用没有先后顺序。
|
||||
|
||||
jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_START, nullptr);
|
||||
jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_FINISH, nullptr);
|
||||
|
||||
|
||||
通过上面的步骤就可以在虚拟机产生GC事件后在回调函数里获取到对应的函数了,这个Sample需要注意的是在gc callback里禁止使用JNI和JVM TI函数,因为虚拟机处于停止状态。
|
||||
|
||||
void GCStartCallback(jvmtiEnv *jvmti) {
|
||||
LOGI("==========触发 GCStart=======");
|
||||
}
|
||||
|
||||
void GCFinishCallback(jvmtiEnv *jvmti) {
|
||||
LOGI("==========触发 GCFinish=======");
|
||||
}
|
||||
|
||||
|
||||
Sample效果如下。
|
||||
|
||||
com.dodola.jvmti I/jvmti: ==========触发 GCStart=======
|
||||
com.dodola.jvmti I/jvmti: ==========触发 GCFinish=======
|
||||
|
||||
|
||||
2. JVM TI字节码增强
|
||||
|
||||
JVM TI可以在虚拟机运行的状态下对字节码进行修改,可以通过下面三种方式修改字节码。
|
||||
|
||||
|
||||
Static:在虚拟机加载Class文件之前,对字节码修改。该方式一般不采用。
|
||||
|
||||
Load-Time:在虚拟机加载某个Class时,可以通过JVM TI回调拿到该类的字节码,会触发ClassFileLoadHook回调函数,该方法由于ClassLoader机制只会触发一次,由于我们Attach Agent的时候经常是在虚拟机执行一段时间之后,所以并不能修改已经加载的Class比如Object,所以需要根据Class的加载时机选择该方法。
|
||||
|
||||
Dynamic:对于已经载入的Class文件也可以通过JVM TI机制修改,当系统调用函数RetransformClasses时会触发ClassFileLoadHook,此时可以对字节码进行修改,该方法最为实用。
|
||||
|
||||
|
||||
传统的JVM操作的是Java Bytecode,Android里的字节码操作的是Dalvik Bytecode,Dalvik Bytecode是寄存器实现的,操作起来相对JavaBytecode来说要相对容易一些,可以不用处理本地变量和操作数栈的交互。
|
||||
|
||||
使用这个功能需要开启JVM TI字节码增强功能。
|
||||
|
||||
jvmtiCapabilities.can_generate_all_class_hook_events=1 //开启 class hook 功能标记
|
||||
jvmtiCapabilities.can_retransform_any_class=1 //开启对任意类进行 retransform 操作
|
||||
|
||||
|
||||
然后注册ClassFileLoadHook事件回调。
|
||||
|
||||
jvmtiEventCallbacks callbacks;s
|
||||
callbacks.ClassFileLoadHook = &ClassTransform;
|
||||
|
||||
|
||||
这里说明一下ClassFileLoadHook的函数原型,后面会讲解如何重新修改现有字节码。
|
||||
|
||||
static void ClassTransform(
|
||||
jvmtiEnv *jvmti_env,//jvmtiEnv 环境指针
|
||||
JNIEnv *env,//jniEnv 环境指针
|
||||
jclass classBeingRedefined,//被重新定义的class 信息
|
||||
jobject loader,//加载该 class 的 classloader,如果该项为 nullptr 则说明是 BootClassLoader 加载的
|
||||
const char *name,//目标类的限定名
|
||||
jobject protectionDomain,//载入类的保护域
|
||||
jint classDataLen,//class 字节码的长度
|
||||
const unsigned char *classData,//class 字节码的数据
|
||||
jint *newClassDataLen,//新的类数据的长度
|
||||
unsigned char **newClassData) //新类的字节码数据
|
||||
|
||||
|
||||
然后开启事件,完整的初始化逻辑可参考Sample中的代码。
|
||||
|
||||
SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, NULL)
|
||||
|
||||
|
||||
下面以Sample代码作为示例来讲解如何在Activity类的onCreate方法中插入一行日志调用代码。
|
||||
|
||||
通过上面的步骤后就可以在虚拟机第一次加载类的时候和在调用RetransformClasses或者RedefineClasses时,在ClassFileLoadHook回调方法里会接收到事件回调。我们目标类是Activity,它在启动应用的时候就已经触发了类加载的过程,由于这个Sample开启事件的时机很靠后,所以此时并不会收到加载Activity类的事件回调,所以需要调用RetransformClasses来触发事件回调,这个方法用于对已经载入的类进行修改,传入一个要修改类的Class数组和数组长度。
|
||||
|
||||
jvmtiError RetransformClasses(jint class_count, const jclass* classes)
|
||||
|
||||
|
||||
调用该方法后会在ClassFileLoadHook设置的回调,也就是上面的ClassTran sform方法中接收到回调,在这个回调方法中我们通过字节码处理工具来修改原始类的字节码。
|
||||
|
||||
类的修改会触发虚拟机使用新的方法,旧的方法将不再被调用,如果有一个方法正在栈帧上,则这个方法会继续运行旧的方法的字节码。RetransformClasses 的修改不会导致类的初始化,也就是不会重新调用 方法,类的静态变量的值和实例变量的值不会产生变化,但目标类的断点会失效。
|
||||
|
||||
处理类有一些限制,我们可以改变方法的实现和属性,但不能添加删除重命名方法,不能改变方法签名、参数、修饰符,不能改变类的继承关系,如果产生上面的行为会导致修改失败。修改之后会触发类的校验,而且如果虚拟机里有多个相同的Class ,我们需要注意一下取到的Class需要是当前生效的Class,按照ClassLoader加载机制也就是说优先使用提前加载的类。
|
||||
|
||||
Sample中实现的效果是在Activity.onCreate方法中增加一行日志输出。
|
||||
|
||||
修改前:
|
||||
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
.......
|
||||
}
|
||||
|
||||
|
||||
修改后:
|
||||
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
com.dodola.jvmtilib.JVMTIHelper.printEnter(this,"....");
|
||||
....
|
||||
}
|
||||
|
||||
|
||||
我使用的Dalvik字节码修改库是Android系统源码里提供的一套修改框架dexter,虽然使用起来十分灵活但比较繁琐,也可以使用dexmaker框架来实现。本例还是使用dexter,框架使用C++开发,可以直接读取classdata然后进行操作,可以类比到ASM框架。下面的代码是核心的操作代码,完整的代码参考本期Sample。
|
||||
|
||||
ir::Type* stringT = b.GetType("Ljava/lang/String;");
|
||||
ir::Type* jvmtiHelperT=b.GetType("Lcom/dodola/jvmtilib/JVMTIHelper;");
|
||||
lir::Instruction *fi = *(c.instructions.begin());
|
||||
VReg* v0 = c.Alloc<VReg>(0);
|
||||
addInstr(c, fi, OP_CONST_STRING,
|
||||
{v0, c.Alloc<String>(methodDesc, methodDesc->orig_index)});
|
||||
addCall(b, c, fi, OP_INVOKE_STATIC, jvmtiHelperT, "printEnter", voidT, {stringT}, {0});
|
||||
c.Assemble();
|
||||
|
||||
|
||||
必须通过JVM TI函数Allocate为要修改的类数据分配内存,将new_class_data指向修改后的类bytecode数组,将new_class_data_len置为修改后的类bytecode数组的长度。若是不修改类文件,则不设置new_class_data即可。若是加载了多个JVM TI Agent都启用了该事件,则设置的new_class_data会成为下一个JVM TI Agent的class_data。
|
||||
|
||||
此时我们生成的onCreate方法里已经加上了我们添加的日志方法调用。开启新的Activity会使用新的类字节码执行,同时会使用ClassLoader加载我们注入的com.dodola.jvmtilib.JVMTIHelper类。我在前面说过,Activity是使用BootClassLoader进行加载的,然而我们的类明显不在BootClassLoader里,此时就会产生Crash。
|
||||
|
||||
java.lang.NoClassDefFoundError: Class not found using the boot class loader; no stack trace available
|
||||
|
||||
|
||||
所以需要想办法将JVMTIHelper类添加到BootClassLoader里,这里可以使用JVM TI提供的AddToBootstrapClassLoaderSearch方法来添加Dex或者APK到Class搜索目录里。Sample里是将 getPackageCodePath添加进去就可以了。
|
||||
|
||||
总结
|
||||
|
||||
今天我主要讲解了JVM TI的概念和原理,以及它可以实现的功能。通过JVM TI可以完成很多平时可能需要很多“黑科技”才可以获取到的数据,比如Thread Park Start/Finish事件、获取一个锁的waiters等。
|
||||
|
||||
可能在Android圈里了解JVM TI的人不多,对它的研究还没有非常深入。目前JVM TI的功能已经十分强大,后续的Android版本也会进一步增加更多的功能支持,这样它可以做的事情将会越来越多。我相信在未来,它将会是本地自动化测试,甚至是线上远程诊断的一大“杀器”。
|
||||
|
||||
在本期的Sample里,我们提供了一些简单的用法,你可以在这个基础之上完成扩展,实现自己想要的功能。
|
||||
|
||||
相关资料
|
||||
|
||||
1.深入 Java 调试体系:第 1 部分,JPDA 体系概览
|
||||
|
||||
2.深入 Java 调试体系:第 2 部分,JVMTI 和 Agent 实现
|
||||
|
||||
3.深入 Java 调试体系:第 3 部分,JDWP 协议及实现
|
||||
|
||||
4.深入 Java 调试体系:第 4 部分,Java 调试接口(JDI)
|
||||
|
||||
5.JVM TI官方文档:https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html
|
||||
|
||||
6.源码是最好的资料:http://androidxref.com/9.0.0_r3/xref/art/openjdkjvmti/
|
||||
|
||||
福利彩蛋
|
||||
|
||||
根据专栏导读里我们约定的,我和绍文会选出一些认真提交作业完成练习的同学,送出一份“学习加油礼包”。专栏更新到现在,很多同学留下了自己的思考和总结,我们选出了@Owen、@志伟、@许圣明、@小洁、@SunnyBird,送出“极客时间周历”一份,希望更多同学可以加入到学习和讨论中来,与我们一起进步。
|
||||
|
||||
-
|
||||
@Owen学习总结:https://github.com/devzhan/Breakpad
|
||||
|
||||
@许圣明、@小洁、@SunnyBird 通过Pull Requests提交了练习作业https://github.com/AndroidAdvanceWithGeektime/Chapter04/pulls。
|
||||
|
||||
极客时间小助手会在24小时内与获奖用户取得联系,注意查看短信哦~
|
||||
|
||||
|
||||
|
||||
|
53
专栏/Android开发高手课/Android工程师的“面试指南”.md
Normal file
53
专栏/Android开发高手课/Android工程师的“面试指南”.md
Normal file
@ -0,0 +1,53 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
Android工程师的“面试指南”
|
||||
你好,我是孙鹏飞。又到了传统的“金三银四”换工作的高峰期,在互联网寒冬下,抓住机会就显得尤为重要了。那作为Android工程师我们应该从哪些方面去准备呢?例如,不太熟悉的技能要不要写在简历上、要复习哪些Android组件的知识、刷算法题目有没有用,可能在面试前你都会仔细考虑这些问题。下面我就结合自身的经验和理解,帮你梳理一下关于简历、面试和算法方面需要准备的内容,分享一些我的心得体会。
|
||||
|
||||
简历
|
||||
|
||||
简历在面试过程会起到至关重要的作用,我们需要非常注意简历的撰写。
|
||||
|
||||
在面试的过程中,面试官通常会非常关注你简历中的工作经历、项目介绍、技能特长这三部分的内容,如果你面试的公司没有固定题目的话,那很多问题都会围绕你简历里这三部分内容去问。这里需要注意的一点是相关技能的书写,首先你要让面试官明确你面试的定级是什么。很多时候一个职位对应了很多个职级,在投简历的时候,你的简历需要让面试官给你一个比较明确的定级,否则面试过程会比较被动,也会影响面试官对你的判断。因此这部分的内容需要突出自己的特长,也要写一些现在公司相对关心的问题,比如你对插件化、热修复、组件化、性能优化等很熟悉,就可以明确的写上,但如果不是很熟悉那么尽量不要去写。如果你对Android某部分内容很熟悉就可以写得相对详细一些,比如你对Handler、Binder机制很熟悉,就可以写“熟悉Android常见机制,比如Handler、Binder机制等”。而看到你很熟悉这部分内容,面试官可能在问问题时一层层深入,因此你肯定需要提前准备一下这部分内容如何讲解,基本可以从机制的优点、重点、难点三方面去说明。
|
||||
|
||||
关于项目介绍主要体现你在这个项目或者这个团队中的作用,突出自己的贡献和项目的难点。很多同学可能在公司一直做需求的开发,会觉得自己的项目经验没有亮点,难度也没有那么大,会觉得在这部分内容上比较吃亏。其实每个需求下来的时候你肯定会对这个需求有一个自己的设计,在这个过程中你会考虑如何对现有代码的影响最小,如何快捷清晰的实现功能,以及在开发过程中对组件、控件的封装,考虑如何优化性能,有没有新的技术可以帮助开发这个需求的…我想我们每个需求都是通过一系列的考量和设计后才实施的,你可以回去翻一下自己的代码,然后考虑一下如何把你的这些设计和思考体现在简历上,同样也是个不错的说明。
|
||||
|
||||
面试
|
||||
|
||||
对于Android工程师来说,面试开始的时候都会问一些算法和Android、Java的基础知识。针对Java的基础知识,我建议你看一下《码出高效:Java 开发手册》《深入理解Java虚拟机》《Java并发编程的艺术》这三本书。对于Android的面试题,大多都是跟系统原理有关的内容,但也有很多没有准确答案的问题,比如四大组件的原理这样的题目,需要你从一个宏观的角度去解释一下四大组件,或者你也可以拆分开一个个去讲解。
|
||||
|
||||
在面试前你需要提前准备一下,避免回答问题的时候没有条理,导致面试官对你的逻辑思维能力和语言表达能力产生不好的判断。一些Android经常使用到的组件一定要理解清楚,比如Handler.postDelay的机制、触摸事件机制、自定义View、如何计算View大小、容器控件如何对子控件进行布局、数据库基本操作、Binder机制、LMK机制等。还有面试官也可能会问一些开源框架的原理,我建议你也要多了解一些优秀的网络框架、图片加载框架、日志记录框架、EventBus、AAC框架的原理。对于相对复杂的插件化和热修复来说,热修复可以去看一下《深入探索Android热修复》这本书,插件化可以去看《Android插件化原理解析》这个系列的文章。还有性能优化,最近几年公司对性能优化关注很多,有的同学可能做过专门的性能优化或者自己开发过一些工具总结过一些方法论,这样比较好答一些。但是大部分同学可能平时都在关注业务需求开发,性能优化的实战可能并不是很多。我建议你可以从业务开发过程中找一些点来说,比如在做一些公共的业务组件时需要在启动时初始化,那么就需要注意初始化过程中的性能;又或者在做一个列表页面的时候,在复杂的列表View下如何保证滑动性能。相信你在平时开发过程中都会有自己的思考,可以结合具体的情景讲出来。
|
||||
|
||||
面试的后面大多都会从项目入手,你需要在面试之前针对你的项目做详细的准备。比如面试官会让你介绍一下你的项目,你需要体现出这个项目的难点、你在项目中的贡献、项目的具体实现等,有可能还会问到一些具体的细节,所以建议是实事求是去讲,但一定要对自己的模块非常清晰。除了技术面试以外,有时还有可能会考察一些软技能,比如面试官会考察你跨部门协作能力、沟通能力、时间管理、任务分配和职业规划等。
|
||||
|
||||
面试更多的还是要靠平时的积累和临场的发挥,做总结是很重要的,因为很多内容不经常使用的话过一段时间之后就会忘掉,这样就会出现原本自己做过的东西,因为忘记了细节,结果在面试过程中没法很好地展现出来。就比如插件化、热修复这样的技术,其实原理相对来说比较简单,但是在开发的过程中会遇到很多很多的坑,如果没有一些关键点的文字记录,过一段时间之后可能就忘记了某段代码是用于什么目的。所以在做完一次需求之后尽量多总结项目中的难点,使用到的框架以及这个框架的原理,以及其中花费时间最长的地方。另外Bug最多的地方也要做总结一下原因,这样在面试前就不用把代码再翻一遍,了解自己的项目细节就可以做到游刃有余了。
|
||||
|
||||
对于复习,首先要对自己做一次自我了解,我是通过画脑图来进行这个过程的,我会整体默想一遍大概的知识体系,画成类似下图。回想每个知识点可能考到的内容,记录下自己模糊的地方,然后去看网上同学们总结的面试题,再对每个题目都做一下回答。这是一个迭代过程。在你预想的问题都可以回答上来的时候,就需要深入挖掘一下技术细节和深度了,比如我工作中开发了一个PLT Hook工具,这个工具可能是我参考开源项目并封装修改过来的,但对其中的细节并没有很了解,这个时候你就要对这个开源项目所涉及的内容做一次系统学习了。
|
||||
|
||||
|
||||
|
||||
另一方面是需要在面试过程中提高面试官对自己的级别评价,比如大部分人回答GC的问题都是按照《深入理解Java虚拟机》里的内容复述一遍,这种回答基本也是可以的。不过毕竟Java虚拟机和Android虚拟机的GC还是有些差别的,如果自己阅读过Android虚拟机GC相关资料或者自己分析过源码的话,可以从Android虚拟机的角度解释GC,比如Android虚拟机里MarkSweep算法的增量回收、并行回收等,后台GC和前台GC、VisitRoot的执行过程、GC的触发方式、TLAB的处理、ConcurrentGC的原理、堆的Trim过程、内存碎片的解决、Reference的处理、finalize函数调用等展开讲。如果你对一个机制很熟悉的话,可以把话题引到这上面去,然后一层层对这个知识点深入讲解,这样可以提升面试官对你的等级评价。
|
||||
|
||||
算法
|
||||
|
||||
算法是一定要复习的,在很多面试的过程中都会穿插算法题。面试的算法题一般不会很难,可以分为基础的数据结构,比如数组、链表、栈、队列、二叉树、堆的使用,这几种常见的数据结构的基础操作一定要很熟悉,比如链表逆置、删除、获取第K个元素、判断是否有环等,二叉树翻转、深度遍历、层级遍历、求树深度、公共父节点等。另一种是常见的搜索、排序算法,这两类算法出现频率很高,一定要知道它们常见的几种实现方式,比如排序方式有冒泡、快排、插入、归并、堆排序等。注意这里一定不要简单地去记忆算法实现,因为面试的时候可能不会直接让你写出对应的算法,会出一些使用搜索或者排序算法来实现的题目,这类题目你可以去LeetCode上通过标签过滤出来。
|
||||
|
||||
|
||||
|
||||
另一部分的算法题可能集中在贪心、动态规划、分治算法、深搜广搜等,这一类的算法相对需要一些技巧性。但面试算法题通常不需要太多行代码就能完成,一般都是在几十行内就能完成的,所以你可以优先去找一些经典题目来做,比如爬楼梯、最大子序和等。但也会有一些相对复杂的题目是几种算法结合在一起的,比如二叉树的最大路径和就是深度搜索和动态规划一起使用的题目。除此之外,也可能会遇到通过其他问题引申出的一些算法题目,比如HashMap可能会引申出红黑树的实现等。这些都有可能需要准备,虽然它可能不会成为你整个面试的一个绊脚石,但有可能成为你获取一个高评价的筹码。
|
||||
|
||||
“临时抱佛脚”对于算法的学习和积累作用不是很大,因此需要我们在平时繁忙的工作中抽出一些时间来复习,你也可以去LeetCode、LintCode上刷刷题。另外,虽然大部分面试的算法题目都是LeetCode上的简单题目,但你同样也需要关注一些中等和困难难度的经典题目。
|
||||
|
||||
总结
|
||||
|
||||
今天我并没有涉及太多具体的面试题,更多侧重的是如何准备面试,而面试的准备其实是在我们平时工作过程中一点一滴积累的,复习只是作为一种在面试前巩固知识的手段。复习的过程主要是我们对知识点的整理和总结,你可以想一下在面试的时候可能会遇到的问题,以及该如何去表达。但是我想说,虽然“临时抱佛脚”的准备可能有时有用,但是在短时间内靠“突击”是很难理解到某个知识点更加深度层次的内容,而且知识面的广度也是需要时间和经验去积累的。所以不管你是否需要面试,在平时工作过程中都需要多思考、多训练、多总结,在有需要的时候可以厚积薄发。
|
||||
|
||||
最后,如果你也在准备面试,可以在留言区分享一下你的准备情况和面试心得。当然你也可以留下你遇到的面试问题,把它分享给其他同学。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
|
||||
|
147
专栏/Android开发高手课/Native下如何获取调用栈?.md
Normal file
147
专栏/Android开发高手课/Native下如何获取调用栈?.md
Normal file
@ -0,0 +1,147 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
Native下如何获取调用栈?
|
||||
你好,我是simsun,曾在微信从事Android开发,也是开源爱好者、Rust语言“铁粉”。应绍文邀请,很高兴可以在“高手课”里和你分享一些编译方面的底层知识。
|
||||
|
||||
当我们在调试Native崩溃或者在做profiling的时候是十分依赖backtrace的,高质量的backtrace可以大大减少我们修复崩溃的时间。但你是否了解系统是如何生成backtrace的呢?今天我们就来探索一下backtrace背后的故事。
|
||||
|
||||
下面是一个常见的Native崩溃。通常崩溃本身并没有任何backtrace信息,可以直接获得的就是当前寄存器的值,但显然backtrace才是能够帮助我们修复Bug的关键。
|
||||
|
||||
pid: 4637, tid: 4637, name: crasher >>> crasher <<<
|
||||
signal 6 (SIGABRT), code -6 (SI_TKILL), fault addr --------
|
||||
Abort message: 'some_file.c:123: some_function: assertion "false" failed'
|
||||
r0 00000000 r1 0000121d r2 00000006 r3 00000008
|
||||
r4 0000121d r5 0000121d r6 ffb44a1c r7 0000010c
|
||||
r8 00000000 r9 00000000 r10 00000000 r11 00000000
|
||||
ip ffb44c20 sp ffb44a08 lr eace2b0b pc eace2b16
|
||||
backtrace:
|
||||
#00 pc 0001cb16 /system/lib/libc.so (abort+57)
|
||||
#01 pc 0001cd8f /system/lib/libc.so (__assert2+22)
|
||||
#02 pc 00001531 /system/bin/crasher (do_action+764)
|
||||
#03 pc 00002301 /system/bin/crasher (main+68)
|
||||
#04 pc 0008a809 /system/lib/libc.so (__libc_init+48)
|
||||
#05 pc 00001097 /system/bin/crasher (_start_main+38)
|
||||
|
||||
|
||||
在阅读后面的内容之前,你可以先给自己2分钟时间,思考一下系统是如何生成backtrace的呢?我们通常把生成backtrace的过程叫作unwind,unwind看似和我们平时开发并没有什么关系,但其实很多功能都是依赖unwind的。举个例子,比如你要绘制火焰图或者是在崩溃发生时得到backtrace,都需要依赖unwind。
|
||||
|
||||
书本中的unwind
|
||||
|
||||
1. 函数调用过程
|
||||
|
||||
如果你在大学时期修过汇编原理这门课程,相信你会对下面的内容还有印象。下图就是一个非常标准的函数调用的过程。
|
||||
|
||||
|
||||
|
||||
|
||||
首先假设我们处于函数main()并准备调用函数foo(),调用方会按倒序压入参数。此时第一个参数会在调用栈栈顶。
|
||||
|
||||
调用invoke foo()伪指令,压入当前寄存器EIP的值到栈,然后载入函数foo()的地址到EIP。
|
||||
|
||||
此时,由于我们已经更改了EIP的值(为foo()的地址),相当于我们已经进入了函数foo()。在执行一个函数之前,编译器都会给每个函数写一段序言(prologue),这里会压入旧的EBP值,并赋予当前EBP和ESP新的值,从而形成新的一个函数栈。
|
||||
|
||||
下一步就行执行函数foo()本身的代码了。
|
||||
|
||||
结束执行函数foo() 并准备返回,这里编译器也会给每个函数插入一段尾声(epilogue)用于恢复调用方的ESP和EBP来重建之前函数的栈和恢复寄存器。
|
||||
|
||||
执行返回指令(ret),被调用函数的尾声(epilogue)已经恢复了EBP和ESP,然后我们可以从被恢复的栈中依次pop出EIP、所有的参数以及被暂存的寄存器的值。
|
||||
|
||||
|
||||
读到这里,相信如果没有学过汇编原理的同学肯定会有一些懵,我来解释一下上面提到的寄存器缩写的具体含义,上述命名均使用了x86的命名方式。讲这些是希望你对函数调用有一个初步的理解,其中有很多细节在不同体系结构、不同编译器上的行为都有所区别,所以请你放松心情,跟我一起继续向后看。
|
||||
|
||||
|
||||
EBP:基址指针寄存器,指向栈帧的底部。-
|
||||
在ARM体系结构中,R11(ARM code)或者R7(Thumb code)起到了类似的作用。在ARM64中,此寄存器为X29。-
|
||||
ESP:栈指针寄存器,指向栈帧的栈顶 , 在ARM下寄存器为R13。-
|
||||
EIP:指令寄存器,存储的是CPU下次要执行的指令的地址,ARM下为PC,寄存器为R15。
|
||||
|
||||
|
||||
2. 恢复调用帧
|
||||
|
||||
如果我们把上述过程缩小,站在更高一层视角去看,所有的函数调用栈都会形成调用帧(stack frame),每一个帧中都保存了足够的信息可以恢复调用函数的栈帧。
|
||||
|
||||
|
||||
|
||||
我们这里忽略掉其他不相关的细节,重点关注一下EBP、ESP和EIP。你可以看到EBP和ESP分别指向执行函数栈的栈底和栈顶。每次函数调用都会保存EBP和EIP用于在返回时恢复函数栈帧。这里所有被保存的EBP就像一个链表指针,不断地指向调用函数的EBP。 这样我们就可以此为线索,十分优雅地恢复整个调用栈。
|
||||
|
||||
|
||||
|
||||
这里我们可以用下面的伪代码来恢复调用栈:
|
||||
|
||||
void debugger::print_backtrace() {
|
||||
auto curr_func = get_func_from_pc(get_pc());
|
||||
output_frame(curr_func);
|
||||
|
||||
auto frame_pointer = get_register_value(m_pid, reg::rbp);
|
||||
auto return_address = read_mem(frame_pointer+8);
|
||||
|
||||
while (dwarf::at_name(curr_func) != "main") {
|
||||
curr_func = get_func_from_pc(ret_addr);
|
||||
output_frame(curr_func);
|
||||
frame_pointer = read_mem(frame_pointer);
|
||||
return_address = read_mem(frame_pointer+8);
|
||||
}
|
||||
|
||||
|
||||
但是在ARM体系结构中,出于性能的考虑,天才的开发者为了节约R7/R11寄存器,使其可以作为通用寄存器来使用,因此无法保证保存足够的信息来形成上述调用栈的(即使你向编译器传入了“-fno-omit-frame-pointer”)。比如下面两种情况,ARM就会不遵循一般意义上的序言(prologue),感兴趣的同学可以具体查看APCS Doc。
|
||||
|
||||
|
||||
函数为叶函数,即在函数体内再没有任何函数调用。
|
||||
|
||||
函数体非常小。
|
||||
|
||||
|
||||
Android中的unwind
|
||||
|
||||
我们知道大部分Android手机使用的都是ARM体系结构,那在Android中需要如何进行unwind呢?我们需要分两种情况分别讨论。
|
||||
|
||||
1. Debug版本unwind
|
||||
|
||||
如果是Debug版本,我们可以通过“.debug_frame”(有兴趣的同学可以了解一下DWARF)来帮助我们进行unwind。这种方法十分高效也十分准确,但缺点是调试信息本身很大,甚至会比程序段(.TEXT段)更大,所以我们是无法在Release版本中包含这个信息的。
|
||||
|
||||
|
||||
DWARF 是一种标准调试信息格式。DWARF最早与ELF文件格式一起设计, 但DWARF本身是独立的一种对象文件格式。本身DAWRF和ELF的名字并没有任何意义(侏儒、精灵,是不是像魔兽世界的角色 :)),后续为了方便宣传,才命名为Debugging With Attributed Record Formats。引自wiki
|
||||
|
||||
|
||||
2. Release版本unwind
|
||||
|
||||
对于Release版本,系统使用了一种类似“.debug_frame”的段落,是更加紧凑的方法,我们可以称之为unwind table,具体来说在x86和ARM64平台上是“.eh_frame”和“.eh_frame_hdr”,在ARM32平台上为“.ARM.extab”和“.ARM.exidx”。
|
||||
|
||||
由于ARM32的标准早于DWARF的方法,所有ARM使用了自己的实现,不过它们的原理十分接近,后续我们只讨论“.eh_frame”,如果你对ARM32的实现特别感兴趣,可以参考ARM-Unwinding-Tutorial。
|
||||
|
||||
“.eh_frame section”也是遵循DWARF的格式的,但DWARF本身十分琐碎也十分复杂,这里我们就不深入进去了,只涉及一些比较浅显的内容,你只需要了解DAWRF使用了一个被称为DI(Debugging Information Entry)的数据结构,去表示每个变量、变量类型和函数等在debug程序时需要用到的内容。
|
||||
|
||||
“.eh_frame”使用了一种很聪明的方法构成了一个非常大的表,表中包含了每个程序段的地址对应的相应寄存器的值以及返回地址等相关信息,下面就是这张表的示例(你可以使用readelf --debug-dump=frames-interp去查看相应的信息,Release版中会精简一些信息,但所有帮助我们unwind的寄存器信息都会被保留)。
|
||||
|
||||
|
||||
|
||||
“.eh_frame section”至少包含了一个CFI(Call Frame Information)。每个CFI都包含了两个条目:独立的CIE(Common Information Entry)和至少一个FDE(Frame Description Entry)。通常来讲CFI都对应一个对象文件,FDE则描述一个函数。
|
||||
|
||||
“.eh_frame_hdr section”包含了一系列属性,除了一些基础的meta信息,还包含了一列有序信息(初始地址,指向“.eh_frame”中FDE的指针),这些信息按照function排序,从而可以使用二分查找加速搜索。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
总的来说,unwind第一个栈帧是最难的,由于ARM无法保证会压基址指针寄存器(EBP)进栈,所以我们需要借助一些额外的信息(.eh_frame)来帮助我们得到相应的基址指针寄存器的值。即使如此,生产环境还是会有各种栈破坏,所以还是有许多工作需要做,比如不同的调试器(GDB、LLDB)或者breakpad都实现了一些搜索算法去寻找潜在的栈帧,这里我们就不展开讨论了,感兴趣的同学可以查阅相关代码。
|
||||
|
||||
扩展阅读
|
||||
|
||||
下面给你一些外部链接,你可以阅读GCC中实现unwind的关键函数,有兴趣的同学可以在调试器中实现自己的unwinder。
|
||||
|
||||
|
||||
_Unwind_Backtrace
|
||||
|
||||
uw_frame_state_for
|
||||
|
||||
uw_update_context
|
||||
|
||||
uw_update_context_1
|
||||
|
||||
|
||||
|
||||
|
||||
|
91
专栏/Android开发高手课/专栏学得苦?可能你还需要一份配套学习书单.md
Normal file
91
专栏/Android开发高手课/专栏学得苦?可能你还需要一份配套学习书单.md
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
专栏学得苦?可能你还需要一份配套学习书单
|
||||
你好,我是张绍文。专栏已经发布了一段时间,很多同学在学习专栏时问我,想补充一些基础知识可以参考哪些图书。今天我就结合专栏的编排,给你推荐几本我看过并且对我帮助很大的图书。推荐的书单不在于数量,而在于希望尽可能覆盖Android开发工程师进阶学习的路径,只有掌握牢固的基础知识,才能在进阶的道路上走得平稳。专栏把进阶的各个主题由点到线串联起来,但这背后必然少不了一些基础的、底层的知识进行支撑,而这些经典的图书涵盖的知识点比较全面,即使遇到问题时放在手边也是很好的参考书。
|
||||
|
||||
作为一名Android开发工程师,你需要学习一些Linux的基础知识,在做优化时可以有更好的思路。
|
||||
|
||||
关于Linux学习,我推荐:
|
||||
|
||||
性能之巅
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
最强Android书:架构大剖析
|
||||
|
||||
|
||||
|
||||
戳此购买
|
||||
|
||||
|
||||
|
||||
极客时间专栏:Linux性能优化实战
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如果想更好地学习虚拟机以及Hook相关的知识,你需要对C++以及编译原理有一定的了解。
|
||||
|
||||
关于虚拟机,我推荐:
|
||||
|
||||
程序员的自我修养——链接、装载与库
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
垃圾回收算法手册
|
||||
|
||||
|
||||
|
||||
戳此购买
|
||||
|
||||
|
||||
|
||||
关于编程语言,我推荐:
|
||||
|
||||
More Effective C++
|
||||
|
||||
|
||||
|
||||
戳此购买
|
||||
|
||||
|
||||
|
||||
Effective Java中文版(第3版)
|
||||
|
||||
|
||||
|
||||
戳此购买
|
||||
|
||||
|
||||
|
||||
其他的知识,例如网络、数据库的一些细分领域,我推荐:
|
||||
|
||||
Web性能权威指南
|
||||
|
||||
|
||||
|
||||
戳此购买
|
||||
|
||||
|
||||
|
||||
UNIX网络编程 卷1:套接字联网API(第3版)
|
||||
|
||||
|
||||
|
||||
戳此购买
|
||||
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
|
||||
|
95
专栏/Android开发高手课/专栏学得苦?可能是方法没找对.md
Normal file
95
专栏/Android开发高手课/专栏学得苦?可能是方法没找对.md
Normal file
@ -0,0 +1,95 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
专栏学得苦?可能是方法没找对
|
||||
|
||||
各位同学,我是《Android开发高手课》的编辑Shawn,很高兴可以和你分享专栏里同学们自己的故事。小说家马塞尔·普鲁斯特说过:“真正的发现之旅,不在于寻找新的风景,而在于有新的视角”。专栏便是给了你一个“高手”的视角,让你重新审视自己工作中处理问题的方法、掌握新的技能,进而让自己也成为真正的高手。这个过程一定是艰辛的,但只要坚持下去,一定能大有收获。
|
||||
|
||||
|
||||
|
||||
|
||||
我是Kenny,来自广州,加入到《Android开发高手课》,是希望自己可以深入学习一些Android的高级知识。
|
||||
|
||||
学习的过程总是枯燥的,我的方法是保持兴趣和新鲜感。你可以把专栏的课程当作是一本武功秘籍,每一期就相当于一种招式,这样就会很有期待。我基本会在专栏更新第一时间就学习一遍,然后再结合Google查找跟专栏相关的知识点,把它们融会贯通。另外,专栏里提供的练习我也会先自己思考,然后再尝试去写demo验证,最后跟老师给的Sample进行对照,对比分析我和老师给的方案的优劣。
|
||||
|
||||
如果你正处学习的迷茫期,我建议学习前先带着问题,比如专栏更新后,先想想自己在工作中是否遇到过和这期主题相关的技术问题。然后就是兴趣了,对技术始终保持兴趣,学习的动力也会更充沛,在学习过程中也会产生更多有价值的思考。
|
||||
|
||||
我目前学习最大的收获是思考问题、处理问题的思维方式的变化,在对待“黑科技”也会更慎重了。最主要的是思维得到了发散,解决问题时也会多考虑如何举一反三。
|
||||
|
||||
我的2019年“小目标”:目前在从事产品性能优化的工作,期待2019能把产品做成竞品间各项性能指标第一!
|
||||
|
||||
|
||||
|
||||
我是ZYW,目前在从事移动端开发,从2008年开始到今天已经10个年头了。
|
||||
|
||||
我主要是在更新当天晚上看专栏的内容,看完后再做练习,遇到不会的内容会去搜索相关的知识点。
|
||||
|
||||
在当下,不论是工作还是学习,焦虑肯定是有的。坚持,学习从来就不是一蹴而就的事情。我毕业10年了,IT编码也10年了,现在还在坚持。IT技术更新很快,一定要在合适的时间做合适的事情,不要怕困难,更不要放弃。
|
||||
|
||||
我的2019年“小目标”:好好的工作,好好照顾家人,能在Android的道路一直走下去,坚持。
|
||||
|
||||
|
||||
|
||||
我是Owen,工作3年做过OA和手机的项目,目前在深圳一家上市公司负责海外工具类开发。订阅这个专栏主要是听过江湖上绍文老师的“传说”,再加上我对讲的内容也比较感兴趣。
|
||||
|
||||
一般专栏更新之后,我会选择第一时间听一遍音频,或是在上下班的地铁上,或是在睡觉前。我会利用零碎时间反复听音频,把整片的时间用来反复阅读课程文章,然后根据文章的Sample自己亲自实践。因为在实践的过程中会有很多坑要爬,需要自己补充老师提到的知识点,专栏的篇幅也有限,有些知识点就需要自己去查找资料来学习。所以一篇文章我会反复阅读,反复查看相关知识点,反复实践。遇到不懂的就问绍文老师、同学和同事,然后形成自己的理解和总结,争取自己也能写一点总结分享出来。
|
||||
|
||||
坚持学习的过程,我感觉自己拓宽了知识面,对知识点的体会也加深了。移动开发领域大佬、高手如云,必须孜孜不倦地学习和交流,才能做到真正的理解并且达到一定的高度。
|
||||
|
||||
这个专栏整体来说适合爬坡进阶,如果坚持学完会有很多收获。有些知识点我平时没太注意,或者有些知识点平时干脆都没听过,学完给我的感觉我们并不是简单的应用开发,还有太多可以精深的东西。
|
||||
|
||||
|
||||
|
||||
大家好,我是Seven,来自四川成都。现在在某家医疗公司担任Android开发,主要做的是摄像头开发、实时分析相机预览数据这块,之前还做过一年的视频点播和直播的底层开发。在这两年的工作中,一直想把自己的半吊子基础给打实。
|
||||
|
||||
说来也巧,曾经的项目中用到了Tinker,也就顺理成章进入了Tinker交流群,也在群内交流了一段时间。某一天,绍文老师在群里推荐了他的极客时间专栏;与此同时,众多著名Android大佬和公众号也争相推广,我已经站不住了,技术的浪潮已经向我扑来,我非接不可,同时也希望自己能够得到一次深度成长的机会,所以我选择了订阅,这也是对自己负责吧。
|
||||
|
||||
刚开始时我是很用心的每天都听,然后课后Sample也在认真做,但当我发现Sample搞不定的时候,我就改变了学习态度:专栏更新,我也及时阅读,Sample做不出来就放在那里,反正保持同步阅读专栏,有时也在专栏里留些无关痛痒的话,以为这样就是学习。直到某一天,专栏出了一篇文章《让Sample跑起来 | 热点问题答疑第1期》,我才恍然大悟,自己当前的学习状态不对,不应该这样学习,应该一丝不苟,脚踏实地才对。那时的我只是看上去在学习而已,实际根本没有任何收获。于是我改正了自己的态度,重新打开第1期重新学习,认真阅读并理解文中的每一个字,遇到不懂的地方就看文中给的链接或者自己查资料慢慢摸索。后来发现这样学习虽然慢,但是我心里很踏实,而且学到的东西其实不止是专栏中的内容,因为在查资料的时候,总会查到别的东西,可以顺带学习,并且做了笔记。我现在的学习状态大概就是这样,我还是很满意自己做出的改变。
|
||||
|
||||
专栏从开始看到现在这么久了,最主要提升的是我的自觉性,看到不懂的知识点我并不会害怕,而是想着要怎么去弄懂它,也不会消极的对待所谓“高深”的知识了。现在遇到一个问题,我脑袋里面想的是要去解决它,先试着自己去解决,解决不了的话说明知识不到位,需要深入学习。
|
||||
|
||||
如果你觉得专栏里的知识很难,也不要消极对待,很多大的知识点都是由许多小知识点组成的,只要分而治之,就可以很快建立自信。并且这些小的知识点排列组合会产生无数种知识,所以这里也建议同学们认真对待基础知识,比如专栏里提到的Linux有关的知识,这些都是保质期很长的知识,希望我们可以一起坚持下来。
|
||||
|
||||
我的2019年“小目标”:2018年已经过去,希望2019年的自己,能够对移动开发的基础知识有更深刻的理解,希望自己能够在Android源码分析研究这一块大有长进,一起加油吧!
|
||||
|
||||
|
||||
|
||||
我是小洁,是一名工作接近4年的Android开发,坐标广东东莞,公司是做儿童电话手表的,我在团队中负责性能相关优化以及组件化优化方面工作。由于目前工作的任务专栏有所涉及,加上身边同事的推荐,所以想从专栏中借鉴学习老师的各种经验。
|
||||
|
||||
由于工作原因,我一般的学习时间主要是在晚上。我会根据自己对当前内容是否感兴趣,以及是否感觉重要而去学习并完成课后的练习。通过专栏我收获了一些老师的经验之谈和对某些方案的思考,能力也有所提升吧。
|
||||
|
||||
我认为学习还是靠个人毅力,每当学习感到枯燥或者遇到障碍时,可以沉下来问问自己开始学习的目的,重新调整自己再一步步看下去,终会有豁然开朗的时候。
|
||||
|
||||
我的2019年“小目标”:希望自己能实现一个性能监控分析。
|
||||
|
||||
|
||||
|
||||
我是希夷,我订阅专栏的目的是想提高自己的眼界和能力。
|
||||
|
||||
我主要是利用空闲的时间学习专栏,并且会做些笔记,但专栏的作业和练习我坚持得不够(捂脸)。我觉得学习专栏时感觉难是正常的,如果不觉得难就说明这个专栏对你提升有限。我认为还是贵在坚持,一遍不懂就多看几遍、多练几遍,勤能补拙,我们学习都会有这个过程,也需要我们给自己打打气。
|
||||
|
||||
同样我觉得Android开发,基础真的很重要,我感觉自己有很多基础的东西要补。通过这个专栏,我看到了大公司在移动开发这块使用的比较新的技术,确实拓展了自己的视野。
|
||||
|
||||
我的2019年“小目标”:希望把Kotlin和Flutter用到项目中,把从专栏中学到的东西落地到项目实践上,也希望自己有更高的收入。
|
||||
|
||||
|
||||
|
||||
我是志伟,从12年开始在芯片企业从事Android ROM开发,15年开始在移动互联网企业做Android开发。我希望可以从专栏里更加全面地了解Android开发的技术,领略绍文面对亿级App各种技术问题的思考方式和处理方法。
|
||||
|
||||
专栏更新当天晚上我都会认真学习,通过文章里附带的外部资料、自己查询网上其他资料和专栏提供的Sample进行练习,从中提炼自己需要的知识。我还会结合自己的实际工作、以前的经历,推敲一下自己在面对专栏里的问题时会如何思考,我会怎么去做。
|
||||
|
||||
专栏涉及技术面广,又有深度,花时间深入钻研,肯定有很大的收获。比如我自己,通过学习扩展了自己的知识面,对一些技术的理解也更加深入,很多问题也有了解决方案。专栏定位 “高手”,肯定有觉得难或者不懂的知识点,这就更需要坚持,因为这正是成长的机会。另外,还可以直接在专栏留言区跟作者进行交流,从中我也获益良多。
|
||||
|
||||
绍文经历过多个App日活过亿的成长,期间面临的各种技术难题也是其他App共同存在的,但我们不是每个人都有这样的机会去经历。通过专栏,可以以高度“浓缩”的方式经历一遍,这也是十分宝贵的收获。
|
||||
|
||||
我的2019年“小目标”:朝架构师的方向“一路走到黑”。
|
||||
|
||||
|
||||
听完身边同学的故事,你有没有想和其他同学分享的故事呢?欢迎你在留言区也写写自己学习专栏的方法和体会,我们会在下一期答疑文章发布时,选出留言点赞数最高的3位,送出“学习加油礼包”,期待你的分享。
|
||||
|
||||
|
||||
|
||||
|
||||
|
103
专栏/Android开发高手课/程序员修炼之路设计能力的提升途径.md
Normal file
103
专栏/Android开发高手课/程序员修炼之路设计能力的提升途径.md
Normal file
@ -0,0 +1,103 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
程序员修炼之路 设计能力的提升途径
|
||||
|
||||
你好,我是张绍文,今天我要和你分享我的朋友长元的一篇文章,主题是设计能力的提升途径。专栏已经进入架构演进模块,由于每个人对架构的理解都不同,在工作中也会遇到各种各样的架构设计问题,很多时候我们的架构设计能力都是靠不断的理论学习和在设计实践中不断摸索提高的,因此在成为设计高手的道路上,我们肯定或多或少有些自己的经验和体会,当然也少不了踩坑。今天长元分享的设计能力提升路径,希望可以把他的经验分享给你,你可以参考他的提升路径来强化自己的设计能力,在高手的修炼之路上少走弯路。
|
||||
|
||||
|
||||
每当我做完一次内部设计培训以后,经常有同学来问我:如何才能快速提升自己的设计能力?我觉得这个问题非常有代表性,代表了一大波程序员在艰辛修炼路上的心声。今天我就来分享一下我所理解的程序员设计能力的提升路径,也欢迎你留言写写你的思考与体会。
|
||||
|
||||
1. 编码历练
|
||||
|
||||
代码行经验是个非常重要的东西,当你还没有1万行代码经验的时候,如果你来问我如何提升设计能力这个问题,我只能告诉你不要太纠结,看看理论就好,老老实实先写代码吧。
|
||||
|
||||
一个程序员平均每天码代码的速度是200~300行。你可能会说,我一天怎么也要写上1000行吧?别忘了,当你码完代码后,你还需要测试、调试、优化、Bug Fix,这些时间你没法一直码代码的。
|
||||
|
||||
编码规范就不多说了,如果你的代码还是杂乱无章的状态,就先别谈什么设计与架构了,先把基础的工作做好再谈其他的吧。
|
||||
|
||||
另外,作为“代码洁癖患者”,推荐你不要在写完代码后,再做批量格式化处理,或者手工再去整理代码,而是应该每敲一个字符,都是符合规范的。习惯真的很重要,有时在招聘面试的时候,我真想添加一个环节,现场编写程序完成一个简单但容易出错的任务,考察一下你的代码基本功。
|
||||
|
||||
2. 理论学习
|
||||
|
||||
简单说就是看书、看博客,学习你能得到的所有资源,但前提是内容质量要高。例如图书,我推荐:《重构 改善既有代码的设计》《敏捷软件开发:原则、模式与实践》《UML和模式应用》《设计模式》等,其他你还需要学习面向对象设计原则(五大原则)。
|
||||
|
||||
《设计模式》是本很古老的书了,只有短短200页,但是可能这是最难看懂的一本书了,可能一个月都看不完(看小说的话,200页3个小时也许就看完了吧)。而且就算看完了,也不会全看懂,很可能看懂的内容不超过30%。我想说的是,看不懂没关系,认真看了就行,不用太纠结,因为这不能说明什么问题。
|
||||
|
||||
另外,我想说一下,多线程技术是程序员必须掌握的,而且需要理解透彻。现在的高级技术例如GCD,会掩盖你对多线程理解不足的问题,因为使用起来实在太简单了。另外,别说你没写过多线程依然完成了复杂的项目,更别说你随手写出的多线程代码好像也没出什么问题啊,你可以试试把你的代码给技术好的同事看看,分分钟写个Demo让它出错乃至崩溃。
|
||||
|
||||
3. 实践
|
||||
|
||||
现在,你已经具备了一定的编码经验,而且已经学习了足够的理论知识,接下来就是真正练手的时候了。好好反复思考你学习的这些理论知识,要如何运用到项目中去,通过身体力行的实践,一定要把那些理论搞清楚,用于指导你的实践。在实践的过程中,你要收起从前的自信,首先否定自己以前的做法,保证每次做出的东西相比以前是有进步、有改进的。
|
||||
|
||||
4. 重温理论
|
||||
|
||||
你已经能看到自己的进步了,发现比以前做得更好了,但是总感觉还不够,好像有瓶颈似的,恭喜你,已经可以看到你未来的潜力了。
|
||||
|
||||
重新拿起书本,重温一遍之前看的那些似懂非懂的东西,你会发现之前没弄懂的内容,现在豁然开朗了,不再有那种难于理解的晦涩感了。而且就算是以前你觉得自己已经理解的内容,再看一遍的话,通常也会有新的收获。
|
||||
|
||||
5. 再实践
|
||||
|
||||
这个阶段,你已经掌握了较多的知识,不但实践经验丰富,各种理论也能手到擒来了。但是,你发现你的设计依然不够专业,而且回过头去看以前写的代码,你会惊讶:天啊,这是谁写的代码,怎么能这样干!然后,就不多说了…此时,你已经进入了自省的阶段,掌握了适合自己的学习方法,之后再学习什么新东西,都不会再难住你了。
|
||||
|
||||
6. 总结
|
||||
|
||||
先别太得意(不信?那你去给团队分享一次讲座试试),你还需要总结,总结自己的学习方法、总结项目经验、总结设计理论的知识。
|
||||
|
||||
如果你能有自己独到的理解,而不是停留在只会使用成熟的设计模式什么的,能根据自己的经验教训总结出一些设计原则,那自然是极好的。
|
||||
|
||||
7. 分享
|
||||
|
||||
分享是最好的学习催化剂,当你要准备一次培训分享的时候,你会发现先前以为已经理解的东西其实并没有完全理解透彻,因为你无法把它讲清楚,实际上还是研究得不够透彻。这时会迫使你再重新深入学习,做到融汇贯通,然后你才敢走上讲台。否则,当别人提问的时候,你根本回答不上来。
|
||||
|
||||
以上,便是我认为的程序员修炼道路的必经阶段。接下来,我再分享几点其他对设计能力提升非常重要的方法。
|
||||
|
||||
|
||||
养成先设计,再编码的习惯。
|
||||
|
||||
|
||||
几乎所有的程序员,一开始都不太愿意写文档,也不太愿意去精心设计,拿到需求总是忍不住那双躁动的手,总觉得敲在键盘上,把一行一行的代码飙出来,才有成就感,才是正确的工作姿势。
|
||||
|
||||
我的建议是,没讨论清楚不要编码,不然你一定会返工。
|
||||
|
||||
|
||||
设计重于编码,接口重于实现。
|
||||
|
||||
|
||||
制定接口的过程,本身就是设计过程,接口一定要反复推敲,尽量做减法而不是加法,在能满足需求的情况下越简单越好。
|
||||
|
||||
另外,不要一个人冥思苦想。可以先简单做一个雏形出来,然后去找使用方沟通,直到对方满意为止。不要完全根据使用需求去设计接口,参考MVVM,ViewModel就是根据View的需要而对Model进行的再封装,不能将这些接口直接设计到Model中。
|
||||
|
||||
|
||||
不盲从设计模式。
|
||||
|
||||
|
||||
设计模式只是一种解决问题的套路方法,你也可以有自己的方法,当然设计模式如果用好了,会让你的设计显得专业、优雅,毕竟前辈们的心血结晶是非常有价值的。但是如果滥用的话,也会导致更严重的问题,甚至可能成为灾难。我觉得面向对象设计原则更加重要,有些原则是必须遵守的(如单向依赖、SRP等),而设计模式本身都是遵守这些原则的,有些模式就是为了遵循某原则而设计出来的。
|
||||
|
||||
抽象不是万能的,在适当的地方使用,需要仔细推敲。当有更好的方案不用抽象就能解决问题时,尽量避免抽象。我见过太多抽象过火、过度设计的案例了,增加了太多维护成本,还不如按照最自然的方式去写。
|
||||
|
||||
|
||||
空杯心态,向身边的同学学习,站在巨人的肩上,站在别人的肩上。
|
||||
|
||||
|
||||
有人提意见,先收下它(无论接受与否)。
|
||||
|
||||
很多程序员都有个“毛病”,就是觉得自己技术牛的不行,不愿意接受别人的意见,尤其是否定意见(文人相轻)。 但是无论是理论的学习,还是编码实践,向身边的同学学习是对自己影响最大的(三人行,必有我师)。
|
||||
|
||||
我自己就经常在跟团队同学讨论中获益,当百思不得其解的时候,把问题抛出来讨论一下,通常都能得到一个最佳方案。
|
||||
|
||||
另外,跟团队其他人讨论还有一个好处,就是当你的设计有妥协或有些不专业的时候,别人看到代码也不会产生质疑,因为他也参与了讨论,你不用花那么多时间去做解释。
|
||||
|
||||
设计期间一定要找其他人一起讨论,我一直比较反对一个人把设计做完、把文档写完,然后才找大家开个评审会那种模式,虽然也有效果,但是效果达不到极致。因为大家没有参与到设计中,通过一次会议的时间理解不一定有那么深,最关键的是,如果在会上发现设计有些问题,但不是致命问题的时候,通常并不会打回重新设计。
|
||||
|
||||
相反,如果前期讨论足够,大家都知道你的思路与方案,而且最后也有设计文档,当其他人阅读你的代码的时候,根本无需你再指引,这样今后在工作交接时都会很顺利,何乐而不为呢?
|
||||
|
||||
最后,我想呼吁一下,当你去修改维护别人的代码时,最好找模块负责人深入讨论沟通一下,让他明白你的需求以及你的方案,请他帮忙评估方案是否可行,是否会踩坑、埋坑等。如果你恰好是模块的负责人,请行使你的权力,拒绝有问题的不符合要求的代码提交入库。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
|
||||
|
184
专栏/Android开发高手课/练习Sample跑起来ASM插桩强化练习.md
Normal file
184
专栏/Android开发高手课/练习Sample跑起来ASM插桩强化练习.md
Normal file
@ -0,0 +1,184 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
练习Sample跑起来 ASM插桩强化练习
|
||||
你好,我是孙鹏飞。
|
||||
|
||||
专栏上一期,绍文讲了编译插桩的三种方法:AspectJ、ASM、ReDex,以及它们的应用场景。学完以后你是不是有些动心,想赶快把它们应用到实际工作中去。但我也还了解到,不少同学其实接触插桩并不多,在工作中更是很少使用。由于这项技术太重要了,可以实现很多功能,所以我还是希望你通过理论 + 实践的方式尽可能掌握它。因此今天我给你安排了一期“强化训练”,希望你可以趁热打铁,保持学习的连贯性,把上一期的理论知识,应用到今天插桩的练习上。
|
||||
|
||||
为了尽量降低上手的难度,我尽量给出详细的操作步骤,相信你只要照着做,并结合专栏上期内容的学习,你一定可以掌握插桩的精髓。
|
||||
|
||||
ASM插桩强化练习
|
||||
|
||||
|
||||
|
||||
在上一期里,Eateeer同学留言说得非常好,提到了一个工具,我也在使用这个工具帮助自己理解ASM。安装“ASM Bytecode Outline”也非常简单,只需要在Android Studio中的Plugin搜索即可。
|
||||
|
||||
|
||||
|
||||
ASM Bytecode Outline插件可以快速展示当前编辑类的字节码表示,也可以展示出生成这个类的ASM代码,你可以在Android Studio源码编译框内右键选择“Show Bytecode Outline“来查看,反编译后的字节码在右侧展示。
|
||||
|
||||
我以今天强化练习中的SampleApplication类为例,具体字节码如下图所示。
|
||||
|
||||
|
||||
|
||||
除了字节码模式,ASM Bytecode Outline还有一种“ASMified”模式,你可以看到SampleApplication类应该如何用ASM代码构建。
|
||||
|
||||
|
||||
|
||||
下面我们通过两个例子的练习,加深对ASM使用的理解。
|
||||
|
||||
1. 通过ASM插桩统计方法耗时
|
||||
|
||||
今天我们的第一个练习是:通过ASM实现统计每个方法的耗时。怎么做呢?请你先不要着急,同样以SampleApplication类为例,如下图所示,你可以先手动写一下希望实现插桩前后的对比代码。
|
||||
|
||||
|
||||
|
||||
那这样“差异”代码怎么样转化了ASM代码呢?ASM Bytecode Outline还有一个非常强大的功能,它可以展示相邻两次修改的代码差异,这样我们可以很清晰地看出修改的代码在字节码上的呈现。
|
||||
|
||||
|
||||
|
||||
“onCreate”方法在“ASMified”模式的前后差异代码,也就是我们需要添加的ASM代码。在真正动手去实现插桩之前,我们还是需要理解一下ASM源码中关于Core API里面ClassReader、ClassWriter、ClassVisitor等几个类的用法。
|
||||
|
||||
我们使用ASM需要先通过ClassReader读入Class文件的原始字节码,然后使用ClassWriter类基于不同的Visitor类进行修改,其中COMPUTE_MAXS和EXPAND_FRAMES都是需要特别注意的参数。
|
||||
|
||||
ClassReader classReader = new ClassReader(is);
|
||||
//COMPUTE_MAXS 说明使用ASM自动计算本地变量表最大值和操作数栈的最大值
|
||||
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
|
||||
ClassVisitor classVisitor = new TraceClassAdapter(Opcodes.ASM5, classWriter);
|
||||
//EXPAND_FRAMES 说明在读取 class 的时候同时展开栈映射帧(StackMap Frame),在使用 AdviceAdapter里这项是必须打开的
|
||||
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
|
||||
|
||||
|
||||
如果要统计每个方法的耗时,我们可以使用AdviceAdapter来实现。它提供了onMethodEnter()和onMethodExit()函数,非常适合实现方法的前后插桩。具体的实现,你可以参考今天强化练习中的TraceClassAdapter的实现:
|
||||
|
||||
private int timeLocalIndex = 0;
|
||||
@Override
|
||||
protected void onMethodEnter() {
|
||||
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
|
||||
timeLocalIndex = newLocal(Type.LONG_TYPE); //这个是LocalVariablesSorter 提供的功能,可以尽量复用以前的局部变量
|
||||
mv.visitVarInsn(LSTORE, timeLocalIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMethodExit(int opcode) {
|
||||
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
|
||||
mv.visitVarInsn(LLOAD, timeLocalIndex);
|
||||
mv.visitInsn(LSUB);//此处的值在栈顶
|
||||
mv.visitVarInsn(LSTORE, timeLocalIndex);//因为后面要用到这个值所以先将其保存到本地变量表中
|
||||
int stringBuilderIndex = newLocal(Type.getType("java/lang/StringBuilder"));
|
||||
mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");
|
||||
mv.visitInsn(Opcodes.DUP);
|
||||
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
|
||||
mv.visitVarInsn(Opcodes.ASTORE, stringBuilderIndex);//需要将栈顶的 stringbuilder 保存起来否则后面找不到了
|
||||
mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);
|
||||
mv.visitLdcInsn(className + "." + methodName + " time:");
|
||||
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
|
||||
mv.visitInsn(Opcodes.POP);//将 append 方法的返回值从栈里 pop 出去
|
||||
mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);
|
||||
mv.visitVarInsn(Opcodes.LLOAD, timeLocalIndex);
|
||||
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
|
||||
mv.visitInsn(Opcodes.POP);//将 append 方法的返回值从栈里 pop 出去
|
||||
mv.visitLdcInsn("Geek");
|
||||
mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);
|
||||
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
|
||||
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);//注意: Log.d 方法是有返回值的,需要 pop 出去
|
||||
mv.visitInsn(Opcodes.POP);//插入字节码后要保证栈的清洁,不影响原来的逻辑,否则就会产生异常,也会对其他框架处理字节码造成影响
|
||||
}
|
||||
|
||||
|
||||
具体实现和我们在ASM Bytecode Outline看到的大同小异,但是这里需要注意局部变量的使用。在练习的例子中用到了AdviceAdapter的一个很重要的父类LocalVariablesSorter,这个类提供了一个很好用的方法newLocal,它可以分配一个本地变量的index,而不用用户考虑本地变量的分配和覆盖问题。
|
||||
|
||||
另一个需要注意的情况是,我们在最后的时候需要判断一下插入的代码是否会在栈顶上遗留不使用的数据,如果有的话需要消耗掉或者POP出去,否则就会导致后续代码的异常。
|
||||
|
||||
这样我们就可以快速地将这一大段字节码完成了。
|
||||
|
||||
2. 替换项目中的所有的new Thread
|
||||
|
||||
今天另一个练习是:替换项目中所有的new Thread,换为自己项目的CustomThread类。在实践中,你可以通过这个方法,在CustomThread增加统计代码,从而实现统计每个线程运行的耗时。
|
||||
|
||||
不过这也是一个相对来说坑比较多的情况,你可以提前考虑一下可能会遇到什么状况。同样我们通过修改MainActivity的startThread方法里面的Thread对象改变成CustomThread,通过ASM Bytecode Outline看看在字节码上面的差异:
|
||||
|
||||
|
||||
|
||||
InvokeVirtual是根据new出来的对象来调用,所以我们只需要替换new对象的过程就可以了。这里需要处理两个指令:一个new、一个InvokeSpecial。在大多数情况下这两条指令是成对出现的,但是在一些特殊情况下,会遇到直接从其他位置传递过来一个已经存在的对象,并强制调用构造方法的情况。
|
||||
|
||||
而我们需要处理这种特殊情况,所以在例子里我们需要判断new和InvokeSpecial是否是成对出现的。
|
||||
|
||||
private boolean findNew = false;//标识是否遇到了new指令
|
||||
@Override
|
||||
public void visitTypeInsn(int opcode, String s) {
|
||||
if (opcode == Opcodes.NEW && "java/lang/Thread".equals(s)) {
|
||||
findNew = true;//遇到new指令
|
||||
mv.visitTypeInsn(Opcodes.NEW, "com/sample/asm/CustomThread");//替换new指令的类名
|
||||
return;
|
||||
}
|
||||
super.visitTypeInsn(opcode, s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
|
||||
//需要排查CustomThread自己
|
||||
if ("java/lang/Thread".equals(owner) && !className.equals("com/sample/asm/CustomThread") && opcode == Opcodes.INVOKESPECIAL && findNew) {
|
||||
findNew= false;
|
||||
mv.visitMethodInsn(opcode, "com/sample/asm/CustomThread", name, desc, itf);//替换INVOKESPECIAL 的类名,其他参数和原来保持一致
|
||||
return;
|
||||
}
|
||||
super.visitMethodInsn(opcode, owner, name, desc, itf);
|
||||
}
|
||||
|
||||
|
||||
new指令的形态相对特殊,比如我们可能会遇到下面的情况:
|
||||
|
||||
new A(new B(2));
|
||||
|
||||
|
||||
字节码如下,你会发现两个new指令连在一起。
|
||||
|
||||
NEW A
|
||||
DUP
|
||||
NEW B
|
||||
DUP
|
||||
ICONST_2
|
||||
INVOKESPECIAL B.<init> (I)V
|
||||
INVOKESPECIAL A.<init> (LB;)V
|
||||
|
||||
|
||||
虽然ASM Bytecode Outline工具可以帮助我们完成很多场景下的ASM需求,但是在处理字节码的时候还是需要考虑很多种可能出现的情况,这点需要你注意一下每个指令的特征。所以说在稍微复杂一些的情况下,我们依然需要对ASM字节码以及ASM源码中的一些工具类有所了解,并且需要很多次的实践,毕竟实践是最重要的。
|
||||
|
||||
最后再留给你一个思考题,如何给某个方法增加一个try catch呢?你可以尝试一下在今天强化练习的代码里根据我提供的插件示例实现一下。
|
||||
|
||||
强化练习的代码:https://github.com/AndroidAdvanceWithGeektime/Chapter-ASM
|
||||
|
||||
福利彩蛋
|
||||
|
||||
学到这里相信你肯定会认同成为一个Android开发高手的确不容易,能够坚持学习和练习,并整理输出分享更是不易。但是也确实有同学坚持下来了。
|
||||
|
||||
还记得在专栏导读里我们的承诺吗?我们会选出坚持参与学习并分享心得的同学,送出2019年GMTC大会的门票。今天我们就来兑现承诺,送出价值4800元的GMTC门票一张。获得这个“大礼包”的同学是@唯鹿,他不仅提交了作业,更是在博客里分享了每个练习Sample实现的过程和心得,并且一直在坚持。我在文稿里贴了他的练习心得文章链接,如果你对于之前的练习Sample还有不明白的地方,可以参考唯鹿同学的实现过程。
|
||||
|
||||
|
||||
Android 开发高手课 课后练习(1 ~ 5)
|
||||
|
||||
Android 开发高手课 课后练习(6 ~ 8,12,17,19)
|
||||
|
||||
专栏第4期完成作业
|
||||
|
||||
专栏第19期完成作业
|
||||
|
||||
|
||||
GMTC门票还有剩余,给自己一个进阶的机会,从现在开始一切都还来得及。
|
||||
|
||||
|
||||
小程序、Flutter、移动AI、工程化、性能优化…大前端的下一站在哪里?GMTC 2019全球大前端技术大会将于6月北京盛大开幕,来自Google、BAT、美团、京东、滴滴等一线前端大牛将与你面对面共话前端那些事,聊聊大前端的最新技术趋势和最佳实践案例。-
|
||||
目前大会最低价7折购票火热进行中,讲师和议题也持续招募中,点击下方图片了解更多大会详情!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
397
专栏/Android开发高手课/练习Sample跑起来唯鹿同学的练习手记第1辑.md
Normal file
397
专栏/Android开发高手课/练习Sample跑起来唯鹿同学的练习手记第1辑.md
Normal file
@ -0,0 +1,397 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
练习Sample跑起来 唯鹿同学的练习手记 第1辑
|
||||
|
||||
你好,我是张绍文,今天我要跟你分享唯鹿同学完成专栏课后练习作业的“手记”。专栏承诺会为坚持完成练习作业的同学送出GMTC大会门票,唯鹿同学通过自己的努力和坚持,为自己赢得了GMTC大会的门票。
|
||||
|
||||
如果你还没开始练习,我强烈建议你花一些时间在练习上,因为每个练习的Sample都是我和学习委员花费很多精力精心准备的,为的是让你在学习完后可以有机会上手实践,帮你尽快消化专栏里的知识并为自己所用。
|
||||
|
||||
|
||||
大家好,我是唯鹿,来自西安,从事Android开发也有近5年的时间了,目前在做智慧社区方面的业务。我自己坚持写博客已经有三年多的时间了,希望分享自己在工作、学习中的收获。
|
||||
|
||||
先说说我学习专栏的方法,专栏更新当天我就会去学习,但是难度真的不小。我对自己的要求并不是看一遍就要搞明白,而是遇见不懂的地方立马查阅资料,要做到大体了解整篇内容。之后在周末的时候我会集中去做Sample练习,一边复习本周发布的内容,一边用写博客的方式记录练习的结果。
|
||||
|
||||
后面我计划专栏结束后再多看、多练习几遍,不断查漏补缺。说真的,我很喜欢《Android开发高手课》的难度,让我在完成练习作业时有种翻越高山的快感。最后,希望同学们一起坚持,享受翻越高山带来的成就感。
|
||||
|
||||
|
||||
|
||||
最近在学习张绍文老师的《Android开发高手课》。课后作业可不是一般的难,最近几天抽空练习了一下,结合老师给的步骤和其他同学的经验,完成了前5课的内容。
|
||||
|
||||
我整理总结了一下,分享出来,希望可以帮到一起学习的同学(当然希望大家尽量靠自己解决问题)。
|
||||
|
||||
Chapter01
|
||||
|
||||
|
||||
例子里集成了Breakpad来获取发生Native Crash时候的系统信息和线程堆栈信息。通过一个简单的Native崩溃捕获过程,完成minidump文件的生成和解析,在实践中加深对Breakpad工作机制的认识。
|
||||
|
||||
|
||||
直接运行项目,按照README.md的步骤操作就行。
|
||||
|
||||
中间有个问题,老师提供的minidump_stackwalker工具在macOS 10.14以上无法成功执行,因为没有libstdc++.6.dylib库,所以我就下载Breakpad源码重新编译了一遍。
|
||||
|
||||
使用minidump_stackwalker工具来根据minidump文件生成堆栈跟踪log,得到的crashLog.txt文件如下:
|
||||
|
||||
Operating system: Android
|
||||
0.0.0 Linux 4.9.112-perf-gb92eddd #1 SMP PREEMPT Tue Jan 1 21:35:06 CST 2019 aarch64
|
||||
CPU: arm64 // 注意点1
|
||||
8 CPUs
|
||||
|
||||
GPU: UNKNOWN
|
||||
|
||||
Crash reason: SIGSEGV /SEGV_MAPERR
|
||||
Crash address: 0x0
|
||||
Process uptime: not available
|
||||
|
||||
Thread 0 (crashed)
|
||||
0 libcrash-lib.so + 0x600 // 注意点2
|
||||
x0 = 0x00000078e0ce8460 x1 = 0x0000007fd4000314
|
||||
x2 = 0x0000007fd40003b0 x3 = 0x00000078e0237134
|
||||
x4 = 0x0000007fd40005d0 x5 = 0x00000078dca14200
|
||||
x6 = 0x0000007fd4000160 x7 = 0x00000078c8987e18
|
||||
x8 = 0x0000000000000000 x9 = 0x0000000000000001
|
||||
x10 = 0x0000000000430000 x11 = 0x00000078e05ef688
|
||||
x12 = 0x00000079664ab050 x13 = 0x0ad046ab5a65bfdf
|
||||
x14 = 0x000000796650c000 x15 = 0xffffffffffffffff
|
||||
x16 = 0x00000078c83defe8 x17 = 0x00000078c83ce5ec
|
||||
x18 = 0x0000000000000001 x19 = 0x00000078e0c14c00
|
||||
x20 = 0x0000000000000000 x21 = 0x00000078e0c14c00
|
||||
x22 = 0x0000007fd40005e0 x23 = 0x00000078c89fa661
|
||||
x24 = 0x0000000000000004 x25 = 0x00000079666cc5e0
|
||||
x26 = 0x00000078e0c14ca0 x27 = 0x0000000000000001
|
||||
x28 = 0x0000007fd4000310 fp = 0x0000007fd40002e0
|
||||
lr = 0x00000078c83ce624 sp = 0x0000007fd40002c0
|
||||
pc = 0x00000078c83ce600
|
||||
Found by: given as instruction pointer in context
|
||||
1 libcrash-lib.so + 0x620
|
||||
fp = 0x0000007fd4000310 lr = 0x00000078e051c7e4
|
||||
sp = 0x0000007fd40002f0 pc = 0x00000078c83ce624
|
||||
Found by: previous frame's frame pointer
|
||||
2 libart.so + 0x55f7e0
|
||||
fp = 0x130c0cf800000001 lr = 0x00000079666cc5e0
|
||||
sp = 0x0000007fd4000320 pc = 0x00000078e051c7e4
|
||||
Found by: previous frame's frame pointer
|
||||
......
|
||||
|
||||
|
||||
下来是符号解析,可以使用NDK中提供的addr2line来根据地址进行一个符号反解的过程,该工具在$NDK_HOME/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-addr2line。
|
||||
|
||||
注意:此处要注意一下平台,如果是ARM 64位的so,解析是需要使用aarch64-linux-android-4.9下的工具链。
|
||||
|
||||
因为我的是ARM 64位的so。所以使用aarch64-linux-android-4.9,libcrash-lib.so在app/build/intermediates/cmake/debug/obj/arm64-v8a下,0x600为错误位置符号。
|
||||
|
||||
aarch64-linux-android-addr2line -f -C -e libcrash-lib.so 0x600
|
||||
|
||||
|
||||
输出结果如下:
|
||||
|
||||
Crash()
|
||||
/Users/weilu/Downloads/Chapter01-master/sample/.externalNativeBuild/cmake/debug/arm64-v8a/../../../../src/main/cpp/crash.cpp:10
|
||||
|
||||
|
||||
可以看到输出结果与下图错误位置一致(第10行)。
|
||||
|
||||
|
||||
|
||||
Chapter02
|
||||
|
||||
|
||||
该例子主要演示了如何通过关闭FinalizerWatchdogDaemon来减少TimeoutException的触发。
|
||||
|
||||
|
||||
在我的上一篇博客:安卓开发中遇到的奇奇怪怪的问题(三)中有说明,就不重复赘述了。
|
||||
|
||||
Chapter03
|
||||
|
||||
|
||||
项目使用了Inline Hook来拦截内存对象分配时候的RecordAllocation函数,通过拦截该接口可以快速获取到当时分配对象的类名和分配的内存大小。
|
||||
|
||||
在初始化的时候我们设置了一个分配对象数量的最大值,如果从start开始对象分配数量超过最大值就会触发内存dump,然后清空alloc对象列表,重新计算。该功能和Android Studio里的Allocation Tracker类似,只不过可以在代码级别更细粒度的进行控制。可以精确到方法级别。
|
||||
|
||||
|
||||
项目直接跑起来后,点击开始记录,然后点击5次生成1000对象按钮。生成对象代码如下:
|
||||
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
Message msg = new Message();
|
||||
msg.what = i;
|
||||
}
|
||||
|
||||
|
||||
因为代码从点击开始记录开始,触发到5000的数据就dump到文件中,点击5次后就会在sdcard/crashDump下生成一个时间戳命名的文件。项目根目录下调用命令:
|
||||
|
||||
java -jar tools/DumpPrinter-1.0.jar dump文件路径 > dump_log.txt
|
||||
|
||||
|
||||
然后就可以在dump_log.txt中看到解析出来的数据:
|
||||
|
||||
Found 5000 records:
|
||||
....
|
||||
tid=4509 android.graphics.drawable.RippleForeground (112 bytes)
|
||||
android.graphics.drawable.RippleDrawable.tryRippleEnter (RippleDrawable.java:569)
|
||||
android.graphics.drawable.RippleDrawable.setRippleActive (RippleDrawable.java:276)
|
||||
android.graphics.drawable.RippleDrawable.onStateChange (RippleDrawable.java:266)
|
||||
android.graphics.drawable.Drawable.setState (Drawable.java:778)
|
||||
android.view.View.drawableStateChanged (View.java:21137)
|
||||
android.widget.TextView.drawableStateChanged (TextView.java:5289)
|
||||
android.support.v7.widget.AppCompatButton.drawableStateChanged (AppCompatButton.java:155)
|
||||
android.view.View.refreshDrawableState (View.java:21214)
|
||||
android.view.View.setPressed (View.java:10583)
|
||||
android.view.View.setPressed (View.java:10561)
|
||||
android.view.View.onTouchEvent (View.java:13865)
|
||||
android.widget.TextView.onTouchEvent (TextView.java:10070)
|
||||
android.view.View.dispatchTouchEvent (View.java:12533)
|
||||
android.view.ViewGroup.dispatchTransformedTouchEvent (ViewGroup.java:3032)
|
||||
android.view.ViewGroup.dispatchTouchEvent (ViewGroup.java:2662)
|
||||
android.view.ViewGroup.dispatchTransformedTouchEvent (ViewGroup.java:3032)
|
||||
tid=4515 int[] (104 bytes)
|
||||
tid=4509 android.os.BaseLooper$MessageMonitorInfo (88 bytes)
|
||||
android.os.Message.<init> (Message.java:123)
|
||||
com.dodola.alloctrack.MainActivity$4.onClick (MainActivity.java:70)
|
||||
android.view.View.performClick (View.java:6614)
|
||||
android.view.View.performClickInternal (View.java:6591)
|
||||
android.view.View.access$3100 (View.java:786)
|
||||
android.view.View$PerformClick.run (View.java:25948)
|
||||
android.os.Handler.handleCallback (Handler.java:873)
|
||||
android.os.Handler.dispatchMessage (Handler.java:99)
|
||||
android.os.Looper.loop (Looper.java:201)
|
||||
android.app.ActivityThread.main (ActivityThread.java:6806)
|
||||
java.lang.reflect.Method.invoke (Native method)
|
||||
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:547)
|
||||
com.android.internal.os.ZygoteInit.main (ZygoteInit.java:873)
|
||||
......
|
||||
|
||||
|
||||
我们用Android Profiler查找一个Message对象对比一下,一模一样。
|
||||
|
||||
|
||||
|
||||
简单看一下Hook代码:
|
||||
|
||||
void hookFunc() {
|
||||
LOGI("start hookFunc");
|
||||
void *handle = ndk_dlopen("libart.so", RTLD_LAZY | RTLD_GLOBAL);
|
||||
|
||||
if (!handle) {
|
||||
LOGE("libart.so open fail");
|
||||
return;
|
||||
}
|
||||
void *hookRecordAllocation26 = ndk_dlsym(handle,
|
||||
"_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPNS_6ObjPtrINS_6mirror6ObjectEEEj");
|
||||
|
||||
void *hookRecordAllocation24 = ndk_dlsym(handle,
|
||||
"_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPPNS_6mirror6ObjectEj");
|
||||
|
||||
void *hookRecordAllocation23 = ndk_dlsym(handle,
|
||||
"_ZN3art3Dbg16RecordAllocationEPNS_6ThreadEPNS_6mirror5ClassEj");
|
||||
|
||||
void *hookRecordAllocation22 = ndk_dlsym(handle,
|
||||
"_ZN3art3Dbg16RecordAllocationEPNS_6mirror5ClassEj");
|
||||
|
||||
if (hookRecordAllocation26 != nullptr) {
|
||||
LOGI("Finish get symbol26");
|
||||
MSHookFunction(hookRecordAllocation26, (void *) &newArtRecordAllocation26,
|
||||
(void **) &oldArtRecordAllocation26);
|
||||
|
||||
} else if (hookRecordAllocation24 != nullptr) {
|
||||
LOGI("Finish get symbol24");
|
||||
MSHookFunction(hookRecordAllocation26, (void *) &newArtRecordAllocation26,
|
||||
(void **) &oldArtRecordAllocation26);
|
||||
|
||||
} else if (hookRecordAllocation23 != NULL) {
|
||||
LOGI("Finish get symbol23");
|
||||
MSHookFunction(hookRecordAllocation23, (void *) &newArtRecordAllocation23,
|
||||
(void **) &oldArtRecordAllocation23);
|
||||
} else {
|
||||
LOGI("Finish get symbol22");
|
||||
if (hookRecordAllocation22 == NULL) {
|
||||
LOGI("error find hookRecordAllocation22");
|
||||
return;
|
||||
} else {
|
||||
MSHookFunction(hookRecordAllocation22, (void *) &newArtRecordAllocation22,
|
||||
(void **) &oldArtRecordAllocation22);
|
||||
}
|
||||
}
|
||||
dlclose(handle);
|
||||
}
|
||||
|
||||
|
||||
使用了Inline Hook方案Substrate来拦截内存对象分配时候libart.so的RecordAllocation函数。首先如果我们要hook一个函数,需要知道这个函数的地址。我们也看到了代码中这个地址判断了四种不同系统。这里有一个网页版的解析工具可以快速获取。下面以8.0为例。
|
||||
|
||||
|
||||
|
||||
我在8.0的源码中找到了对应的方法:
|
||||
|
||||
|
||||
|
||||
7.0方法就明显不同:
|
||||
|
||||
|
||||
|
||||
我也同时参看了9.0的代码,发现没有变化,所以我的测试机是9.0的也没有问题。
|
||||
|
||||
Hook新内存对象分配处理代码:
|
||||
|
||||
static bool newArtRecordAllocationDoing24(Class *type, size_t byte_count) {
|
||||
|
||||
allocObjectCount++;
|
||||
//根据 class 获取类名
|
||||
char *typeName = GetDescriptor(type, &a);
|
||||
//达到 max
|
||||
if (allocObjectCount > setAllocRecordMax) {
|
||||
CMyLock lock(g_Lock);//此处需要 loc 因为对象分配的时候不知道在哪个线程,不 lock 会导致重复 dump
|
||||
allocObjectCount = 0;
|
||||
|
||||
// dump alloc 里的对象转换成 byte 数据
|
||||
jbyteArray allocData = getARTAllocationData();
|
||||
// 将alloc数据写入文件
|
||||
SaveAllocationData saveData{allocData};
|
||||
saveARTAllocationData(saveData);
|
||||
resetARTAllocRecord();
|
||||
LOGI("===========CLEAR ALLOC MAPS=============");
|
||||
|
||||
lock.Unlock();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Chapter04
|
||||
|
||||
|
||||
通过分析内存文件hprof快速判断内存中是否存在重复的图片,并且将这些重复图片的PNG、堆栈等信息输出。
|
||||
|
||||
|
||||
首先是获取我们需要分析的hprof文件,我们加载两张相同的图片:
|
||||
|
||||
Bitmap bitmap1 = BitmapFactory.decodeResource(getResources(), R.mipmap.test);
|
||||
Bitmap bitmap2 = BitmapFactory.decodeResource(getResources(), R.mipmap.test);
|
||||
|
||||
imageView1.setImageBitmap(bitmap1);
|
||||
imageView2.setImageBitmap(bitmap2);
|
||||
|
||||
|
||||
生成hprof文件
|
||||
|
||||
// 手动触发GC
|
||||
Runtime.getRuntime().gc();
|
||||
System.runFinalization();
|
||||
Debug.dumpHprofData(file.getAbsolutePath());
|
||||
|
||||
|
||||
接下来就是利用HAHA库进行文件分析的核心代码:
|
||||
|
||||
// 打开hprof文件
|
||||
final HeapSnapshot heapSnapshot = new HeapSnapshot(hprofFile);
|
||||
// 获得snapshot
|
||||
final Snapshot snapshot = heapSnapshot.getSnapshot();
|
||||
// 获得Bitmap Class
|
||||
final ClassObj bitmapClass = snapshot.findClass("android.graphics.Bitmap");
|
||||
// 获得heap, 只需要分析app和default heap即可
|
||||
Collection<Heap> heaps = snapshot.getHeaps();
|
||||
|
||||
for (Heap heap : heaps) {
|
||||
// 只需要分析app和default heap即可
|
||||
if (!heap.getName().equals("app") && !heap.getName().equals("default")) {
|
||||
continue;
|
||||
}
|
||||
for (ClassObj clazz : bitmapClasses) {
|
||||
//从heap中获得所有的Bitmap实例
|
||||
List<Instance> bitmapInstances = clazz.getHeapInstances(heap.getId());
|
||||
//从Bitmap实例中获得buffer数组,宽高信息等。
|
||||
ArrayInstance buffer = HahaHelper.fieldValue(((ClassInstance) bitmapInstance).getValues(), "mBuffer");
|
||||
int bitmapHeight = fieldValue(bitmapInstance, "mHeight");
|
||||
int bitmapWidth = fieldValue(bitmapInstance, "mWidth");
|
||||
// 引用链信息
|
||||
while (bitmapInstance.getNextInstanceToGcRoot() != null) {
|
||||
print(instance.getNextInstanceToGcRoot());
|
||||
instance = instance.getNextInstanceToGcRoot();
|
||||
}
|
||||
// 根据hashcode来进行重复判断
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
最终的输出结果:
|
||||
|
||||
|
||||
|
||||
我们用Studio打开hprof文件对比一下:
|
||||
|
||||
|
||||
|
||||
可以看到信息是一摸一样的。对于更优处理引用链的信息,可以参看LeakCanary源码的实现。
|
||||
|
||||
我已经将上面的代码打成JAR包,可以直接调用:
|
||||
|
||||
//调用方法:
|
||||
java -jar tools/DuplicatedBitmapAnalyzer-1.0.jar hprof文件路径
|
||||
|
||||
|
||||
详细的代码我提交到了Github,供大家参考。
|
||||
|
||||
Chapter05
|
||||
|
||||
|
||||
尝试模仿ProcessCpuTracker.java拿到一段时间内各个线程的耗时占比。
|
||||
|
||||
|
||||
usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000):
|
||||
System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle
|
||||
CPU Core: 8
|
||||
Load Average: 8.74 / 7.74 / 7.36
|
||||
|
||||
Process:com.sample.app
|
||||
50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965
|
||||
|
||||
Threads:
|
||||
43% 23493/singleThread(R): 6.5% user + 36% kernel faults:3094
|
||||
3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults:329
|
||||
0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults:6
|
||||
0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults:982
|
||||
...
|
||||
|
||||
|
||||
因为了解Linux不多,所以看这个有点懵逼。好在课代表孙鹏飞同学解答了相关问题,看懂了上面信息,同时学习到了一些Linux知识。
|
||||
|
||||
private void testIO() {
|
||||
Thread thread = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
File f = new File(getFilesDir(), "aee.txt");
|
||||
FileOutputStream fos = new FileOutputStream(f);
|
||||
byte[] data = new byte[1024 * 4 * 3000];// 此处分配一个 12mb 大小的 byte 数组
|
||||
|
||||
for (int i = 0; i < 30; i++) {// 由于 IO cache 机制的原因所以此处写入多次 cache,触发 dirty writeback 到磁盘中
|
||||
Arrays.fill(data, (byte) i);// 当执行到此处的时候产生 minor fault,并且产生 User cpu useage
|
||||
fos.write(data);
|
||||
}
|
||||
fos.flush();
|
||||
fos.close();
|
||||
|
||||
}
|
||||
});
|
||||
thread.setName("SingleThread");
|
||||
thread.start();
|
||||
}
|
||||
|
||||
|
||||
上述代码就是导致的问题罪魁祸首,这种密集I/O操作集中在SingleThread线程中处理,导致发生了3094次faults、36% kernel,完全没有很好利用到8核CPU。
|
||||
|
||||
最后,通过检测CPU的使用率,可以更好地避免卡顿现象,防止ANR的发生。
|
||||
|
||||
前前后后用了两三天的时间,远远没有当初想的顺利,感觉身体被掏空。中间也爬了不少坑,虽然没有太深入实现代码,但是中间的体验过程也是收获不小。所以总不能因为难就放弃了,先做到力所能及的部分,让自己动起来!
|
||||
|
||||
参考
|
||||
|
||||
|
||||
练习Sample跑起来 | 热点问题答疑第1期
|
||||
|
||||
练习Sample跑起来 | 热点问题答疑第2期
|
||||
|
||||
|
||||
|
||||
|
||||
|
311
专栏/Android开发高手课/练习Sample跑起来唯鹿同学的练习手记第2辑.md
Normal file
311
专栏/Android开发高手课/练习Sample跑起来唯鹿同学的练习手记第2辑.md
Normal file
@ -0,0 +1,311 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
练习Sample跑起来 唯鹿同学的练习手记 第2辑
|
||||
你好,我是唯鹿。
|
||||
|
||||
接着上篇练习手记,今天练习6~8、12、17、19这六期内容(主要针对有课后Sample练习的),相比1~5期轻松了很多。
|
||||
|
||||
Chapter06
|
||||
|
||||
|
||||
该项目展示了使用PLT Hook技术来获取Atrace的日志,可以学习到systrace的一些底层机制。
|
||||
|
||||
|
||||
没有什么问题,项目直接可以运行起来。运行项目后点击开启Atrace日志,然后就可以在Logcat日志中查看到捕获的日志,如下:
|
||||
|
||||
11:40:07.031 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= install systrace hoook =========
|
||||
11:40:07.034 8537-8537/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|Record View#draw()
|
||||
11:40:07.034 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|DrawFrame
|
||||
11:40:07.035 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|syncFrameState
|
||||
========= B|8537|prepareTree
|
||||
========= E
|
||||
========= E
|
||||
========= B|8537|eglBeginFrame
|
||||
========= E
|
||||
========= B|8537|computeOrdering
|
||||
========= E
|
||||
========= B|8537|flush drawing commands
|
||||
========= E
|
||||
11:40:07.036 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|eglSwapBuffersWithDamageKHR
|
||||
========= B|8537|setSurfaceDamage
|
||||
========= E
|
||||
11:40:07.042 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|queueBuffer
|
||||
========= E
|
||||
11:40:07.043 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|dequeueBuffer
|
||||
========= E
|
||||
========= E
|
||||
========= E
|
||||
|
||||
|
||||
通过B|事件和E|事件是成对出现的,这样就可以计算出应用执行每个事件使用的时间。那么上面的Log中View的draw()方法显示使用了9ms。
|
||||
|
||||
这里实现方法是使用了Profilo的PLT Hook来hook libc.so的write与__write_chk方法。libc是C的基础库函数,为什么要hook这些方法,需要我们补充C、Linux相关知识。
|
||||
|
||||
同理Chapter06-plus展示了如何使用 PLT Hook技术来获取线程创建的堆栈,README有详细的实现步骤介绍,我就不赘述了。
|
||||
|
||||
Chapter07
|
||||
|
||||
|
||||
这个Sample是学习如何给代码加入Trace Tag,大家可以将这个代码运用到自己的项目中,然后利用systrace查看结果。这就是所谓的systrace + 函数插桩。
|
||||
|
||||
|
||||
操作步骤:
|
||||
|
||||
|
||||
使用Android Studio打开工程Chapter07。
|
||||
|
||||
运行Gradle Task :systrace-gradle-plugin:buildAndPublishToLocalMaven编译plugin插件。
|
||||
|
||||
使用Android Studio单独打开工程systrace-sample-android。
|
||||
|
||||
编译运行App(插桩后的class文件在目录Chapter07/systrace-sample-android/app/build/systrace_output/classes中查看)。
|
||||
|
||||
|
||||
对比一下插桩效果,插桩前:
|
||||
|
||||
|
||||
|
||||
插桩后:
|
||||
|
||||
|
||||
|
||||
可以看到在方法执行前后插入了TraceTag,这样的话beginSection方法和endSection方法之间的代码就会被追踪。
|
||||
|
||||
public class TraceTag {
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
public static void i(String name) {
|
||||
Trace.beginSection(name);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
public static void o() {
|
||||
Trace.endSection();
|
||||
}
|
||||
|
||||
|
||||
其实Support-Compat库中也有类似的一个TraceCompat,项目中可以直接使用。
|
||||
|
||||
然后运行项目,打开systrace:
|
||||
|
||||
python $ANDROID_HOME/platform-tools/systrace/systrace.py gfx view wm am pm ss dalvik app sched -b 90960 -a com.sample.systrace -o test.log.html
|
||||
|
||||
|
||||
|
||||
|
||||
最后打开生成的test.log.html文件就可以查看systrace记录:
|
||||
|
||||
|
||||
|
||||
当然,这一步我们也可以使用SDK中的Monitor,效果是一样的。
|
||||
|
||||
使用systrace + 函数插桩的方式,我们就可以很方便地观察每个方法的耗时,从而针对耗时的方法进行优化,尤其是Application的启动优化。
|
||||
|
||||
Chapter08
|
||||
|
||||
|
||||
该项目展示了关闭掉虚拟机的class verify后对性能的影响。
|
||||
|
||||
|
||||
在加载类的过程有一个verify class的步骤,它需要校验方法的每一个指令,是一个比较耗时的操作。这个例子就是通过Hook去掉verify这个步骤。该例子尽量在Dalvik下执行,在ART下的效果并不明显。
|
||||
|
||||
去除校验代码(可以参看阿里的Atlas):
|
||||
|
||||
AndroidRuntime runtime = AndroidRuntime.getInstance();
|
||||
runtime.init(this.getApplicationContext(), true);
|
||||
runtime.setVerificationEnabled(false);
|
||||
|
||||
|
||||
具体运行效果这里我就不展示了,直接运行体验就可以了。
|
||||
|
||||
Chapter12
|
||||
|
||||
|
||||
通过复写Application的getSharedPreferences替换系统SharedPreferences的实现,核心的优化在于修改了Apply的实现,将多个Apply方法在内存中合并,而不是多次提交。
|
||||
|
||||
|
||||
修改SharedPreferencesImpl的Apply部分如下:
|
||||
|
||||
public void apply() {
|
||||
// 先调用commitToMemory()
|
||||
final MemoryCommitResult mcr = commitToMemory();
|
||||
|
||||
boolean hasDiskWritesInFlight = false;
|
||||
synchronized (SharedPreferencesImpl.this) {
|
||||
// mDiskWritesInFlight大于0说明之前已经有调用过commitToMemory()了
|
||||
hasDiskWritesInFlight = mDiskWritesInFlight > 0;
|
||||
}
|
||||
// 源码没有这层判断,直接提交。
|
||||
if (!hasDiskWritesInFlight) {
|
||||
final Runnable awaitCommit = new Runnable() {
|
||||
public void run() {
|
||||
try {
|
||||
mcr.writtenToDiskLatch.await();
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
QueuedWork.add(awaitCommit);
|
||||
|
||||
|
||||
Runnable postWriteRunnable = new Runnable() {
|
||||
public void run() {
|
||||
awaitCommit.run();
|
||||
|
||||
QueuedWork.remove(awaitCommit);
|
||||
}
|
||||
};
|
||||
|
||||
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
|
||||
}
|
||||
|
||||
// Okay to notify the listeners before it's hit disk
|
||||
// because the listeners should always get the same
|
||||
// SharedPreferences instance back, which has the
|
||||
// changes reflected in memory.
|
||||
notifyListeners(mcr);
|
||||
|
||||
|
||||
Chapter14
|
||||
|
||||
这个是全面解析SQLite的资料,有兴趣的可以下载看看。
|
||||
|
||||
Chapter17
|
||||
|
||||
|
||||
该项目展示了如何使用PLT Hook技术来获取网络请求相关信息。
|
||||
|
||||
|
||||
通过PLT Hook,代理Socket相关的几个重要函数:
|
||||
|
||||
/**
|
||||
* 直接 hook 内存中的所有so,但是需要排除掉socket相关方法本身定义的libc(不然会出现循坏)
|
||||
* plt hook
|
||||
*/
|
||||
void hookLoadedLibs() {
|
||||
ALOG("hook_plt_method");
|
||||
hook_plt_method_all_lib("libc.so", "send", (hook_func) &socket_send_hook);
|
||||
hook_plt_method_all_lib("libc.so", "recv", (hook_func) &socket_recv_hook);
|
||||
hook_plt_method_all_lib("libc.so", "sendto", (hook_func) &socket_sendto_hook);
|
||||
hook_plt_method_all_lib("libc.so", "recvfrom", (hook_func) &socket_recvfrom_hook);
|
||||
hook_plt_method_all_lib("libc.so", "connect", (hook_func) &socket_connect_hook);
|
||||
}
|
||||
|
||||
|
||||
int hook_plt_method_all_lib(const char* exclueLibname, const char* name, hook_func hook) {
|
||||
if (refresh_shared_libs()) {
|
||||
// Could not properly refresh the cache of shared library data
|
||||
return -1;
|
||||
}
|
||||
|
||||
int failures = 0;
|
||||
|
||||
for (auto const& lib : allSharedLibs()) {
|
||||
if (strcmp(lib.first.c_str(), exclueLibname) != 0) {
|
||||
failures += hook_plt_method(lib.first.c_str(), name, hook);
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
|
||||
运行项目,访问百度的域名https://www.baidu.com,输出如下:
|
||||
|
||||
17:08:28.347 12145-12163/com.dodola.socket E/HOOOOOOOOK: socket_connect_hook sa_family: 10
|
||||
17:08:28.349 12145-12163/com.dodola.socket E/HOOOOOOOOK: stack:com.dodola.socket.SocketHook.getStack(SocketHook.java:13)
|
||||
java.net.PlainSocketImpl.socketConnect(Native Method)
|
||||
java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:334)
|
||||
java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:196)
|
||||
java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:178)
|
||||
java.net.SocksSocketImpl.connect(SocksSocketImpl.java:356)
|
||||
java.net.Socket.connect(Socket.java:586)
|
||||
com.android.okhttp.internal.Platform.connectSocket(Platform.java:113)
|
||||
com.android.okhttp.Connection.connectSocket(Connection.java:196)
|
||||
com.android.okhttp.Connection.connect(Connection.java:172)
|
||||
com.android.okhttp.Connection.connectAndSetOwner(Connection.java:367)
|
||||
com.android.okhttp.OkHttpClient$1.connectAndSetOwner(OkHttpClient.java:130)
|
||||
com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:329)
|
||||
com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:246)
|
||||
com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute(HttpURLConnection
|
||||
AF_INET6 ipv6 IP===>183.232.231.173:443
|
||||
socket_connect_hook sa_family: 1
|
||||
Ignore local socket connect
|
||||
02-07 17:08:28.637 12145-12163/com.dodola.socket E/HOOOOOOOOK: respond:<!DOCTYPE html>
|
||||
<html><!--STATUS OK--><head><meta charset="utf-8"><title>百度一下,你就知道</title>
|
||||
|
||||
|
||||
可以看到我们获取到了网络请求的相关信息。
|
||||
|
||||
最后,我们可以通过Connect函数的hook,实现很多需求,例如:
|
||||
|
||||
|
||||
禁用应用网络访问
|
||||
过滤广告IP
|
||||
禁用定位功能
|
||||
|
||||
|
||||
Chapter19
|
||||
|
||||
|
||||
使用Java Hook实现Alarm、WakeLock与GPS的耗电监控。
|
||||
|
||||
|
||||
实现原理
|
||||
|
||||
根据老师提供的提示信息,动态代理对应的PowerManager、AlarmManager、LocationManager的mService实现,要拦截的方法在PowerManagerService、AlarmManagerService、LocationManagerService中。
|
||||
|
||||
实现核心代码:
|
||||
|
||||
Object oldObj = mHostContext.getSystemService(Context.XXX_SERVICE);
|
||||
Class<?> clazz = oldObj.getClass();
|
||||
Field field = clazz.getDeclaredField("mService");
|
||||
field.setAccessible(true);
|
||||
|
||||
final Object mService = field.get(oldObj);
|
||||
setProxyObj(mService);
|
||||
|
||||
Object newObj = Proxy.newProxyInstance(this.getClass().getClassLoader(), mService.getClass().getInterfaces(), this);
|
||||
field.set(oldObj, newObj)
|
||||
|
||||
|
||||
写了几个调用方法去触发,通过判断对应的方法名来做堆栈信息的输出。
|
||||
|
||||
输出的堆栈信息如下:
|
||||
|
||||
|
||||
|
||||
当然,强大的Studio在3.2后也有了强大的耗电量分析器,同样可以监测到这些信息,如下图所示(我使用的Studio版本为3.3)。
|
||||
|
||||
|
||||
|
||||
实现不足之处:
|
||||
|
||||
|
||||
可能兼容性上不是特别完善(期待老师的标准答案)。
|
||||
没有按照耗电监控的规则去做一些业务处理。
|
||||
|
||||
|
||||
心得体会:
|
||||
|
||||
|
||||
本身并不复杂,只是为了找到Hook点,看了对应的Service源码耗费了一些时间,对于它们的工作流程有了更深的认识。
|
||||
平时也很少使用动态代理,这回查漏补缺,一次用了个爽。
|
||||
|
||||
|
||||
这个作业前前后后用了一天时间,之前作业还有一些同学提供PR,所以相对轻松些,但这次没有参考,走了点弯路,不过收获也是巨大的。我就不细说了,感兴趣的话可以参考我的实现。完整代码参见GitHub,仅供参考。
|
||||
|
||||
参考
|
||||
|
||||
|
||||
练习Sample跑起来 | 热点问题答疑第3期
|
||||
练习Sample跑起来 | 热点问题答疑第4期
|
||||
|
||||
|
||||
|
||||
|
||||
|
324
专栏/Android开发高手课/练习Sample跑起来唯鹿同学的练习手记第3辑.md
Normal file
324
专栏/Android开发高手课/练习Sample跑起来唯鹿同学的练习手记第3辑.md
Normal file
@ -0,0 +1,324 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
练习Sample跑起来 唯鹿同学的练习手记 第3辑
|
||||
没想到之前的写的练习心得得到了老师的认可,看来我要更加认真努力练习了。今天来练习第22、27、ASM这三课的Sample。
|
||||
|
||||
Chapter22
|
||||
|
||||
|
||||
尝试使用Facebook ReDex库来优化我们的安装包。
|
||||
|
||||
|
||||
准备工作
|
||||
|
||||
首先是下载ReDex:
|
||||
|
||||
git clone https://github.com/facebook/redex.git
|
||||
cd redex
|
||||
|
||||
|
||||
接着是安装:
|
||||
|
||||
autoreconf -ivf && ./configure && make -j4
|
||||
sudo make install
|
||||
|
||||
|
||||
在安装时执行到这里,报出下图错误:
|
||||
|
||||
|
||||
|
||||
其实就是没有安装Boost,所以执行下面的命令安装它。
|
||||
|
||||
brew install boost jsoncpp
|
||||
|
||||
|
||||
安装Boost完成后,再等待十几分钟时间安装ReDex。
|
||||
|
||||
下来就是编译我们的Sample,得到的安装包信息如下。
|
||||
|
||||
|
||||
|
||||
可以看到有三个Dex文件,APK大小为13.7MB。
|
||||
|
||||
通过ReDex命令优化
|
||||
|
||||
为了让我们可以更加清楚流程,你可以输出ReDex的日志。
|
||||
|
||||
export TRACE=2
|
||||
|
||||
|
||||
去除Debuginfo的方法,需要在项目根目录执行:
|
||||
|
||||
redex --sign -s ReDexSample/keystore/debug.keystore -a androiddebugkey -p android -c redex-test/stripdebuginfo.config -P ReDexSample/proguard-rules.pro -o redex-test/strip_output.apk ReDexSample/build/outputs/apk/debug/ReDexSample-debug.apk
|
||||
|
||||
|
||||
上面这段很长的命令,其实可以拆解为几部分:
|
||||
|
||||
|
||||
--sign 签名信息
|
||||
|
||||
-s(keystore)签名文件路径
|
||||
|
||||
-a(keyalias)签名的别名
|
||||
|
||||
-p(keypass)签名的密码
|
||||
|
||||
-c 指定ReDex的配置文件路径
|
||||
|
||||
-P ProGuard规则文件路径
|
||||
|
||||
-o 输出的文件路径
|
||||
|
||||
最后是要处理APK文件的路径
|
||||
|
||||
|
||||
但在使用时,我遇到了下图的问题:
|
||||
|
||||
|
||||
|
||||
这里是找不到Zipalign,所以需要我们配置Android SDK的根目录路径,添加在原命令前面:
|
||||
|
||||
ANDROID_SDK=/path/to/android/sdk redex [... arguments ...]
|
||||
|
||||
|
||||
结果如下:
|
||||
|
||||
|
||||
|
||||
实际的优化效果是,原Debug包为14.21MB,去除Debuginfo的方法后为12.91MB,效果还是不错的。去除的内容就是一些调试信息及堆栈行号。
|
||||
|
||||
|
||||
|
||||
不过老师在Sample的proguard-rules.pro中添加了-keepattributes SourceFile,LineNumberTable保留了行号信息。
|
||||
|
||||
所以处理后的包安装后进入首页,还是可以看到堆栈信息的行号。
|
||||
|
||||
Dex重分包的方法
|
||||
|
||||
redex --sign -s ReDexSample/keystore/debug.keystore -a androiddebugkey -p android -c redex-test/interdex.config -P ReDexSample/proguard-rules.pro -o redex-test/interdex_output.apk ReDexSample/build/outputs/apk/debug/ReDexSample-debug.apk
|
||||
|
||||
|
||||
和之前的命令一样,只是-c使用的配置文件为interdex.config。
|
||||
|
||||
输出信息:
|
||||
|
||||
|
||||
|
||||
优化效果为,原Debug包为14.21MB、3个Dex,优化后为13.34MB、2个Dex。
|
||||
|
||||
|
||||
|
||||
根据老师的介绍,如果你的应用有4个以上的Dex,这个体积优化至少有10%。 看来效果还是很棒棒的。至于其他问题,比如在Windows环境使用ReDex,可以参看ReDex的使用文档。
|
||||
|
||||
Chapter27
|
||||
|
||||
|
||||
利用AspectJ实现插桩的例子。
|
||||
|
||||
|
||||
效果和Chapter07是一样的,只是Chapter07使用的是ASM方式实现的,这次是AspectJ实现。ASM与AspectJ都是Java字节码处理框架,相比较来说AspectJ使用更加简单,同样的功能实现只需下面这点代码,但是ASM比AspectJ更加高效和灵活。
|
||||
|
||||
AspectJ实现代码:
|
||||
|
||||
@Aspect
|
||||
public class TraceTagAspectj {
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
@Before("execution(* **(..))")
|
||||
public void before(JoinPoint joinPoint) {
|
||||
Trace.beginSection(joinPoint.getSignature().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* hook method when it's called out.
|
||||
*/
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
@After("execution(* **(..))")
|
||||
public void after() {
|
||||
Trace.endSection();
|
||||
}
|
||||
|
||||
|
||||
简单介绍下上面代码的意思:
|
||||
|
||||
|
||||
@Aspect:在编译时AspectJ会查找被@Aspect注解的类,然后执行我们的AOP实现。
|
||||
|
||||
@Before:可以简单理解为方法执行前。
|
||||
|
||||
@After:可以简单理解为方法执行后。
|
||||
|
||||
execution:方法执行。
|
||||
|
||||
* **(..):第一个星号代表任意返回类型,第二个星号代表任意类,第三个代表任意方法,括号内为方法参数无限制。星号和括号内都是可以替换为具体值,比如String TestClass.test(String)。
|
||||
|
||||
|
||||
知道了相关注解的含义,那么实现的代码含义就是,所有方法在执行前后插入相应指定操作。
|
||||
|
||||
效果对比如下:
|
||||
|
||||
-
|
||||
|
||||
|
||||
下来实现给MainActivity的onResume方法增加try catch。
|
||||
|
||||
@Aspect
|
||||
public class TryCatchAspect {
|
||||
|
||||
@Pointcut("execution(* com.sample.systrace.MainActivity.onResume())") // <- 指定类与方法
|
||||
public void methodTryCatch() {
|
||||
}
|
||||
|
||||
@Around("methodTryCatch()")
|
||||
public void aroundTryJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
|
||||
// try catch
|
||||
try {
|
||||
joinPoint.proceed(); // <- 调用原方法
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
上面用到了两个新注解:
|
||||
|
||||
|
||||
@Around:用于替换以前的代码,使用joinPoint.proceed()可以调用原方法。
|
||||
|
||||
@Pointcut:指定一个切入点。
|
||||
|
||||
|
||||
实现就是指定一个切入点,利用替换原方法的思路包裹一层try catch。
|
||||
|
||||
效果对比如下:
|
||||
|
||||
-
|
||||
|
||||
|
||||
当然AspectJ还有很多用法,Sample中包含有《AspectJ程序设计指南》,便于我们具体了解和学习AspectJ。
|
||||
|
||||
Chapter-ASM
|
||||
|
||||
|
||||
Sample利用ASM实现了统计方法耗时和替换项目中所有的new Thread。
|
||||
|
||||
|
||||
|
||||
运行项目首先要注掉ASMSample build.gradle的apply plugin: 'com.geektime.asm-plugin'和根目录build.gradle的classpath ("com.geektime.asm:asm-gradle-plugin:1.0") { changing = true }。
|
||||
|
||||
运行gradle task ":asm-gradle-plugin:buildAndPublishToLocalMaven"编译plugin插件,编译的插件在本地.m2\repository目录下
|
||||
|
||||
打开第一步注掉的内容就可以运行了。
|
||||
|
||||
|
||||
实现的大致过程是,先利用Transform遍历所有文件,再通过ASM的visitMethod遍历所有方法,最后通过AdviceAdapter实现最终的修改字节码。具体实现可以看代码和《练习Sample跑起来 | ASM插桩强化练习》。
|
||||
|
||||
效果对比:
|
||||
|
||||
-
|
||||
|
||||
|
||||
下面是两个练习:
|
||||
|
||||
1.给某个方法增加try catch
|
||||
|
||||
这里我就给MainActivity的mm方法进行try catch。实现很简单,直接修改ASMCode的TraceMethodAdapter。
|
||||
|
||||
public static class TraceMethodAdapter extends AdviceAdapter {
|
||||
|
||||
private final String methodName;
|
||||
private final String className;
|
||||
private final Label tryStart = new Label();
|
||||
private final Label tryEnd = new Label();
|
||||
private final Label catchStart = new Label();
|
||||
private final Label catchEnd = new Label();
|
||||
|
||||
protected TraceMethodAdapter(int api, MethodVisitor mv, int access, String name, String desc, String className) {
|
||||
super(api, mv, access, name, desc);
|
||||
this.className = className;
|
||||
this.methodName = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMethodEnter() {
|
||||
if (className.equals("com/sample/asm/MainActivity") && methodName.equals("mm")) {
|
||||
mv.visitTryCatchBlock(tryStart, tryEnd, catchStart, "java/lang/Exception");
|
||||
mv.visitLabel(tryStart);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMethodExit(int opcode) {
|
||||
if (className.equals("com/sample/asm/MainActivity") && methodName.equals("mm")) {
|
||||
mv.visitLabel(tryEnd);
|
||||
mv.visitJumpInsn(GOTO, catchEnd);
|
||||
mv.visitLabel(catchStart);
|
||||
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/RuntimeException", "printStackTrace", "()V", false);
|
||||
mv.visitInsn(Opcodes.RETURN);
|
||||
mv.visitLabel(catchEnd);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
visitTryCatchBlock方法:前三个参数均是Label实例,其中一、二表示try块的范围,三则是catch块的开始位置,第四个参数是异常类型。其他的方法及参数就不细说了,具体你可以参考ASM文档。
|
||||
|
||||
实现类似AspectJ,在方法执行开始及结束时插入我们的代码。
|
||||
|
||||
效果我就不截图了,代码如下:
|
||||
|
||||
public void mm() {
|
||||
try {
|
||||
A a = new A(new B(2));
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2.查看代码中谁获取了IMEI
|
||||
|
||||
这个就更简单了,直接寻找谁使用了TelephonyManager的getDeviceId方法,并且在Sample中有答案。
|
||||
|
||||
public class IMEIMethodAdapter extends AdviceAdapter {
|
||||
|
||||
private final String methodName;
|
||||
private final String className;
|
||||
|
||||
protected IMEIMethodAdapter(int api, MethodVisitor mv, int access, String name, String desc, String className) {
|
||||
super(api, mv, access, name, desc);
|
||||
this.className = className;
|
||||
this.methodName = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
|
||||
super.visitMethodInsn(opcode, owner, name, desc, itf);
|
||||
|
||||
if (owner.equals("android/telephony/TelephonyManager") && name.equals("getDeviceId") && desc.equals("()Ljava/lang/String;")) {
|
||||
Log.e("asmcode", "get imei className:%s, method:%s, name:%s", className, methodName, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Build后输出如下:
|
||||
|
||||
|
||||
|
||||
总体来说ASM的上手难度还是高于AspectJ,需要我们了解编译后的字节码,这里所使用的功能也只是冰山一角。课代表鹏飞同学推荐的ASM Bytecode Outline插件是个好帮手!最后我将我练习的代码也上传到了GitHub,里面还包括一份中文版的ASM文档,有兴趣的同学可以下载看看。
|
||||
|
||||
参考
|
||||
|
||||
|
||||
练习Sample跑起来 | ASM插桩强化练
|
||||
ASM文档
|
||||
|
||||
|
||||
|
||||
|
||||
|
182
专栏/Android开发高手课/练习Sample跑起来热点问题答疑第1期.md
Normal file
182
专栏/Android开发高手课/练习Sample跑起来热点问题答疑第1期.md
Normal file
@ -0,0 +1,182 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
练习Sample跑起来 热点问题答疑第1期
|
||||
你好,我是专栏的“学习委员”孙鹏飞。
|
||||
|
||||
专栏上线以来很多同学反馈,说在运行练习Sample的时候遇到问题。由于这些Sample多是采用C/C++来完成的,所以在编译运行上会比传统的纯Java项目稍微复杂一些。今天我就针对第1期~第4期中,同学们集中遇到的问题做一期答疑。设置练习的目的,也是希望你在学习完专栏的内容后,可以快速上手试验一下专栏所讲的工具或方法,帮你加快掌握技术的精髓。所以希望各位同学可以多参与进来,有任何问题也可以在留言区给我们反馈,后面我还会不定期针对练习再做答疑。
|
||||
|
||||
编译环境配置
|
||||
|
||||
首先是同学们问得比较多的运行环境问题。
|
||||
|
||||
前几期的练习Sample大多是使用C/C++开发的,所以要运行起来需要先配置好SDK和NDK,SDK我们一般都是配置好的,NDK环境的配置有一些特殊的地方,一般我们的Sample都会使用最新的NDK版本,代码可能会使用C++11/14的语法进行编写,并且使用CMake进行编译,我这里给出NDK环境的配置项。
|
||||
|
||||
首先需要去NDK官网下载最新版本,下载后可以解压到合适的地方,一般macOS可以存放在 ANDROID_SDK_HOME/ndk_bundle目录下,Android Studio可以默认找到该目录。如果放到别的目录,可能需要自己指定一下。
|
||||
|
||||
指定NDK目录的方法一般有下面两种。
|
||||
|
||||
1.在练习Sample根目录下都会有一个local.properties文件,修改其中的ndk.dir路径即可。
|
||||
|
||||
ndk.dir=/Users/sample/Library/Android/sdk/ndk-bundle
|
||||
sdk.dir=/Users/sample/Library/Android/sdk
|
||||
|
||||
|
||||
2.可以在Android Studio里进行配置,打开File -> Project Structure -> SDK Location进行修改。
|
||||
|
||||
|
||||
|
||||
上面两种修改方法效果是一致的。
|
||||
|
||||
有些Sample需要降级NDK编译使用,可能需要下载旧版本的NDK,可以从官网下载。
|
||||
|
||||
之后需要安装CMake和LLDB。
|
||||
|
||||
|
||||
CMake:一款外部构建工具,可与Gradle搭配使用来构建原生库。
|
||||
|
||||
LLDB:一种调试程序,Android Studio使用它来调试原生代码。
|
||||
|
||||
|
||||
这两项都可以在Tools > Android > SDK Manager里进行安装。
|
||||
|
||||
|
||||
|
||||
这样我们编译所需要的环境就配置好了。
|
||||
|
||||
热点问题答疑
|
||||
|
||||
01 | 崩溃优化(上):关于“崩溃”那些事儿
|
||||
|
||||
关于第1期的Sample,同学们遇到的最多的问题是使用模拟器运行无法获取Crash日志的问题。
|
||||
|
||||
引起这个问题的缘由比较深层,最直观的原因是使用Clang来编译x86平台下的Breakpad会导致运行出现异常,从而导致无法抓取日志。想要解决这个问题,我们需要先来了解一下NDK集成的编译器。
|
||||
|
||||
NDK集成了两套编译器:GCC和Clang。从NDK r11开始,官方就建议使用Clang,详情可以看ChangeLog,并且标记GCC为Deprecated,并且从GCC 4.8升级到4.9以后就不再进行更新了。NDK r13开始,默认使用Clang。NDK r16b以后的版本貌似强制开启GCC会引起错误,并将libc++作为默认的STL,而NDK r18干脆就完全删除了GCC。
|
||||
|
||||
由于Clang的编译会引起x86的Breakpad执行异常,所以我们需要切换到GCC下进行编译,步骤如下。
|
||||
|
||||
1.首先将NDK切换到r16b,你可以从这里下载,在里面找到对应你操作系统平台的NDK版本。
|
||||
|
||||
2.在Android Studio里设置NDK路径为ndk-16b的路径。
|
||||
|
||||
3.在练习例子源码的sample和breakpad-build的build.gradle配置里进行如下配置。
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags "-std=c++11"
|
||||
arguments "-DANDROID_TOOLCHAIN=gcc"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
第二个问题是日志解析工具如何获取。
|
||||
|
||||
解析Minidump日志主要是使用minidump_stackwalk工具,配合使用的工具是dump_syms,这个工具可以获取一个so文件的符号表。
|
||||
|
||||
这两项工具需要通过编译Breakpad来获取,有部分同学查到的文章是采用Chrome团队的depot_tools来进行工具的源码下载、编译操作。depot_tools是个很好用的工具,但是在国内其服务器是无法访问的,所以我们采用直接下载源码编译的方式相对来说比较方便。
|
||||
|
||||
编译Breakpad有一些需要注意的地方,由于Android平台的内核是Linux,Android里的动态链接库的符号表导出工具dump_syms需要运行在Linux下(暂时没有找到交叉编译在别的平台上的办法),所以下面的步骤都是在Linux环境(Ubuntu 18.04)下进行的,步骤如下。
|
||||
|
||||
1.先下载源码。
|
||||
|
||||
2.由于源码里没有附带上一些第三方的库,所以现在编译会出现异常,我们需要下载lss库到Breakpad源码目录src/third_party下面。
|
||||
|
||||
git clone https://chromium.googlesource.com/linux-syscall-support
|
||||
|
||||
|
||||
3.然后在源码目录下执行。
|
||||
|
||||
./configure && make
|
||||
make install
|
||||
|
||||
|
||||
这样我们就可以直接调用minidump_stackwalk、dump_syms工具了。
|
||||
|
||||
第三个问题是如何解析抓取下来的Minidump日志。
|
||||
|
||||
生成的Crash信息,如果授予Sdcard权限会优先存放在/sdcard/crashDump下,便于我们做进一步的分析。反之会放到目录/data/data/com.dodola.breakpad/files/crashDump下。
|
||||
|
||||
你可以通过adb pull命令拉取日志文件。
|
||||
|
||||
adb pull /sdcard/crashDump/
|
||||
|
||||
|
||||
1.首先我们需要从产生Crash的动态库中提取出符号表,以第1期的Sample为例,产生Crash的动态库obj路径在Chapter01/sample/build/intermediates/cmake/debug/obj下。
|
||||
|
||||
|
||||
|
||||
此处需要注意一下手机平台,按照运行Sample时的平台取出libcrash-lib.so库进行符号表的dump,然后调用dump_syms工具获取符号表。
|
||||
|
||||
dump_syms libcrash-lib.so > libcrash-lib.so.sym
|
||||
|
||||
|
||||
2.建立符号表目录结构。首先打开刚才生成的libcrash-lib.so.syms,找到如下编码。
|
||||
|
||||
MODULE Linux arm64 322FCC26DA8ED4D7676BD9A174C299630 libcrash-lib.so
|
||||
|
||||
|
||||
然后建立如下结构的目录Symbol/libcrash-lib.so/322FCC26DA8ED4D7676BD9A174C299630/,将libcrash-lib.so.sym文件复制到该文件夹中。注意,目录结构不能有错,否则会导致符号表对应失败。
|
||||
|
||||
3.完成上面的步骤后,就可以来解析Crash日志了,执行minidump_stackwalk命令。
|
||||
|
||||
minidump_stackwalk crash.dmp ./Symbol > dump.txt
|
||||
|
||||
|
||||
4.这样我们获取的crash日志就会有符号表了,对应一下之前没有符号表时候的日志记录。
|
||||
|
||||
|
||||
|
||||
5.如果我们没有原始的obj,那么需要通过libcrash-lib.so的导出符号来进行解析,这里用到的工具是addr2line工具,这个工具存放在$NDK_HOME/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-addr2line下。你要注意一下平台,如果是解析64位的动态库,需要使用aarch64-linux-android-4.9下的addr2line(此处是64位的)。
|
||||
|
||||
aarch64-linux-android-addr2line -f -C -e libcrash-lib.so 0x5f8
|
||||
Java_com_dodola_breakpad_MainActivity_crash
|
||||
|
||||
|
||||
6.可以使用GDB来根据Minidump调试出问题的动态库,这里就不展开了,你可以参考这里。
|
||||
|
||||
03 | 内存优化(上):4GB内存时代,再谈内存优化
|
||||
|
||||
针对这一期的Sample,很多同学询问Sample中经常使用的Hook框架的原理。
|
||||
|
||||
Sample中使用的Hook框架有两种,一种是Inline Hook方案(Substrate和HookZz),一种是PLT Hook方案(Facebook Hook),这两种方案各有优缺点,根据要实现功能的不同采取不同的框架。
|
||||
|
||||
PLT Hook相对Inline Hook的方案要稳定很多,但是它操作的范围只是针对出现在PLT表中的动态链接函数,而Inline Hook可以hook整个so里的所有代码。Inline Hook由于要针对各个平台进行指令修复操作,所以稳定性和兼容性要比PLT Hook差很多。
|
||||
|
||||
关于PLT Hook的内容,你可以看一下《程序员的自我修养:链接、装载与库》这本书,而Inline Hook则需要对ARM、x86汇编,以及各个平台下的过程调用标准(Procedure Call Standard)有很深入的了解。
|
||||
|
||||
第3期里,还有部分同学询问Sample中的函数符号是如何来的。
|
||||
|
||||
首先如果我们要hook一个函数,需要知道这个函数的地址。在Linux下我们获取函数的地址可以通过dlsym函数来根据名字获取,动态库里的函数名称一般都会通过Name Mangling技术来生成一个符号名称(具体细节可以看这篇文章),所以第3期的Sample里出现了很多经过转换的函数名。
|
||||
|
||||
void *hookRecordAllocation26 = ndk_dlsym(handle,
|
||||
"_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPNS_6ObjPtrINS_6mirror6ObjectEEEj");
|
||||
|
||||
void *hookRecordAllocation24 = ndk_dlsym(handle, "_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPPNS_6mirror6ObjectEj");
|
||||
|
||||
|
||||
这样的函数可以通过c++filt工具来进行反解,我在这里给你提供一个网页版的解析工具。
|
||||
|
||||
|
||||
|
||||
我们需要阅读系统源码来寻找Hook点,比如第3期里Hook的方法都是虚拟机内存分配相关的函数。需要注意的一点是,要先确认是否存在该函数的符号,很多时候由于强制Inline的函数或者过于短小的函数可能没有对应的符号,这时候就需要使用objdump、readelf、nm或者各种disassembly工具进行查看,根据类名、函数名查找一下有没有对应的符号。
|
||||
|
||||
总结
|
||||
|
||||
第1期的Breakpad的Sample主要是展示Native Crash的日志如何获取和解读。根据业务的不同,我们平时接触的很多都是Java的异常,在业务不断稳定、代码异常处理逐渐完善的情况下,Java异常的量会逐渐减少,而Native Crash的问题会逐步的显现出来。一般比较大型的应用,都会或多或少包含一些Native库,比如加密、地图、日志、Push等模块,由于多方面的原因,这些代码会产生一些异常,我们需要了解Crash日志来排查解决,又或者说绕过这些异常,进而提高应用的稳定性。
|
||||
|
||||
通过Breakpad的源码,以帮你了解到信号捕获、ptrace的使用、进程fork/clone机制、主进程子进程通信、unwind stack、system info的获取、memory maps info的获取、symbol的dump,以及symbol反解等,通过源码我们可以学习到很多东西。
|
||||
|
||||
第2期的Sample提供了解决系统异常的一种思路,使用反射或者代理机制来解决系统代码中的异常。需要说明的是FinalizerWatchdog机制并不是系统异常,而是系统的一种防护机制。很多时候我们会遇到一些系统Framework的bug产生的Crash,比如很常见的Toast异常等,这些异常虽然不属于本应用产生的,但也会影响用户的使用,解决这种异常可以考虑一下这个Sample中的思路。
|
||||
|
||||
第3期的Sample描述了一个简单的Memory Allocation Trace监控模块,这个模块主要是配合自动性能分析体系来自动发现问题,比如大对象的分配数量监控、分配对象的调用栈分析等。它可以做的事很多,同学们可以根据这个思路,根据自己的业务来开发适合自己的工具。
|
||||
|
||||
从第3期的Sample的代码,你可以学习到Inline Hook Substrate框架的使用,使用ndk_dlopen来绕过Android Classloader-Namespace Restriction机制,以及C++里的线程同步等。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
|
||||
|
118
专栏/Android开发高手课/练习Sample跑起来热点问题答疑第2期.md
Normal file
118
专栏/Android开发高手课/练习Sample跑起来热点问题答疑第2期.md
Normal file
@ -0,0 +1,118 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
练习Sample跑起来 热点问题答疑第2期
|
||||
你好,我是孙鹏飞。今天我们基于专栏第5期的练习Sample以及热点问题,我来给你做答疑。有关上一期答疑,你可以点击这里查看。
|
||||
|
||||
为了让同学们可以进行更多的实践,专栏第5期Sample采用了让你自己实现部分功能的形式,希望可以让你把专栏里讲的原理可以真正用起来。
|
||||
|
||||
前面几期已经有同学通过Pull request提交了练习作业,这里要给每位参与练习、提交作业的同学点个赞。
|
||||
|
||||
第5期的作业是根据系统源码来完成一个CPU数据的采集工具,并且在结尾我们提供了一个案例让你进行分析。我已经将例子的实现提交到了GitHub上,你可以参考一下。
|
||||
|
||||
在文中提到,“当发生ANR的时候,Android系统会打印CPU相关的信息到日志中,使用的是ProcessCpuTracker.java”。ProcessCpuTracker的实现主要依赖于Linux里的/proc伪文件系统(in-memory pseudo-file system),主要使用到了/proc/stat、/proc/loadavg、/proc/[pid]/stat、/proc/[pid]/task相关的文件来读取数据。在Linux中有很多程序都依赖/proc下的数据,比如top、netstat、ifconfig等,Android里常用的procrank、librank、procmem等也都以此作为数据来源。关于/proc目录的结构在Linux Man Pages里有很详细的说明,在《Linux/Unix系统编程手册》这本书里,也有相关的中文说明。
|
||||
|
||||
关于proc有一些需要说明的地方,在不同的Linux内核中,该目录下的内容可能会有所不同,所以如果要使用该目录下的数据,可能需要做一些版本上的兼容处理。并且由于Linux内核更新速度较快,文档的更新可能还没有跟上,这就会导致一些数据和文档中说明的不一致,尤其是大量的以空格隔开的数字数据。这些文件其实并不是真正的文件,你用ls查看会发现它们的大小都是0,这些文件都是系统虚拟出来的,读取这些文件并不会涉及文件系统的一系列操作,只有很小的性能开销,而现阶段并没有类似文件系统监听文件修改的回调,所以需要采用轮询的方式来进行数据采集。
|
||||
|
||||
下面我们来看一下专栏文章结尾的案例分析。下面是这个示例的日志数据,我会通过分析数据来猜测一下是什么原因引起,并用代码还原这个情景。
|
||||
|
||||
usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000):
|
||||
System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle
|
||||
CPU Core: 8
|
||||
Load Average: 8.74 / 7.74 / 7.36
|
||||
|
||||
Process:com.sample.app
|
||||
50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965
|
||||
|
||||
Threads:
|
||||
43% 23493/singleThread(R): 6.5% user + 36% kernel faults:3094
|
||||
3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults:329
|
||||
0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults:6
|
||||
0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults:982
|
||||
\.\.\.
|
||||
|
||||
|
||||
上面的示例展示了一段在5秒时间内CPU的usage的情况。初看这个日志,你可以收集到几个重要信息。
|
||||
|
||||
1.在System Total部分user占用不多,CPU idle很高,消耗多在kernel和iowait。
|
||||
|
||||
2.CPU是8核的,Load Average大约也是8,表示CPU并不处于高负载情况。
|
||||
|
||||
3.在Process里展示了这段时间内sample app的CPU使用情况:user低,kernel高,并且有4965次page faults。
|
||||
|
||||
4.在Threads里展示了每个线程的usage情况,当前只有singleThread处于R状态,并且当前线程产生了3096次page faults,其他的线程包括主线程(Sample日志里可见的)都是处于S状态。
|
||||
|
||||
根据内核中的线程状态的宏的名字和缩写的对应,R值代表线程处于Running或者Runnable状态。Running状态说明线程当前被某个Core执行,Runnable状态说明线程当前正在处于等待队列中等待某个Core空闲下来去执行。从内核里看两个状态没有区别,线程都会持续执行。日志中的其他线程都处于S状态,S状态代表TASK_INTERRUPTIBLE,发生这种状态是线程主动让出了CPU,如果线程调用了sleep或者其他情况导致了自愿式的上下文切换(Voluntary Context Switches)就会处于S状态。常见的发生S状态的原因,可能是要等待一个相对较长时间的I/O操作或者一个IPC操作,如果一个I/O要获取的数据不在Buffer Cache或者Page Cache里,就需要从更慢的存储设备上读取,此时系统会把线程挂起,并放入一个等待I/O完成的队列里面,在I/O操作完成后产生中断,线程重新回到调度序列中。但只根据文中这个日志,并不能判定是何原因所引起的。
|
||||
|
||||
还有就是SingleThread的各项指标都相对处于一个很高的情况,而且产生了一些faults。page faluts分为三种:minor page fault、major page fault和invalid page fault,下面我们来具体分析。
|
||||
|
||||
minor page fault是内核在分配内存的时候采用一种Lazy的方式,申请内存的时候并不进行物理内存的分配,直到内存页被使用或者写入数据的时候,内核会收到一个MMU抛出的page fault,此时内核才进行物理内存分配操作,MMU会将虚拟地址和物理地址进行映射,这种情况产生的page fault就是minor page fault。
|
||||
|
||||
major page fault产生的原因是访问的内存不在虚拟地址空间,也不在物理内存中,需要从慢速设备载入,或者从Swap分区读取到物理内存中。需要注意的是,如果系统不支持zRAM来充当Swap分区,可以默认Android是没有Swap分区的,因为在Android里不会因为读取Swap而发生major page fault的情况。另一种情况是mmap一个文件后,虚拟内存区域、文件磁盘地址和物理内存做一个映射,在通过地址访问文件数据的时候发现内存中并没有文件数据,进而产生了major page fault的错误。
|
||||
|
||||
根据page fault发生的场景,虚拟页面可能有四种状态:
|
||||
|
||||
|
||||
第一种,未分配;
|
||||
第二种,已经分配但是未映射到物理内存;
|
||||
第三种,已经分配并且已经映射到物理内存;
|
||||
第四种,已经分配并映射到Swap分区(在Android中此种情况基本不存在)。
|
||||
|
||||
|
||||
通过上面的讲解并结合page fault数据,你可以看到SingleThread你一共发生了3094次fault,根据每个页大小为4KB,可以知道在这个过程中SingleThread总共分配了大概12MB的空间。
|
||||
|
||||
下面我们来分析iowait数据。既然有iowait的占比,就说明在5秒内肯定进行了I/O操作,并且iowait占比还是比较大的,说明当时可能进行了大量的I/O操作,或者当时由于其他原因导致I/O操作缓慢。
|
||||
|
||||
从上面的分析可以猜测一下具体实现,并且在读和写的时候都有可能发生。由于我的手机写的性能要低一些,比较容易复现,所以下面的代码基于写操作实现。
|
||||
|
||||
File f = new File(getFilesDir(), "aee.txt");
|
||||
|
||||
FileOutputStream fos = new FileOutputStream(f);
|
||||
|
||||
byte[] data = new byte[1024 * 4 * 3000];//此处分配一个12mb 大小的 byte 数组
|
||||
|
||||
for (int i = 0; i < 30; i++) {//由于 IO cache 机制的原因所以此处写入多次cache,触发 dirty writeback 到磁盘中
|
||||
Arrays.fill(data, (byte) i);//当执行到此处的时候产生 minor fault,并且产生 User cpu useage
|
||||
fos.write(data);
|
||||
}
|
||||
fos.flush();
|
||||
fos.close();
|
||||
|
||||
|
||||
上面的代码抓取到的CPU数据如下。
|
||||
|
||||
E/ProcessCpuTracker: CPU usage from 5187ms to 121ms ago (2018-12-28 08:28:27.186 to 2018-12-28 08:28:32.252):
|
||||
40% 24155/com.sample.processtracker(R): 14% user + 26% kernel / faults: 5286 minor
|
||||
thread stats:
|
||||
35% 24184/SingleThread(S): 11% user + 24% kernel / faults: 3055 minor
|
||||
2.1% 24174/RenderThread(S): 1.3% user + 0.7% kernel / faults: 384 minor
|
||||
1.5% 24155/.processtracker(R): 1.1% user + 0.3% kernel / faults: 95 minor
|
||||
0.1% 24166/HeapTaskDaemon(S): 0.1% user + 0% kernel / faults: 1070 minor
|
||||
|
||||
100% TOTAL(): 3.8% user + 7.8% kernel + 11% iowait + 0.1% irq + 0% softirq + 76% idle
|
||||
Load: 6.31 / 6.52 / 6.66
|
||||
|
||||
|
||||
可以对比Sample中给出的数据,基本一致。
|
||||
|
||||
通过上面的说明,你可以如法炮制去分析ANR日志中相关的数据来查找性能瓶颈,比如,如果产生大量的major page fault其实是不太正常的,或者iowait过高就需要关注是否有很密集的I/O操作。
|
||||
|
||||
相关资料
|
||||
|
||||
|
||||
低内存配置
|
||||
iowait的形成原因和内核分析
|
||||
page fault带来的性能问题
|
||||
Linux工具快速教程
|
||||
Android: memory management insights, part I
|
||||
Linux 2.6调度系统分析
|
||||
《性能之巅》
|
||||
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
|
||||
|
98
专栏/Android开发高手课/练习Sample跑起来热点问题答疑第3期.md
Normal file
98
专栏/Android开发高手课/练习Sample跑起来热点问题答疑第3期.md
Normal file
@ -0,0 +1,98 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
练习Sample跑起来 热点问题答疑第3期
|
||||
你好,我是孙鹏飞。又到了答疑的时间,今天我将围绕卡顿优化这个主题,和你探讨一下专栏第6期和补充篇的两个Sample的实现。
|
||||
|
||||
专栏第6期的Sample完全来自于Facebook的性能分析框架Profilo,主要功能是收集线上用户的atrace日志。关于atrace相信我们都比较熟悉了,平时经常使用的systrace工具就是封装了atrace命令来开启ftrace事件,并读取ftrace缓冲区生成可视化的HTML日志。这里多说一句,ftrace是Linux下常用的内核跟踪调试工具,如果你不熟悉的话可以返回第6期文稿最后查看ftrace的介绍。Android下的atrace扩展了一些自己使用的categories和tag,这个Sample获取的就是通过atrace的同步事件。
|
||||
|
||||
Sample的实现思路其实也很简单,有两种方案。
|
||||
|
||||
第一种方案:hook掉atrace写日志时的一系列方法。以Android 9.0的代码为例写入ftrace日志的代码在trace-dev.cpp里,由于每个版本的代码有些区别,所以需要根据系统版本做一些区分。
|
||||
|
||||
第二种方案:也是Sample里所使用的方案,由于所有的atrace event写入都是通过/sys/kernel/debug/tracing/trace_marker,atrace在初始化的时候会将该路径fd的值写入atrace_marker_fd全局变量中,我们可以通过dlsym轻易获取到这个fd的值。关于trace_maker这个文件我需要说明一下,这个文件涉及ftrace的一些内容,ftrace原来是内核的事件trace工具,并且ftrace文档的开头已经写道
|
||||
|
||||
|
||||
Ftrace is an internal tracer designed to help out developers and designers of systems to find what is going on inside the kernel.
|
||||
|
||||
|
||||
从文档中可以看出来,ftrace工具主要是用来探查outside of user-space的性能问题。不过在很多场景下,我们需要知道user space的事件调用和kernel事件的一个先后关系,所以ftrace也提供了一个解决方法,也就是提供了一个文件trace_marker,往该文件中写入内容可以产生一条ftrace记录,这样我们的事件就可以和kernel的日志拼在一起。但是这样的设计有一个不好的地方,在往文件写入内容的时候会发生system call调用,有系统调用就会产生用户态到内核态的切换。这种方式虽然没有内核直接写入那么高效,但在很多时候ftrace工具还是很有用处的。
|
||||
|
||||
由此可知,用户态的事件数据都是通过trace_marker写入的,更进一步说是通过write接口写入的,那么我们只需要hook住write接口并过滤出写入这个fd下的内容就可以了。这个方案通用性比较高,而且使用PLT Hook即可完成。
|
||||
|
||||
下一步会遇到的问题是,想要获取atrace的日志,就需要设置好atrace的category tag才能获取到。我们从源码中可以得知,判断tag是否开启,是通过atrace_enabled_tags & tag来计算的,如果大于0则认为开启,等于0则认为关闭。下面我贴出了部分atrace_tag的值,你可以看到,判定一个tag是否是开启的,只需要tag值的左偏移数的位值和atrace_enabled_tags在相同偏移数的位值是否同为1。其实也就是说,我将atrace_enabled_tags的所有位都设置为1,那么在计算时候就能匹配到任何的atrace tag。
|
||||
|
||||
#define ATRACE_TAG_NEVER 0
|
||||
#define ATRACE_TAG_ALWAYS (1<<0)
|
||||
#define ATRACE_TAG_GRAPHICS (1<<1)
|
||||
#define ATRACE_TAG_INPUT (1<<2)
|
||||
#define ATRACE_TAG_VIEW (1<<3)
|
||||
#define ATRACE_TAG_WEBVIEW (1<<4)
|
||||
#define ATRACE_TAG_WINDOW_MANAGER (1<<5)
|
||||
#define ATRACE_TAG_ACTIVITY_MANAGER (1<<6)
|
||||
#define ATRACE_TAG_SYNC_MANAGER (1<<7)
|
||||
#define ATRACE_TAG_AUDIO (1<<8)
|
||||
#define ATRACE_TAG_VIDEO (1<<9)
|
||||
#define ATRACE_TAG_CAMERA (1<<10)
|
||||
#define ATRACE_TAG_HAL (1<<11)
|
||||
#define ATRACE_TAG_APP (1<<12)
|
||||
|
||||
|
||||
下面是我用atrace抓下来的部分日志。
|
||||
|
||||
|
||||
|
||||
看到这里有同学会问,Begin和End是如何对应上的呢?要回答这个问题,首先要先了解一下这种记录产生的场景。这个日志在Java端是由Trace.traceBegin和Trace.traceEnd产生的,在使用上有一些硬性要求:这两个方法必须成对出现,否则就会造成日志的异常。请看下面的系统代码示例。
|
||||
|
||||
void assignWindowLayers(boolean setLayoutNeeded) {
|
||||
2401 Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "assignWindowLayers");//关注此处事件开始代码
|
||||
2402 assignChildLayers(getPendingTransaction());
|
||||
2403 if (setLayoutNeeded) {
|
||||
2404 setLayoutNeeded();
|
||||
2405 }
|
||||
2406
|
||||
2411 scheduleAnimation();
|
||||
2412 Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);//事件结束
|
||||
2413 }
|
||||
2414
|
||||
|
||||
|
||||
所以我们可以认为B下面紧跟的E就是事件的结束标志,但很多情况下我们会遇到上面日志中所看到的两个B连在一起,紧跟的两个E我们不知道分别对应哪个B。此时我们需要看一下产生事件的CPU是哪个,并且看一下产生事件的task_pid是哪个,也就是最前面的InputDispatcher-1944,这样我们就可以对应出来了。
|
||||
|
||||
接下来我们一起来看看补充篇的Sample,它的目的是希望让你练习一下如何监控线程创建,并且打印出创建线程的Java方法。Sample的实现比较简单,主要还是依赖PLT Hook来hook线程创建时使用的主要函数pthread_create。想要完成这个Sample你需要知道Java线程是如何创建出来的,并且还要理解Java线程的执行方式。需要特别说明的是,其实这个Sample也存在一个缺陷。从虚拟机的角度看,线程其实又分为两种,一种是Attached线程,我习惯按照.Net的叫法称其为托管线程;一种是Unattached线程,为非托管线程。但底层都是依赖POSIX Thread来实现的,从pthread_create里无法区分该线程是否是托管线程,也有可能是Native直接开启的线程,所以有可能并不能对应到创建线程时候的Java Stack。
|
||||
|
||||
关于线程,我们在日常监控中可能并不太关心线程创建时候的状况,而区分线程可以通过提前设置Thread Name来实现。举个例子,比如在出现OOM时发现是发生在pthread_create执行的时候,说明当前线程数可能过多,一般我们会在OOM的时候采集当前线程数和线程堆栈信息,可以看一下是哪个线程创建过多,如果指定了线程名称则很快就能查找出问题所在。
|
||||
|
||||
对于移动端的线程来说,我们大多时候更关心的是主线程的执行状态。因为主线程的任何耗时操作都会影响操作界面的流畅度,所以我们经常把看起来比较耗时的操作统统都往子线程里面丢,虽然这种操作虽然有时候可能很有效,但还可能会产生一些我们平时很少遇到的异常情况。比如我曾经遇到过,由于用户手机的I/O性能很低,大量的线程都在wait io;或者线程开启的太多,导致线程Context switch过高;又或者是一个方法执行过慢,导致持有锁的时间过长,其他线程无法获取到锁等一系列异常的情况,
|
||||
|
||||
虽然线程的监控很不容易,但并不是不能实现,只是实现起来比较复杂并且要考虑兼容性。比如我们可能比较关心一个Lock当前有多少线程在等待锁释放,就需要先获取到这个Object的MirrorObject,然后构造一个MonitorInfo,之后获取到waiters的列表,而这个列表里就存储了等待锁释放的线程。你看其实过程也并不复杂,只是在计算地址偏移量的时候需要做一些处理。
|
||||
|
||||
当然还有更细致的优化,比如我们都知道Java里是有轻量级锁和重量级锁的一个转换过程,在ART虚拟机里被称为ThinLocked和FatLocked,而转换过程是通过Monitor::Inflate和Monitor::Deflate函数来实现的。此时我们可以监控Monitor::Inflate调用时monitor指向的Object,来判断是哪段代码产生了“瘦锁”到“胖锁”转换的过程,从而去做一些优化。接下来要做优化,需要先知晓ART虚拟机锁转换的机制,如果当前锁是瘦锁,持有该锁的线程再一次获取这个锁只递增了lock count,并未改变锁的状态。但是lock count超过4096则会产生瘦锁到胖锁的转换,如果当前持有该锁的线程和进入MontorEnter的线程不是同一个的情况下就会产生锁争用的情况。ART虚拟机为了减少胖锁的产生做了一些优化,虚拟机先通过sched_yield让出当前线程的执行权,操作系统在后面的某个时间再次调度该线程执行,从调用sched_yield到再次执行的时候计算时间差,在这个时间差里占用该锁的线程可能会释放对锁的占用,那么调用线程会再次尝试获取锁,如果获取锁成功的话则会从 Unlocked状态直接转换为ThinLocked状态,不会产生FatLocked状态。这个过程持续50次,如果在50次循环内无法获取到锁则会将瘦锁转为胖锁。如果我们对某部分的多线程代码性能敏感,则希望锁尽量持续在瘦锁的状态,我们可以减少同步块代码的粒度,尽量减少很多线程同时争抢锁,可以监控Inflate函数调用情况来判断优化效果。
|
||||
|
||||
最后,还有同学对在Crash状态下获取Java线程堆栈的方法比较感兴趣,我在这里简单讲一下,后面会有专门的文章介绍这部分内容。
|
||||
|
||||
一种方案是使用ThreadList::ForEach接口间接实现,具体的逻辑可以看这里。另一种方案是 Profilo里的Unwinder机制,这种实现方式就是模拟StackVisitor的逻辑来实现。
|
||||
|
||||
这两期反馈的问题不多,答疑的内容也可以算作对正文的补充,如果有同学想多了解虚拟机的机制或者其他性能相关的问题,欢迎你给我留言,我也会在后面的文章和你聊聊这些话题,比如有同学问到的ART下GC的详细逻辑之类的问题。
|
||||
|
||||
相关资料
|
||||
|
||||
|
||||
ftrace kernel doc
|
||||
|
||||
ftrace的使用
|
||||
|
||||
A look at ftrace
|
||||
|
||||
|
||||
福利彩蛋
|
||||
|
||||
今天为认真提交作业完成练习的同学,送出第二波“学习加油礼包”。@Seven同学提交了第5期的作业,送出“极客周历”一本,其他同学如果完成了练习千万别忘了通过Pull request提交哦。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
|
||||
|
45
专栏/Android开发高手课/练习Sample跑起来热点问题答疑第4期.md
Normal file
45
专栏/Android开发高手课/练习Sample跑起来热点问题答疑第4期.md
Normal file
@ -0,0 +1,45 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
练习Sample跑起来 热点问题答疑第4期
|
||||
你好,我是孙鹏飞。今天我们回到专栏第7期和第8期,来看看课后练习Sample的运行需要注意哪些问题。另外我结合同学们留言的疑问,也来谈谈文件顺序对I/O的影响,以及关于Linux学习我的一些方法和建议。
|
||||
|
||||
专栏第7期的Sample借助于systrace工具,通过字节码处理框架对函数插桩来获取方法执行的trace。这个Sample实现相当完整,你在日常工作也可以使用它。
|
||||
|
||||
这个Sample使用起来虽然非常简单,但其内部的实现相对来说是比较复杂的。它的实现涉及Gradle Transform、Task实现、增量处理、ASM字节码处理、mapping文件使用,以及systrace工具的使用等。
|
||||
|
||||
对于Gradle来说,我们应该比较熟悉,它是Android平台下的构建工具。对于平时使用来说,我们大多时候只需要关注Android Gradle Plugin的一些参数配置就可以实现很多功能了,官方文档已经提供了很详细的参数设置说明。对于一些需要侵入打包流程的操作,就需要我们实现自己的Task或者Transform代码来完成,比如处理Class和JAR包、对资源做一些处理等。
|
||||
|
||||
Gradle学习的困难更多来自于Android Gradle Plugin对Gradle做的一些封装扩展,而这部分Google并没有提供很完善的文档,并且每个版本都有一些接口上的变动。对于这部分内容的学习,我主要是去阅读别人实现的Gradle工具代码和Android Gradle Plugin代码。
|
||||
|
||||
关于这期的Sample实现,有几个可能产生疑问的地方我们来探讨一下。
|
||||
|
||||
这个Sample的Gradle插件是发布到本地Maven库的,所以如果没有执行发布直接编译需要先发布插件库到本地Maven中才能执行编译成功。
|
||||
|
||||
另一个可能遇到问题的是,如果你想把Sample使用到其他项目,需要自己将SampleApp中其p的e.systrace.TraceTag”类移植到自己的项目中,否则会产生编译错误。
|
||||
|
||||
对于字节码处理,在Sample中主要使用了ASM框架来处理。市面上关于字节码处理的框架有很多,常见的有ASM和Javassist框架,其他的框架你可以使用“Java bytecode manipulation”关键字在Google上搜索。使用字节码处理框架需要对字节码有比较深入的了解,要提醒你的是这里的字节码不是Dalvik bytecode而是Java bytecode。对于字节码的学习,你可以参考官方文档和《Java虚拟机规范》,里面对字节码的执行规则和指令说明都有很详细的描述。并且还可以配合javap命令查看反编译的字节码对应的源码,这样学习下来会有很好的效果。字节码处理是一个很细微的操作,稍有失误就会产生编译错误、执行错误或者Crash的情况,里面需要注意的地方也非常多,比如Try Catch Block对操作数栈的影响、插入的代码对本地变量表和操作数栈的影响等。
|
||||
|
||||
实现AOP的另一种方是可以接操作Dex文件进行Dalvik bytecode字节码注入,关于这种实现方式可以使用dexer库来完成,在Facebook的Redex中也提供了针对dex的AOP功能。
|
||||
|
||||
下面我们来看专栏第8期。我从文章留言里看到,有同学关于数据重排序对I/O性能的影响有些疑问,不太清楚优化的原理。其实这个优化原理理解起来是很容易的,在进行文件读取的操作过程中,系统会读取比预期更多的文件内容并缓存在Page Cache中,这样下一次读请求到来时,部分页面直接从Page Cache读取,而不用再从磁盘中获取数据,这样就加速了读取的操作。在《支付宝App构建优化解析》里“原理”一节中已经有比较详细的描述,我就不多赘述了。如果你对“预读”感兴趣的话,我给你提供一些资料,可以深入了解一下。
|
||||
|
||||
预读(readhead)机制的系统源码在readhead.c文件中。需要说明的是,预读机制可能在不同系统版本中有所变化,所以下面我提供的资料大多是基于 Linux 2.6.x的内核,在这以后的系统版本可能对 readhead 机制有修改,你需要留意一下。
|
||||
|
||||
关于预读机制详细的算法说明可以看《Linux readahead: less tricks for more》和《Sequential File Prefetching In Linux》、《Linux内核的文件预读(readahead)》 这三篇文档。
|
||||
|
||||
从专栏前几篇的正文看,很多优化的内容是从Linux的机制入手的,如果你对Linux的机制和优化不了解的话,是不太容易想到这些方案的。举个例子,专栏文章提到的小文件系统是运行在用户态的代码,底层依然依赖现存文件系统提供的功能,因此需要深入了解Linux VFS、ext4的实现,以及它们的优缺点和原理,这样我们才能发现为什么大量的小文件依赖现存的文件系统管理是存在性能缺陷的,以及下一步如何填补这些性能缺陷。
|
||||
|
||||
作为Android开发工程师,我们该何学习Linux呢?我其实不建议上来就直接阅读系统源码分析相关的书,我建议是从理解操作系统概念开始,推荐两本操作系统相关的书:《深入理解计算机系统》和《计算机系统 系统架构与操作系统的高度集成》。Linux的系统实现其实和传统的操作系统概念在细节上会有不小的差别,再推荐一本解析Linux操作系统的书《操作系统之编程观察》,这本书结合源码对Linux的各方面机制都进行和很详细的分析。
|
||||
|
||||
对于从事Android开发的同学来说,确实很有必要深入了解Linux系统相关的知识,因为Android里很多特性都是依赖底层基础系统的,就比如我刚刚提到的“预读”机制,不光可以用在Android的资源加载上,也可以拓展到Flutter的资源加载上。假如我们以后面对一个不是Linux内核的系统,比如Fuchsia OS,也可以根据已经掌握的系统知识套用到现有的操作系统上,因为像内存管理、文件系统、信号机制、进程调度、系统调用、中断机制、驱动等内容都是共通的,在迁移到新的系统上的时候可以有一个全局的学习视角,帮助我们快速上手。对于操作系统内容,我的学习路线是先熟悉系统机制,然后熟悉系统提供的各个方向的接口,比如I/O操作、进程创建、信号中断处理、线程使用、epoll、通信机制等,按照《UNIX环境高级编程》这本书的内容一步步的走就可以完成这一步骤,熟悉之后可以按照自己的节奏,再去学习自己比较感兴趣的模块。此时可以找一本源码分析的书再去阅读,比如想了解fork机制的实现、I/O操作的read和write在内核态的调度执行,像这些问题就需要有目的性的进行挖掘。
|
||||
|
||||
上面这个学习路线是我在学习过程中不断踩坑总结出来的一些经验,对于操作系统我也只是个初学者,也欢迎你留言说说自己学习的经验和问题,一起切磋进步。
|
||||
|
||||
最后送出3本“极客周历”给用户故事“专栏学得苦?可能是方法没找对”留言点赞数前三的同学,分别是@坚持远方、@蜗牛、@JIA,感谢同学们的参与。
|
||||
|
||||
|
||||
|
||||
|
61
专栏/Android开发高手课/结束语移动开发的今天和明天.md
Normal file
61
专栏/Android开发高手课/结束语移动开发的今天和明天.md
Normal file
@ -0,0 +1,61 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 移动开发的今天和明天
|
||||
不知不觉,“Android开发高手课”上线更新到现在,已经陪伴了大家140多天,转眼间到了不得不说“再见”的时候。回想半年前开始筹备这个专栏时,当时焦虑和浮躁是移动开发者比较普遍的心态,而作为移动开发者的一份子,希望通过这个专栏可以分享一些我的经验,帮你克服焦虑,拥抱未来无限可能。
|
||||
|
||||
我的专栏希望通过质量、效率以及架构三个角度来说明一个移动开发高手应该具备的特质。“高质量开发”模块,希望让你知道Android开发并不是那么简单,一个Android高手需要具备从上层到底层的“通关”能力。Flutter就是一个非常好的例子,这里建议你回顾一下《想成为Android高手,你需要搞定这三个问题》。
|
||||
|
||||
|
||||
|
||||
稳定性、内存、网络、渲染、I/O…你可以选择其中一两个点深入往底层走走,打造自身的技术壁垒,当团队内部如果遇到相关问题(比如虚拟机、渲染、数据库)的时候,第一时间就会想到你来解决。
|
||||
|
||||
而“高效开发”模块希望告诉你一个开发高手不仅仅有“深度”,还需要有“广度”。移动开发早已经过了“单兵作战”的年代了,服务化、前后端一体化越来越重要,这里也推荐你回顾一下《做一名有高度的移动开发工程师》。
|
||||
|
||||
|
||||
|
||||
这里要求我们学会跳出客户端的范畴,具备全链路优化的思维。一个真正的开发高手应该不仅能把性能优化多少倍,而且还能带动整个团队提升开发效率。
|
||||
|
||||
最后一个模块是“架构演进”,我希望告诉你架构是为性能和效率服务的。无论是H5、React Native/Weex、小程序、Flutter,还是类似游戏、音视频、AI这些移动开发“新大陆”,它们都是殊途同归。
|
||||
|
||||
|
||||
|
||||
对于移动开发的今日,在学完专栏以后,我希望你能体会到下面这两点:
|
||||
|
||||
|
||||
移动开发并不简单。想成为一名移动开发高手并不简单,它需要非常深入地学习。当然学完专栏几十篇文章,你也不可能立刻就成为了一名高手,俗话说“师傅领进门,修行靠个人”,在专栏以外你还需要进一步深入学习更多的知识,积累更丰富的经验。
|
||||
|
||||
移动开发生命力依然顽强。移动开发很有前途,而且这个领域不仅局限于Android开发。我们的出路其实有很多很多,只要我们肯拿出折腾Android的热情,用从上到下“通关”的钻研精神,无论是转向iOS,还是游戏、前端等其他的领域,我相信绝对都是“降维打击”。
|
||||
|
||||
|
||||
但是不可否认,如今的移动开发已经不再是“风口上的猪”,对于初级移动开发者的岗位越来越少,大厂的要求也随之变得越来越严格。类似微信、头条、阿里,他们都对候选人在算法和底层基础知识考究得非常深入。
|
||||
|
||||
但是我相信只要你认真学习完这个专栏,对面试大厂会有非常大的帮助。专栏里提到的很多知识点,目前其实很多大厂也还做得不够完善。正如前面所说,你可以找其中一两个点作为进入大厂的突破口。
|
||||
|
||||
而对于Android的未来,个人认为会有下面两个变化:
|
||||
|
||||
|
||||
合规和用户隐私越发重要。无论被称为史上最严厉的欧盟GDPR法令,还是Android Q用户唯一标识、权限等最新限制,都可以清楚看到未来对用户隐私和权限更严格限制的趋势。
|
||||
|
||||
中国的声音会越来越大。以往我们总说Copy to China,现在更多的是Copy from China。无论是Google还是苹果,都无法忽视我们13亿人的巨大市场。比如说Android Q就为了中国的热修复框架,专门提供新的接口。
|
||||
|
||||
|
||||
对于移动开发的未来,可能未来的新设备将颠覆手机,也许是嵌入式设备、智能眼镜、车载设备等,但这些设备仍然还是移动设备。从当下的技术发展来看,我很期待今年的Facebook F8和Google I/O大会(我今年也会去现场观摩,欢迎面基~),Facebook今年计划开源很多好东西,例如React Native新版本、启动优化测量等。
|
||||
|
||||
“三星凉了,宝洁药丸,诺基亚衰落,苹果下凡,国外品牌都挺惨…”,但现实情况却是三星依然在全球称霸。在我看来,“Android开发要完,移动开发要完”也是这个道理。我们不应该焦虑,也不需要浮躁,没有不过时的技术,不过时的只有学习能力和态度。
|
||||
|
||||
“唯有学习,不可辜负”。Android开发的路很长也很宽,我们既可以往底层走,也能往大中台走,还可以去看看IoT、游戏、AI。未来的机会还有很多很多,只要我们找准方向,积极拥抱变化、迎接挑战,每个人都可以走出一条自己的“光明大道”。
|
||||
|
||||
不过在工作学习之余,我也奉劝各位同学一定要锻炼好身体,无论什么时候身体都是第一位。我们的职业生涯非常漫长,脑力、体力、心力三者缺一不可。
|
||||
|
||||
非常感谢订阅专栏和我一路走来的同学们,因为你们的鼓励让我充满激情,驱使我呈现出最好的内容。最后祝愿你们未来无论是从事哪方面的工作,都能成为一名真正的高手。
|
||||
|
||||
到了说再见的时候了,各位潜水的同学都来冒个泡吧,我希望听听你这几个月学习的心得和收获,自己在工作和生活有什么变化,2019年的Flag完成得如何了?我们可以在专栏里畅所欲言、把酒言欢。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
164
专栏/Android开发高手课/聊聊Framework的学习方法.md
Normal file
164
专栏/Android开发高手课/聊聊Framework的学习方法.md
Normal file
@ -0,0 +1,164 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
聊聊Framework的学习方法
|
||||
大家好,我是陆晓明,现在在一家互联网手机公司担任Android系统开发工程师。很高兴可以在极客时间Android开发高手课专栏里,分享一些我在手机行业9年的经验以及学习Android的方法。
|
||||
|
||||
今天我要跟你分享的是Framework的学习和调试的方法。
|
||||
|
||||
首先,Android是一种基于Linux的开放源代码软件栈,为广泛的设备和机型而创建。下图是Android平台的主要组件。
|
||||
|
||||
|
||||
|
||||
从图中你可以看到主要有以下几部分组成:
|
||||
|
||||
|
||||
Linux内核
|
||||
Android Runtime
|
||||
原生C/C++库
|
||||
Java API框架(后面我称之为Framework框架层)
|
||||
系统应用
|
||||
|
||||
|
||||
我们在各个应用市场看到的,大多是第三方应用,也就是安装在data区域的应用,它们可以卸载,并且权限也受到一些限制,比如不能直接设置时间日期,需要调用到系统应用设置里面再进行操作。
|
||||
|
||||
而我们在应用开发过程中使用的四大组件,便是在Framework框架层进行实现,应用通过约定俗成的规则,在AndroidMainfest.xml中进行配置,然后继承对应的基类进行复写。系统在启动过程中解析AndroidMainfest.xml,将应用的信息存储下来,随后根据用户的操作,或者系统的广播触发,启动对应的应用。
|
||||
|
||||
那么,我们先来看看Framework框架层都有哪些东西。
|
||||
|
||||
Framework框架层是应用开发过程中,调用的系统方法的内部实现,比如我们使用的TextView、Button控件,都是在这里实现的。再举几个例子,我们调用ActivityManager的getRunningAppProcesses方法查看当前运行的进程列表,还有我们使用NotificationManager的notify发送一个系统通知。
|
||||
|
||||
让我们来看看Framework相关的代码路径。
|
||||
|
||||
|
||||
|
||||
如何快速地学习、梳理Framework知识体系呢?常见的学习方法有下面几种:
|
||||
|
||||
|
||||
阅读书籍(方便梳理知识体系,但对于解决问题只能提供方向)。
|
||||
直接阅读源码(效率低,挑战难度大)。
|
||||
打Log和打堆栈 (效率有所提升,但需要反复编译,添加Log和堆栈代码)。
|
||||
直接联调,实时便捷(需要调试版本)。
|
||||
|
||||
|
||||
首先可以通过购买相关的书籍进行学习,其中主要的知识体系有Linux操作系统,比如进程、线程、进程间通信、虚拟内存,建立起自己的软件架构。在此基础上学习Android的启动过程、服务进程SystemServer的创建、各个服务线程(AMS/PMS等)的创建过程,以及Launcher的启动过程。熟悉了这些之后,你还要了解ART虚拟机的主要工作原理,以及init和Zygote的主要工作原理。之后随着在工作和实践过程中你会发现,Framework主要是围绕应用启动、显示、广播消息、按键传递、添加服务等开展,这些代码的实现主要使用的是Java和C++这两种语言。
|
||||
|
||||
通过书籍或者网络资料学习一段时间后,你会发现很多问题都没有现成的解决方案,而此时就需要我们深入源码中进行挖掘和学习。但是除了阅读官方文档外,别忘了调试Framework也是一把利刃,可以让你游刃有余快速定位和分析源码。
|
||||
|
||||
下面我们来看看调试Framework的Java部分,关于C++的部分,需要使用GDB进行调试,你可以在课下实践一下,调试的过程可以参考《深入Android源码系列(一)》。
|
||||
|
||||
我们这里使用Android Studio进行调试,在调试前我们要先掌握一些知识。Java代码的调试,主要依据两个因素,一个是你要调试的进程;一个是调试的类对应的包名路径,同时还要保证你所运行的手机环境和你要调试的代码是匹配的。只要这两个信息匹配,编译不通过也是可以进行调试的。
|
||||
|
||||
我们调试的系统服务是在SystemServer进程中,可以使用下面的命令验证(我这里使用Genymotion上安装Android对应版本镜像的环境演示)。
|
||||
|
||||
ps -A |grep system_server 查看系统服务进程pid
|
||||
cat /proc/pid/maps |grep services 通过cat查看此进程的内存映射,看看是否services映射到内存里面。
|
||||
|
||||
|
||||
这里我们看到信息:/system/framework/oat/x86/services.odex 。
|
||||
|
||||
odex是Android系统对于dex的进一步优化,目的是为了提升执行效率。从这个信息便可以确定,我们的services.jar确实是跑到这里了,也就是我们的系统服务相关联的代码,可以通过调试SystemServer进程进行跟踪。
|
||||
|
||||
下来我们来建立调试环境。
|
||||
|
||||
|
||||
打开Genymotion,选择下载好Android 9.0的镜像文件,启动模拟器。
|
||||
|
||||
找到模拟器对应的ActivityManagerService.java代码。 我是从http://androidxref.com/下载Android 9.0对应的代码。
|
||||
|
||||
打开Android Studio,File -> New -> New Project然后直接Next直到完成就行。
|
||||
|
||||
新建一个包名,从ActivityManagerService.java文件中找到它,这里为com.android.server.am,然后把ActivityManagerService.java放到里面即可。
|
||||
|
||||
在ActivityManagerService.java的startActivity方法上面设置断点,然后找到菜单的Run -> Attach debugger to Android process勾选Show all process,选中SystemServer进程确定。
|
||||
|
||||
|
||||
|
||||
|
||||
这时候我们点击Genymotion模拟器中桌面的一个图标,启动新的界面。
|
||||
|
||||
|
||||
|
||||
会发现这时候我们设定的断点已经生效。
|
||||
|
||||
|
||||
|
||||
你可以看到断下来的堆栈信息,以及一些变量值,然后我们可以一步步调试下去,跟踪启动的流程。
|
||||
|
||||
对于学习系统服务线程来讲,通过调试可以快速掌握流程,再结合阅读源码,便可以快速学习,掌握系统框架的整个逻辑,从而节省学习的时间成本。
|
||||
|
||||
以上我们验证了系统服务AMS服务代码的调试,其他服务调试方法也是一样,具体的线程信息,可以使用下面的命令查看。
|
||||
|
||||
ps -T 353
|
||||
这里353是使用ps -A |grep SystemServer查出 SystemServer的进程号
|
||||
|
||||
|
||||
|
||||
|
||||
在上面图中,PID = TID的只有第一行这一行,如果PID = TID的话,也就是这个线程是主线程。下面是我们平时使用Logcat查看输出的信息。
|
||||
|
||||
03-10 09:33:01.804 240 240 I hostapd : type=1400 audit(0.0:1123): avc: de
|
||||
03-10 09:33:37.320 353 1213 D WificondControl: Scan result ready event
|
||||
03-10 09:34:00.045 404 491 D hwcomposer: hw_composer sent 6 syncs in 60s
|
||||
|
||||
|
||||
这里我还框了一个ActivityManager的线程,这个是线程的名称,通过查看这行的TID(368)就知道下面的Log就是这个线程输出的。
|
||||
|
||||
03-10 08:47:33.574 353 368 I ActivityManager: Force stopping com.android.providers
|
||||
|
||||
|
||||
学习完上面的知识,相信你应该学会了系统服务的调试。通过调试分析,我们便可以将系统服务框架进行庖丁解牛般的学习,面对大量庞杂的代码掌握起来也可以轻松一些。
|
||||
|
||||
我们回过头来,再次在终端中输入ps -A,看看下面这一段信息。
|
||||
|
||||
|
||||
|
||||
你可以看到这里的第一列,代表的是当前的用户,这里有system root和u0_axx,不同的用户有不同的权限。我们当前关注的是第二列和第三列,第二列代表的是PID,也就是进程ID;第三列代表的是PPID,也就是父进程ID。
|
||||
|
||||
你发现我这里框住的都是同一个父进程,那么我们来找下这个323进程,看看它到底是谁。
|
||||
|
||||
root 323 1 1089040 127540 poll_schedule_timeout f16fcbc9 S zygote
|
||||
|
||||
|
||||
这个名字在学习Android系统的时候,总被反复提及,因为它是我们Android世界的孵化器,每一个上层应用的创建,都是通过Zygote调用fork创建的子进程,而子进程可以快速继承父进程已经加载的资源库,这里主要指的是应用所需的JAR包,比如/system/framework/framework.jar,因为我们应用所需的基础控件都在这里,像View、TextView、ImageView。
|
||||
|
||||
接下来我来讲解下一个调试,也就是对TextView的调试(其他Button调试方式一样)。如前面所说,这个代码被编译到/system/framework/framework.jar,那么我们通过ps命令和cat /proc/pid/maps命令在Zygote中找到它,同时它能够被每一个由Zygote创建的子进程找到,比如我们当前要调试Gallery的主界面TextView。
|
||||
|
||||
我们验证下,使用ps -A |grep gallery3d查到Gallery对应的进程PID,使用cat /proc/pid/maps |grep framework.jar看到如下信息:
|
||||
|
||||
efcd5000-efcd6000 r--s 00000000 08:06 684 /system/framework/framework.jar
|
||||
|
||||
|
||||
这说明我们要调试的应用进程在内存映射中确实存在,那么我们就需要在gallery3d进程中下断点了。
|
||||
|
||||
下来我们建立调试环境:
|
||||
|
||||
|
||||
打开Genymotion,选择下载好Android 9.0的镜像文件,启动模拟器,然后在桌面上启动Gallery图库应用。
|
||||
|
||||
找到模拟器对应的TextView.java代码。
|
||||
|
||||
打开Android Studio,File -> New -> New Project然后直接Next直到完成就行。
|
||||
|
||||
新建一个包名,从TextView.java文件中找到它的包名,这里为android.widget,然后把TextView.java放到里面即可。
|
||||
|
||||
在TextView.java的onDraw方法上面设置断点,然后找到菜单的Run -> Attach debugger to Android process勾选Show all process,选中com.android.gallery3d进程(我们已知这个主界面有TextView控件)确定。
|
||||
|
||||
|
||||
然后我们点击下这个界面左上角的菜单,随便选择一个点击,发现断点已生效,具体如下图所示。
|
||||
|
||||
|
||||
|
||||
然后我们可以使用界面上的调试按钮(或者快捷键)进行调试代码。
|
||||
|
||||
|
||||
|
||||
今天我讲解了如何调试Framework中的系统服务进程的AMS服务线程,其他PMS、WMS的调试方法跟AMS一样。并且我也讲解了如何调试一个应用里面的TextView控件,其他的比如Button、ImageView调试方法跟TextView也是一样的。
|
||||
|
||||
通过今天的学习,我希望能够给你一个学习系统框架最便捷的路径。在解决系统问题的时候,你可以方便的使用调试分析,从而快速定位、修复问题。
|
||||
|
||||
|
||||
|
||||
|
163
专栏/CNCFX阿里巴巴云原生技术公开课/01第一堂“云原生”课.md
Normal file
163
专栏/CNCFX阿里巴巴云原生技术公开课/01第一堂“云原生”课.md
Normal file
@ -0,0 +1,163 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 第一堂“云原生”课
|
||||
本节课程要点
|
||||
|
||||
|
||||
云原生技术发展历程(为什么要学习这门课)
|
||||
课程简介与预备知识(这门课到底教什么)
|
||||
云原生的定义与技术要点(本节正式内容)
|
||||
|
||||
|
||||
为什么要开设云原生技术公开课?
|
||||
|
||||
云原生技术发展简史
|
||||
|
||||
首先从第一个问题进行分享,那就是“为什么要开设云原生技术公开课?”云原生、CNCF 都是目前非常热门的关键词,但是这些技术并不是非常新鲜的内容。
|
||||
|
||||
|
||||
2004 年— 2007 年,Google 已在内部大规模地使用像 Cgroups 这样的容器技术;
|
||||
2008 年,Google 将 Cgroups 合并进入了 Linux 内核主干;
|
||||
2013 年,Docker 项目正式发布。
|
||||
2014 年,Kubernetes 项目也正式发布。这样的原因也非常容易理解,因为有了容器和 Docker 之后,就需要有一种方式去帮助大家方便、快速、优雅地管理这些容器,这就是 Kubernetes 项目的初衷。在 Google 和 Redhat 发布了 Kubernetes 之后,这个项目的发展速度非常之快。
|
||||
2015 年,由Google、Redhat 以及微软等大型云计算厂商以及一些开源公司共同牵头成立了 CNCF 云原生基金会。CNCF 成立之初,就有 22 个创始会员,而且 Kubernetes 也成为了 CNCF 托管的第一个开源项目。在这之后,CNCF 的发展速度非常迅猛;
|
||||
2017 年,CNCF 达到 170 个成员和 14 个基金项目;
|
||||
2018 年,CNCF 成立三周年有了 195 个成员,19 个基金会项目和 11 个孵化项目,如此之快的发展速度在整个云计算领域都是非常罕见的。
|
||||
|
||||
|
||||
云原生技术生态现状
|
||||
|
||||
因此,如今我们所讨论的云原生技术生态是一个庞大的技术集合。CNCF 有一张云原生全景图(https://github.com/cncf/landscape),在这个全景图里已经有 200 多个项目和产品了,这些项目和产品也都是和 CNCF 的观点所契合的。所以如果以这张全景图作为背景,加以思考就会发现,我们今天所讨论的云原生其实主要谈论了以下几点:
|
||||
|
||||
|
||||
云原生基金会 —— CNCF;
|
||||
云原生技术社区,比如像 CNCF 目前正式托管的 20 多个项目共同构成了现代云计算生态的基石,其中像 Kubernetes 这样的项目已经成为了世界第四活跃的开源项目;
|
||||
除了前面两点之外,现在全球各大公有云厂商都已经支持了 Kubernetes。此外,还有 100 多家技术创业公司也在持续地进行投入。现在阿里巴巴也在谈全面上云,而且上云就要上云原生,这也是各大技术公司拥抱云原生的一个例子。
|
||||
|
||||
|
||||
我们正处于时代的关键节点
|
||||
|
||||
2019 年正是云原生时代的关键节点,为什么这么说?我们这里就为大家简单梳理一下。 从 2013 年 Docker 项目发布开始说起,Docker 项目的发布使得全操作系统语义的沙盒技术唾手可得,使得用户能够更好地、更完整地打包自己的应用,使得开发者可以轻而易举的获得了一个应用的最小可运行单位,而不需要依赖任何 PaaS 能力。这对经典 PaaS 产业其实是一个“降维打击”。 2014 年的时候,Kubernetes 项目发布,其意义在于 Google 将内部的 Borg/Omega 系统思想借助开源社区实现了“重生”,并且提出了“容器设计模式”的思想。而 Google 之所以选择间接开源 Kubernetes 而不是直接开源 Borg 项目,其实背后的原因也比较容易理解:Borg/Omega 这样的系统太复杂了,是没办法提供给 Google 之外的人使用,但是 Borg/Omega 这样的设计思想却可以借助 Kubernetes 让大家接触到,这也是开源 Kubernetes 的重要背景。 这样到了 2015 年到 2016 年,就到了容器编排“三国争霸”的时代,当时 Docker、Swarm、Mesos、Kubernetes 都在容器编排领域展开角逐,他们竞争的原因其实也比较容易理解, 那就是 Docker 或者容器本身的价值虽然大,但是如果想要让其产生商业价值或者说对云的价值,那么就一定需要在编排上面占据一个有利的位置。 Swarm 和 Mesos 的特点,那就是各自只在生态和技术方面比较强,其中,Swarm 更偏向于生态,而 Mesos 技术更强一些。相比之下, Kubernetes 则兼具了两者优势,最终在 2017 年“三国争霸”的局面中得以胜出,成为了当时直到现在的容器编排标准。这一过程的代表性事件就是 Docker 公司宣布在核心产品中内置了 Kubernetes 服务,并且 Swarm 项目逐渐停止维护。 到了 2018 年的时候,云原生技术理念开始逐渐萌芽,这是因为此时 Kubernetes 以及容器都成为了云厂商的既定标准,以“云”为核心的软件研发思想逐步形成。 而到了 2019 年,情况似乎又将发生一些变化。
|
||||
|
||||
2019 年——云原生技术普及元年
|
||||
|
||||
为什么说 2019 年很可能是一个关键节点呢?我们认为 2019 年是云原生技术的普及元年。 首先大家可以看到,在 2019 年,阿里巴巴宣布要全面上云,而且“上云就要上云原生”。我们还可以看到,以“云”为核心的软件研发思想,正逐步成为所有开发者的默认选项。像 Kubernetes 等云原生技术正在成为技术人员的必修课,大量的工作岗位正在涌现出来。
|
||||
|
||||
这种背景下,“会 Kubernetes”已经远远不够了,“懂 Kubernetes”、“会云原生架构”的重要性正日益凸显出来。 从 2019 年开始,云原生技术将会大规模普及,这也是为什么大家都要在这个时间点上学习和投资云原生技术的重要原因。
|
||||
|
||||
“云原生技术公开课”是一门怎样的课程?
|
||||
|
||||
基于上面所提到的技术趋势,所以阿里巴巴和 CNCF 联合开设了云原生技术公开课。 那么这样的公开课到底在讲什么内容呢?
|
||||
|
||||
公开课教学大纲
|
||||
|
||||
第一期云原生公开课的教学大纲,主要以应用容器和 Kubernetes 为核心,在后面几期将会陆续上线 Service Mesh、Serverless 等相关课程。 在第一期公开课中,我们首先将课程分为两部分——基础知识部分和进阶知识部分:
|
||||
|
||||
|
||||
首先,我们希望通过第一部分的课程讲解帮助大家夯实基础。然后,对于更高阶的内容展开更深入的代码级别的剖析。希望通过这样循序渐进的方式帮助大家学习云原生技术;
|
||||
其次,在每个课程后面我们的讲师都会设置对应的课后自测考试题,这些考试题实际上是对本节课程最有效的归纳,我们希望能够通过课后评测的方式来帮助大家总结知识点,打造出属于自己的云原生知识体系;
|
||||
最后,我们的讲师在每个知识点的背后都设计了云端实践,所谓“实践出真知”,学习计算机相关的知识还是需要上手来实际地进行操作才可以。 因此在云端实践部分,讲师会提供详细的实践步骤供大家课后自我联系。并且在这个环节,阿里云还会赠送了定量的阿里云代金券帮助大家更好地在云上进行实践。
|
||||
|
||||
|
||||
以上三个部分就构成了阿里云和 CNCF 联合推出的云原生技术公开课的教学内容。
|
||||
|
||||
公开课授课计划
|
||||
|
||||
在授课计划方面,初步这样安排:第一堂课在 2019 年 9 月上线,此后将会每周更新2节课,总共 29 个课时。每个知识点后面都提供了课后自测。 对于讲师阵容而言,也是本次公开课最引以为傲的部分。我们的公开课将会主要由 CNCF 社区资深成员与项目维护者为大家讲解,很多课程讲师都是阿里云容器平台团队的专家级工程师。同时,我们也会邀请云原生社区的资深专家和外部讲师为大家讲解部分内容。因此在课程进行过程中,我们会不定期地安排大咖直播、课程答疑和落地实践案例。 我们希望将这些内容都集成在一起,为大家呈现一个中国最完整、最权威、最具有影响力的云原生技术公开课。
|
||||
|
||||
课程预备知识
|
||||
|
||||
大家可能存在这样的疑惑,就是想要学习云原生基础知识之前需要哪些预备知识呢?其实大致需要三部分预备知识:
|
||||
|
||||
|
||||
Linux 操作系统知识:主要是一些通识性的基础,最好具有一定的在 Linux 下开发的经验;
|
||||
计算机和程序设计的基础:这一点到入门工程师或者高年级本科生水平就足够了;
|
||||
容器的使用基础:希望大家具有容器的简单使用经验,比如 docker run 以及 docker build 等,最好有一定 Docker 化应用开发的经验。当然,我们在课程中也会讲解相关的基础知识。
|
||||
|
||||
|
||||
什么是“云原生”?云原生该怎么落地?
|
||||
|
||||
在介绍完课程之后,我们再来详细的聊一聊“云原生”:什么是“云原生”?云原生该怎么落地?这两个问题也是整个课程的核心内容。
|
||||
|
||||
云原生的定义
|
||||
|
||||
很多人都会问“到底什么是云原生?” 实际上,云原生是一条最佳路径或者最佳实践。更详细的说,云原生为用户指定了一条低心智负担的、敏捷的、能够以可扩展、可复制的方式最大化地利用云的能力、发挥云的价值的最佳路径。 因此,云原生其实是一套指导进行软件架构设计的思想。按照这样的思想而设计出来的软件:首先,天然就“生在云上,长在云上”;其次,能够最大化地发挥云的能力,使得我们开发的软件和“云”能够天然地集成在一起,发挥出“云”的最大价值。 所以,云原生的最大价值和愿景,就是认为未来的软件,会从诞生起就生长在云上,并且遵循一种新的软件开发、发布和运维模式,从而使得软件能够最大化地发挥云的能力。说到了这里,大家可以思考一下为什么容器技术具有革命性?
|
||||
|
||||
其实,容器技术和集装箱技术的革命性非常类似,即:容器技术使得应用具有了一种“自包含”的定义方式。所以,这样的应用才能以敏捷的、以可扩展可复制的方式发布在云上,发挥出云的能力。这也就是容器技术对云发挥出的革命性影响所在,所以说,容器技术正是云原生技术的核心底盘。
|
||||
|
||||
云原生的技术范畴
|
||||
|
||||
云原生的技术范畴包括了以下几个方面:
|
||||
|
||||
|
||||
第一部分是云应用定义与开发流程。这包括应用定义与镜像制作、配置 CI/CD、消息和 Streaming 以及数据库等。
|
||||
第二部分是云应用的编排与管理流程。这也是 Kubernetes 比较关注的一部分,包括了应用编排与调度、服务发现治理、远程调用、API 网关以及 Service Mesh。
|
||||
第三部分是监控与可观测性。这部分所强调的是云上应用如何进行监控、日志收集、Tracing 以及在云上如何实现破坏性测试,也就是混沌工程的概念。
|
||||
第四部分就是云原生的底层技术,比如容器运行时、云原生存储技术、云原生网络技术等。
|
||||
第五部分是云原生工具集,在前面的这些核心技术点之上,还有很多配套的生态或者周边的工具需要使用,比如流程自动化与配置管理、容器镜像仓库、云原生安全技术以及云端密码管理等。
|
||||
最后则是 Serverless。Serverless 是一种 PaaS 的特殊形态,它定义了一种更为“极端抽象”的应用编写方式,包含了 FaaS 和 BaaS 这样的概念。而无论是 FaaS 还是 BaaS,其最为典型的特点就是按实际使用计费(Pay as you go),因此 Serverless 计费也是重要的知识和概念。
|
||||
|
||||
|
||||
云原生思想的两个理论
|
||||
|
||||
在了解完云原生的技术范畴之后你就会发现,其所包含的技术内容还是很多的,但是这些内容的技术本质却是类似的。云原生技术的本质是两个理论基础。
|
||||
|
||||
|
||||
第一个理论基础是:不可变基础设施。这一点目前是通过容器镜像来实现的,其含义就是应用的基础设施应该是不可变的,是一个自包含、自描述可以完全在不同环境中迁移的东西;
|
||||
第二个理论基础就是:云应用编排理论。当前的实现方式就是 Google 所提出来的“容器设计模式”,这也是本系列课程中的 Kubernetes 部分所需主要讲解的内容。
|
||||
|
||||
|
||||
基础设施向云演进的过程
|
||||
|
||||
首先为大家介绍一下“不可变基础设施”的概念。其实,应用所依赖的基础设施也在经历一个向云演进的过程,举例而言,对于传统的应用基础设施而言,其实往往是可变的。
|
||||
|
||||
大家可能经常会干这样一件事情,比如需要发布或者更新一个软件,那么流程大致是这样的,先通过 SSH 连到服务器,然后手动升级或者降级软件包,逐个调整服务器上的配置文件,并且将新代码直接都部署到现有服务器上。因此,这套基础设施会不断地被调整和修改。 但是在云上,对“云”友好的应用基础设施是不可变的。
|
||||
|
||||
这种场景下的上述更新过程会这么做:一旦应用部署完成之后,那么这套应用基础设施就不会再修改了。如果需要更新,那么需要现更改公共镜像来构建新服务直接替换旧服务。而我们之所以能够实现直接替换,就是因为容器提供了自包含的环境(包含应用运行所需的所有依赖)。所以对于应用而言,完全不需要关心容器发生了什么变化,只需要把容器镜像本身修改掉就可以了。因此,对于云友好的基础设施是随时可以替换和更换的,这就是因为容器具有敏捷和一致性的能力,也就是云时代的应用基础设施。 所以,总结而言,云时代的基础设施就像是可以替代的“牲口”,可以随时替换;而传统的基础设施则是独一无二的“宠物”,需要细心呵护,这就体现出了云时代不可变基础设施的优点。
|
||||
|
||||
基础设施向云演进的意义
|
||||
|
||||
所以,像这样的基础设施向“不可变”演进的过程,为我们提供了两个非常重要的优点。
|
||||
|
||||
|
||||
1、基础设施的一致性和可靠性。同样一个镜像,无论是在美国打开,在中国打开,还是在印度打开都是一样的。并且其中的 OS 环境对于应用而言都是一致的。而对于应用而言,它就不需要关心容器跑在哪里,这就是基础设施一致性非常重要的一个特征。
|
||||
2、这样的镜像本身就是自包含的,其包含了应用运行所需要的所有依赖,因此也可以漂移到云上的任何一个位置。
|
||||
|
||||
|
||||
此外,云原生的基础设施还提供了简单、可预测的部署和运维能力。由于现在有了镜像,应用还是自描述的,通过镜像运行起来的整个容器其实可以像 Kubernetes 的 Operator 技术一样将其做成自运维的,所以整个应用本身都是自包含的行为,使得其能够迁移到云上任何一个位置。这也使得整个流程的自动化变得非常容易。
|
||||
|
||||
应用本身也可以更好地扩容,从 1 个实例变成 100 个实例,进而变成 1 万个实例,这个过程对于容器化后的应用没有任何特殊的。最后,我们这时也能够通过不可变的基础设施来地快速周围的管控系统和支撑组件。因为,这些组件本身也是容器化的,是符合不可变基础设施这样一套理论的组件。 以上就是不可变基础设施为用户带来的最大的优点。
|
||||
|
||||
云原生关键技术点
|
||||
|
||||
当我们回过头来看云原生关键技术点或者说它所依赖的技术理论的时候,可以看到主要有这样的四个方向:
|
||||
|
||||
|
||||
如何构建自包含、可定制的应用镜像;
|
||||
能不能实现应用快速部署与隔离能力;
|
||||
应用基础设施创建和销毁的自动化管理;
|
||||
可复制的管控系统和支撑组件。
|
||||
|
||||
|
||||
这四个云原生关键技术点是落地实现云原生技术的四个主要途径,而这四个技术点也是本门课程的 17 个技术点所主要讲述的核心知识。
|
||||
|
||||
本节总结
|
||||
|
||||
|
||||
“云原生”具备着重要的意义,它是云时代技术人自我提升的必备路径;
|
||||
“云原生”定义了一条云时代应用从开发到交付的最佳路径;
|
||||
“云原生”应用生在云上,长在云上,希望能够将云的能力发挥到极致。
|
||||
|
||||
|
||||
讲师点评
|
||||
|
||||
“未来的软件一定是生长于云上的”这是云原生理念的最核心假设。而所谓“云原生”,实际上就是在定义一条能够让应用最大程度利用云的能力、发挥云的价值的最佳路径。在这条路径上,脱离了“应用”这个载体,“云原生”就无从谈起;容器技术,则是将这个理念落地、将软件交付的革命持续进行下去的重要手段之一。
|
||||
|
||||
而本期云原生公开课重点讲解的 Kubernetes 项目,则是整个“云原生”理念落地的核心与关键所在。它正在迅速成为连通“云”与“应用”的高速公路,以标准、高效的方式将“应用”快速交付到世界上任何一个位置。如今”云原生应用交付“,已经成为了 2019 年云计算市场上最热门的技术关键词之一。希望学习课程的同学们能够学以致用,持续关注以 K8s 为基础进行“云原生应用管理与交付”的技术趋势。
|
||||
|
||||
|
||||
|
||||
|
143
专栏/CNCFX阿里巴巴云原生技术公开课/02容器基本概念.md
Normal file
143
专栏/CNCFX阿里巴巴云原生技术公开课/02容器基本概念.md
Normal file
@ -0,0 +1,143 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 容器基本概念
|
||||
容器与镜像
|
||||
|
||||
什么是容器?
|
||||
|
||||
在介绍容器的具体概念之前,先简单回顾一下操作系统是如何管理进程的。
|
||||
|
||||
首先,当我们登录到操作系统之后,可以通过 ps 等操作看到各式各样的进程,这些进程包括系统自带的服务和用户的应用进程。那么,这些进程都有什么样的特点?
|
||||
|
||||
|
||||
第一,这些进程可以相互看到、相互通信;
|
||||
第二,它们使用的是同一个文件系统,可以对同一个文件进行读写操作;
|
||||
第三,这些进程会使用相同的系统资源。
|
||||
|
||||
|
||||
这样的三个特点会带来什么问题呢?
|
||||
|
||||
|
||||
因为这些进程能够相互看到并且进行通信,高级权限的进程可以攻击其他进程;
|
||||
因为它们使用的是同一个文件系统,因此会带来两个问题:这些进程可以对于已有的数据进行增删改查,具有高级权限的进程可能会将其他进程的数据删除掉,破坏掉其他进程的正常运行;此外,进程与进程之间的依赖可能会存在冲突,如此一来就会给运维带来很大的压力;
|
||||
因为这些进程使用的是同一个宿主机的资源,应用之间可能会存在资源抢占的问题,当一个应用需要消耗大量 CPU 和内存资源的时候,就可能会破坏其他应用的运行,导致其他应用无法正常地提供服务。
|
||||
|
||||
|
||||
针对上述的三个问题,如何为进程提供一个独立的运行环境呢?
|
||||
|
||||
|
||||
针对不同进程使用同一个文件系统所造成的问题而言,Linux 和 Unix 操作系统可以通过 chroot 系统调用将子目录变成根目录,达到视图级别的隔离;进程在 chroot 的帮助下可以具有独立的文件系统,对于这样的文件系统进行增删改查不会影响到其他进程;
|
||||
因为进程之间相互可见并且可以相互通信,使用 Namespace 技术来实现进程在资源的视图上进行隔离。在 chroot 和 Namespace 的帮助下,进程就能够运行在一个独立的环境下了;
|
||||
但在独立的环境下,进程所使用的还是同一个操作系统的资源,一些进程可能会侵蚀掉整个系统的资源。为了减少进程彼此之间的影响,可以通过 Cgroup 来限制其资源使用率,设置其能够使用的 CPU 以及内存量。
|
||||
|
||||
|
||||
那么,应该如何定义这样的进程集合呢?
|
||||
|
||||
其实,容器就是一个视图隔离、资源可限制、独立文件系统的进程集合。所谓“视图隔离”就是能够看到部分进程以及具有独立的主机名等;控制资源使用率则是可以对于内存大小以及 CPU 使用个数等进行限制。容器就是一个进程集合,它将系统的其他资源隔离开来,具有自己独立的资源视图。
|
||||
|
||||
容器具有一个独立的文件系统,因为使用的是系统的资源,所以在独立的文件系统内不需要具备内核相关的代码或者工具,我们只需要提供容器所需的二进制文件、配置文件以及依赖即可。只要容器运行时所需的文件集合都能够具备,那么这个容器就能够运行起来。
|
||||
|
||||
什么是镜像?
|
||||
|
||||
综上所述,我们将这些容器运行时所需要的所有的文件集合称之为容器镜像。
|
||||
|
||||
那么,一般都是通过什么样的方式来构建镜像的呢?通常情况下,我们会采用 Dockerfile 来构建镜像,这是因为 Dockerfile 提供了非常便利的语法糖,能够帮助我们很好地描述构建的每个步骤。当然,每个构建步骤都会对已有的文件系统进行操作,这样就会带来文件系统内容的变化,我们将这些变化称之为 changeset。当我们把构建步骤所产生的变化依次作用到一个空文件夹上,就能够得到一个完整的镜像。 changeset 的分层以及复用特点能够带来几点优势:
|
||||
|
||||
|
||||
第一,能够提高分发效率,简单试想一下,对于大的镜像而言,如果将其拆分成各个小块就能够提高镜像的分发效率,这是因为镜像拆分之后就可以并行下载这些数据;
|
||||
第二,因为这些数据是相互共享的,也就意味着当本地存储上包含了一些数据的时候,只需要下载本地没有的数据即可,举个简单的例子就是 golang 镜像是基于 alpine 镜像进行构建的,当本地已经具有了 alpine 镜像之后,在下载 golang 镜像的时候只需要下载本地 alpine 镜像中没有的部分即可;
|
||||
第三,因为镜像数据是共享的,因此可以节约大量的磁盘空间,简单设想一下,当本地存储具有了 alpine 镜像和 golang 镜像,在没有复用的能力之前,alpine 镜像具有 5M 大小,golang 镜像有 300M 大小,因此就会占用 305M 空间;而当具有了复用能力之后,只需要 300M 空间即可。
|
||||
|
||||
|
||||
如何构建镜像?
|
||||
|
||||
如下图所示的 Dockerfile 适用于描述如何构建 golang 应用的。
|
||||
|
||||
|
||||
|
||||
如图所示:
|
||||
|
||||
|
||||
FROM 行表示以下的构建步骤基于什么镜像进行构建,正如前面所提到的,镜像是可以复用的;
|
||||
WORKDIR 行表示会把接下来的构建步骤都在哪一个相应的具体目录下进行,其起到的作用类似于 Shell 里面的 cd;
|
||||
COPY 行表示的是可以将宿主机上的文件拷贝到容器镜像内;
|
||||
RUN 行表示在具体的文件系统内执行相应的动作。当我们运行完毕之后就可以得到一个应用了;
|
||||
CMD 行表示使用镜像时的默认程序名字。
|
||||
|
||||
|
||||
当有了 Dockerfile 之后,就可以通过 docker build 命令构建出所需要的应用。构建出的结果存储在本地,一般情况下,镜像构建会在打包机或者其他的隔离环境下完成。
|
||||
|
||||
那么,这些镜像如何运行在生产环境或者测试环境上呢?这时候就需要一个中转站或者中心存储,我们称之为 docker registry,也就是镜像仓库,其负责存储所有产生的镜像数据。我们只需要通过 docker push 就能够将本地镜像推动到镜像仓库中,这样一来,就能够在生产环境上或者测试环境上将相应的数据下载下来并运行了。
|
||||
|
||||
如何运行容器?
|
||||
|
||||
运行一个容器一般情况下分为三步:
|
||||
|
||||
|
||||
第一步:从镜像仓库中将相应的镜像下载下来;
|
||||
第二步:当镜像下载完成之后就可以通过 docker images 来查看本地镜像,这里会给出一个完整的列表,我们可以在列表中选中想要的镜像;
|
||||
第三步:当选中镜像之后,就可以通过 docker run 来运行这个镜像得到想要的容器,当然可以通过多次运行得到多个容器。一个镜像就相当于是一个模板,一个容器就像是一个具体的运行实例,因此镜像就具有了一次构建、到处运行的特点。
|
||||
|
||||
|
||||
小结
|
||||
|
||||
简单回顾一下,容器就是和系统其它部分隔离开来的进程集合,这里的其他部分包括进程、网络资源以及文件系统等。而镜像就是容器所需要的所有文件集合,其具备一次构建、到处运行的特点。
|
||||
|
||||
容器的生命周期
|
||||
|
||||
容器运行时的生命周期
|
||||
|
||||
容器是一组具有隔离特性的进程集合,在使用 docker run 的时候会选择一个镜像来提供独立的文件系统并指定相应的运行程序。这里指定的运行程序称之为 initial 进程,这个 initial 进程启动的时候,容器也会随之启动,当 initial 进程退出的时候,容器也会随之退出。
|
||||
|
||||
因此,可以认为容器的生命周期和 initial 进程的生命周期是一致的。当然,因为容器内不只有这样的一个 initial 进程,initial 进程本身也可以产生其他的子进程或者通过 docker exec 产生出来的运维操作,也属于 initial 进程管理的范围内。当 initial 进程退出的时候,所有的子进程也会随之退出,这样也是为了防止资源的泄漏。 但是这样的做法也会存在一些问题,首先应用里面的程序往往是有状态的,其可能会产生一些重要的数据,当一个容器退出被删除之后,数据也就会丢失了,这对于应用方而言是不能接受的,所以需要将容器所产生出来的重要数据持久化下来。容器能够直接将数据持久化到指定的目录上,这个目录就称之为数据卷。
|
||||
|
||||
数据卷有一些特点,其中非常明显的就是数据卷的生命周期是独立于容器的生命周期的,也就是说容器的创建、运行、停止、删除等操作都和数据卷没有任何关系,因为它是一个特殊的目录,是用于帮助容器进行持久化的。简单而言,我们会将数据卷挂载到容器内,这样一来容器就能够将数据写入到相应的目录里面了,而且容器的退出并不会导致数据的丢失。
|
||||
|
||||
通常情况下,数据卷管理主要有两种方式:
|
||||
|
||||
|
||||
第一种是通过 bind 的方式,直接将宿主机的目录直接挂载到容器内;这种方式比较简单,但是会带来运维成本,因为其依赖于宿主机的目录,需要对于所有的宿主机进行统一管理。
|
||||
第二种是将目录管理交给运行引擎。
|
||||
|
||||
|
||||
容器项目架构
|
||||
|
||||
moby 容器引擎架构
|
||||
|
||||
moby 是目前最流行的容器管理引擎,moby daemon 会对上提供有关于容器、镜像、网络以及 Volume的管理。moby daemon 所依赖的最重要的组件就是 containerd,containerd 是一个容器运行时管理引擎,其独立于 moby daemon ,可以对上提供容器、镜像的相关管理。
|
||||
|
||||
containerd 底层有 containerd shim 模块,其类似于一个守护进程,这样设计的原因有几点:
|
||||
|
||||
|
||||
首先,containerd 需要管理容器生命周期,而容器可能是由不同的容器运行时所创建出来的,因此需要提供一个灵活的插件化管理。而 shim 就是针对于不同的容器运行时所开发的,这样就能够从 containerd 中脱离出来,通过插件的形式进行管理。
|
||||
其次,因为 shim 插件化的实现,使其能够被 containerd 动态接管。如果不具备这样的能力,当 moby daemon 或者 containerd daemon 意外退出的时候,容器就没人管理了,那么它也会随之消失、退出,这样就会影响到应用的运行。
|
||||
最后,因为随时可能会对 moby 或者 containerd 进行升级,如果不提供 shim 机制,那么就无法做到原地升级,也无法做到不影响业务的升级,因此 containerd shim 非常重要,它实现了动态接管的能力。
|
||||
|
||||
|
||||
本节课程只是针对于 moby 进行一个大致的介绍,在后续的课程也会详细介绍。
|
||||
|
||||
容器 VS VM
|
||||
|
||||
容器和 VM 之间的差异
|
||||
|
||||
VM 利用 Hypervisor 虚拟化技术来模拟 CPU、内存等硬件资源,这样就可以在宿主机上建立一个 Guest OS,这是常说的安装一个虚拟机。
|
||||
|
||||
每一个 Guest OS 都有一个独立的内核,比如 Ubuntu、CentOS 甚至是 Windows 等,在这样的 Guest OS 之下,每个应用都是相互独立的,VM 可以提供一个更好的隔离效果。但这样的隔离效果需要付出一定的代价,因为需要把一部分的计算资源交给虚拟化,这样就很难充分利用现有的计算资源,并且每个 Guest OS 都需要占用大量的磁盘空间,比如 Windows 操作系统的安装需要 10~30G 的磁盘空间,Ubuntu 也需要 5~6G,同时这样的方式启动很慢。正是因为虚拟机技术的缺点,催生出了容器技术。 容器是针对于进程而言的,因此无需 Guest OS,只需要一个独立的文件系统提供其所需要文件集合即可。所有的文件隔离都是进程级别的,因此启动时间快于 VM,并且所需的磁盘空间也小于 VM。当然了,进程级别的隔离并没有想象中的那么好,隔离效果相比 VM 要差很多。
|
||||
|
||||
总体而言,容器和 VM 相比,各有优劣,因此容器技术也在向着强隔离方向发展。
|
||||
|
||||
本节总结
|
||||
|
||||
|
||||
容器是一个进程集合,具有自己独特的视图视角;
|
||||
镜像是容器所需要的所有文件集合,其具备一次构建、到处运行的特点;
|
||||
容器的生命周期和 initial 进程的生命周期是一样的;
|
||||
容器和 VM 相比,各有优劣,容器技术在向着强隔离方向发展。
|
||||
|
||||
|
||||
|
||||
|
||||
|
308
专栏/CNCFX阿里巴巴云原生技术公开课/03Kubernetes核心概念.md
Normal file
308
专栏/CNCFX阿里巴巴云原生技术公开课/03Kubernetes核心概念.md
Normal file
@ -0,0 +1,308 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 Kubernetes 核心概念
|
||||
什么是 Kubernetes
|
||||
|
||||
Kubernetes,从官方网站上可以看到,它是一个工业级的容器编排平台。Kubernetes 这个单词是希腊语,它的中文翻译是“舵手”或者“飞行员”。在一些常见的资料中也会看到“ks”这个词,也就是“k8s”,它是通过将8个字母“ubernete ”替换为“8”而导致的一个缩写。
|
||||
|
||||
Kubernetes 为什么要用“舵手”来命名呢?大家可以看一下这张图:
|
||||
|
||||
|
||||
|
||||
这是一艘载着一堆集装箱的轮船,轮船在大海上运着集装箱奔波,把集装箱送到它们该去的地方。我们之前其实介绍过一个概念叫做 container,container 这个英文单词也有另外的一个意思就是“集装箱”。Kubernetes 也就借着这个寓意,希望成为运送集装箱的一个轮船,来帮助我们管理这些集装箱,也就是管理这些容器。
|
||||
|
||||
这个就是为什么会选用 Kubernetes 这个词来代表这个项目的原因。更具体一点地来说:Kubernetes 是一个自动化的容器编排平台,它负责应用的部署、应用的弹性以及应用的管理,这些都是基于容器的。
|
||||
|
||||
Kubernetes 有如下几个核心的功能
|
||||
|
||||
|
||||
服务的发现与负载的均衡;
|
||||
容器的自动装箱,我们也会把它叫做 scheduling,就是“调度”,把一个容器放到一个集群的某一个机器上,Kubernetes 会帮助我们去做存储的编排,让存储的声明周期与容器的生命周期能有一个连接;
|
||||
Kubernetes 会帮助我们去做自动化的容器的恢复。在一个集群中,经常会出现宿主机的问题或者说是 OS 的问题,导致容器本身的不可用,Kubernetes 会自动地对这些不可用的容器进行恢复;
|
||||
Kubernetes 会帮助我们去做应用的自动发布与应用的回滚,以及与应用相关的配置密文的管理;
|
||||
对于 job 类型任务,Kubernetes 可以去做批量的执行;
|
||||
为了让这个集群、这个应用更富有弹性,Kubernetes 也支持水平的伸缩。
|
||||
|
||||
|
||||
下面,我们希望以三个例子跟大家更切实地介绍一下 Kubernetes 的能力。
|
||||
|
||||
调度
|
||||
|
||||
Kubernetes 可以把用户提交的容器放到 Kubernetes 管理的集群的某一台节点上去。Kubernetes 的调度器是执行这项能力的组件,它会观察正在被调度的这个容器的大小、规格。
|
||||
|
||||
比如说它所需要的 CPU以及它所需要的 memory,然后在集群中找一台相对比较空闲的机器来进行一次 placement,也就是一次放置的操作。在这个例子中,它可能会把红颜色的这个容器放置到第二个空闲的机器上,来完成一次调度的工作。
|
||||
|
||||
|
||||
|
||||
自动修复
|
||||
|
||||
Kubernetes 有一个节点健康检查的功能,它会监测这个集群中所有的宿主机,当宿主机本身出现故障,或者软件出现故障的时候,这个节点健康检查会自动对它进行发现。
|
||||
|
||||
下面 Kubernetes 会把运行在这些失败节点上的容器进行自动迁移,迁移到一个正在健康运行的宿主机上,来完成集群内容器的一个自动恢复。
|
||||
|
||||
|
||||
|
||||
水平伸缩
|
||||
|
||||
Kubernetes 有业务负载检查的能力,它会监测业务上所承担的负载,如果这个业务本身的 CPU 利用率过高,或者响应时间过长,它可以对这个业务进行一次扩容。
|
||||
|
||||
比如说在下面的例子中,黄颜色的过度忙碌,Kubernetes 就可以把黄颜色负载从一份变为三份。接下来,它就可以通过负载均衡把原来打到第一个黄颜色上的负载平均分到三个黄颜色的负载上去,以此来提高响应的时间。
|
||||
|
||||
|
||||
|
||||
以上就是 Kubernetes 三个核心能力的简单介绍。
|
||||
|
||||
Kubernetes 的架构
|
||||
|
||||
Kubernetes 架构是一个比较典型的二层架构和 server-client 架构。Master 作为中央的管控节点,会去与 Node 进行一个连接。
|
||||
|
||||
所有 UI 的、clients、这些 user 侧的组件,只会和 Master 进行连接,把希望的状态或者想执行的命令下发给 Master,Master 会把这些命令或者状态下发给相应的节点,进行最终的执行。
|
||||
|
||||
|
||||
|
||||
Kubernetes 的 Master 包含四个主要的组件:API Server、Controller、Scheduler 以及 etcd。如下图所示:
|
||||
|
||||
|
||||
|
||||
|
||||
API Server: 顾名思义是用来处理 API 操作的,Kubernetes 中所有的组件都会和 API Server 进行连接,组件与组件之间一般不进行独立的连接,都依赖于 API Server 进行消息的传送;
|
||||
Controller: 是控制器,它用来完成对集群状态的一些管理。比如刚刚我们提到的两个例子之中,第一个自动对容器进行修复、第二个自动进行水平扩张,都是由 Kubernetes 中的 Controller 来进行完成的;
|
||||
Scheduler: 是调度器,“调度器”顾名思义就是完成调度的操作,就是我们刚才介绍的第一个例子中,把一个用户提交的 Container,依据它对 CPU、对 memory 请求大小,找一台合适的节点,进行放置;
|
||||
etcd: 是一个分布式的一个存储系统,API Server 中所需要的这些原信息都被放置在 etcd 中,etcd 本身是一个高可用系统,通过 etcd 保证整个 Kubernetes 的 Master 组件的高可用性。
|
||||
|
||||
|
||||
我们刚刚提到的 API Server,它本身在部署结构上是一个可以水平扩展的一个部署组件;Controller 是一个可以进行热备的一个部署组件,它只有一个 active,它的调度器也是相应的,虽然只有一个 active,但是可以进行热备。
|
||||
|
||||
Kubernetes 的架构:Node
|
||||
|
||||
Kubernetes 的 Node 是真正运行业务负载的,每个业务负载会以 Pod 的形式运行。等一下我会介绍一下 Pod 的概念。一个 Pod 中运行的一个或者多个容器,真正去运行这些 Pod 的组件的是叫做 kubelet,也就是 Node 上最为关键的组件,它通过 API Server 接收到所需要 Pod 运行的状态,然后提交到我们下面画的这个 Container Runtime 组件中。
|
||||
|
||||
|
||||
|
||||
在 OS 上去创建容器所需要运行的环境,最终把容器或者 Pod 运行起来,也需要对存储跟网络进行管理。Kubernetes 并不会直接进行网络存储的操作,他们会靠 Storage Plugin 或者是网络的 Plugin 来进行操作。用户自己或者云厂商都会去写相应的 Storage Plugin 或者 Network Plugin,去完成存储操作或网络操作。
|
||||
|
||||
在 Kubernetes 自己的环境中,也会有 Kubernetes 的 Network,它是为了提供 Service network 来进行搭网组网的。(等一下我们也会去介绍“service”这个概念。)真正完成 service 组网的组件的是 Kube-proxy,它是利用了 iptable 的能力来进行组建 Kubernetes 的 Network,就是 cluster network,以上就是 Node 上面的四个组件。
|
||||
|
||||
Kubernetes 的 Node 并不会直接和 user 进行 interaction,它的 interaction 只会通过 Master。而 User 是通过 Master 向节点下发这些信息的。Kubernetes 每个 Node 上,都会运行我们刚才提到的这几个组件。
|
||||
|
||||
下面我们以一个例子再去看一下 Kubernetes 架构中的这些组件,是如何互相进行 interaction 的。
|
||||
|
||||
|
||||
|
||||
用户可以通过 UI 或者 CLI 提交一个 Pod 给 Kubernetes 进行部署,这个 Pod 请求首先会通过 CLI 或者 UI 提交给 Kubernetes API Server,下一步 API Server 会把这个信息写入到它的存储系统 etcd,之后 Scheduler 会通过 API Server 的 watch 或者叫做 notification 机制得到这个信息:有一个 Pod 需要被调度。
|
||||
|
||||
这个时候 Scheduler 会根据它的内存状态进行一次调度决策,在完成这次调度之后,它会向 API Server report 说:“OK!这个 Pod 需要被调度到某一个节点上。”
|
||||
|
||||
这个时候 API Server 接收到这次操作之后,会把这次的结果再次写到 etcd 中,然后 API Server 会通知相应的节点进行这次 Pod 真正的执行启动。相应节点的 kubelet 会得到这个通知,kubelet 就会去调 Container runtime 来真正去启动配置这个容器和这个容器的运行环境,去调度 Storage Plugin 来去配置存储,network Plugin 去配置网络。
|
||||
|
||||
这个例子我们可以看到:这些组件之间是如何相互沟通相互通信,协调来完成一次Pod的调度执行操作的。
|
||||
|
||||
Kubernetes 的核心概念与它的 API
|
||||
|
||||
核心概念
|
||||
|
||||
第一个概念:Pod
|
||||
|
||||
Pod 是 Kubernetes 的一个最小调度以及资源单元。用户可以通过 Kubernetes 的 Pod API 生产一个 Pod,让 Kubernetes 对这个 Pod 进行调度,也就是把它放在某一个 Kubernetes 管理的节点上运行起来。一个 Pod 简单来说是对一组容器的抽象,它里面会包含一个或多个容器。
|
||||
|
||||
比如像下面的这幅图里面,它包含了两个容器,每个容器可以指定它所需要资源大小。比如说,一个核一个 G,或者说 0.5 个核,0.5 个 G。
|
||||
|
||||
当然在这个 Pod 中也可以包含一些其他所需要的资源:比如说我们所看到的 Volume 卷这个存储资源;比如说我们需要 100 个 GB 的存储或者 20GB 的另外一个存储。
|
||||
|
||||
|
||||
|
||||
在 Pod 里面,我们也可以去定义容器所需要运行的方式。比如说运行容器的 Command,以及运行容器的环境变量等等。Pod 这个抽象也给这些容器提供了一个共享的运行环境,它们会共享同一个网络环境,这些容器可以用 localhost 来进行直接的连接。而 Pod 与 Pod 之间,是互相有 isolation 隔离的。
|
||||
|
||||
第二个概念:Volume
|
||||
|
||||
Volume 就是卷的概念,它是用来管理 Kubernetes 存储的,是用来声明在 Pod 中的容器可以访问文件目录的,一个卷可以被挂载在 Pod 中一个或者多个容器的指定路径下面。
|
||||
|
||||
而 Volume 本身是一个抽象的概念,一个 Volume 可以去支持多种的后端的存储。比如说 Kubernetes 的 Volume 就支持了很多存储插件,它可以支持本地的存储,可以支持分布式的存储,比如说像 ceph,GlusterFS ;它也可以支持云存储,比如说阿里云上的云盘、AWS 上的云盘、Google 上的云盘等等。
|
||||
|
||||
|
||||
|
||||
第三个概念:Deployment
|
||||
|
||||
Deployment 是在 Pod 这个抽象上更为上层的一个抽象,它可以定义一组 Pod 的副本数目、以及这个 Pod 的版本。一般大家用 Deployment 这个抽象来做应用的真正的管理,而 Pod 是组成 Deployment 最小的单元。
|
||||
|
||||
Kubernetes 是通过 Controller,也就是我们刚才提到的控制器去维护 Deployment 中 Pod 的数目,它也会去帮助 Deployment 自动恢复失败的 Pod。
|
||||
|
||||
比如说我可以定义一个 Deployment,这个 Deployment 里面需要两个 Pod,当一个 Pod 失败的时候,控制器就会监测到,它重新把 Deployment 中的 Pod 数目从一个恢复到两个,通过再去新生成一个 Pod。通过控制器,我们也会帮助完成发布的策略。比如说进行滚动升级,进行重新生成的升级,或者进行版本的回滚。
|
||||
|
||||
|
||||
|
||||
第四个概念:Service
|
||||
|
||||
Service 提供了一个或者多个 Pod 实例的稳定访问地址。
|
||||
|
||||
比如在上面的例子中,我们看到:一个 Deployment 可能有两个甚至更多个完全相同的 Pod。对于一个外部的用户来讲,访问哪个 Pod 其实都是一样的,所以它希望做一次负载均衡,在做负载均衡的同时,我只想访问某一个固定的 VIP,也就是 Virtual IP 地址,而不希望得知每一个具体的 Pod 的 IP 地址。
|
||||
|
||||
我们刚才提到,这个 pod 本身可能 terminal go(终止),如果一个 Pod 失败了,可能会换成另外一个新的。
|
||||
|
||||
对一个外部用户来讲,提供了多个具体的 Pod 地址,这个用户要不停地去更新 Pod 地址,当这个 Pod 再失败重启之后,我们希望有一个抽象,把所有 Pod 的访问能力抽象成一个第三方的一个 IP 地址,实现这个的 Kubernetes 的抽象就叫 Service。
|
||||
|
||||
实现 Service 有多种方式,Kubernetes 支持 Cluster IP,上面我们讲过的 kuber-proxy 的组网,它也支持 nodePort、 LoadBalancer 等其他的一些访问的能力。
|
||||
|
||||
|
||||
|
||||
第五个概念:Namespace
|
||||
|
||||
Namespace 是用来做一个集群内部的逻辑隔离的,它包括鉴权、资源管理等。Kubernetes 的每个资源,比如刚才讲的 Pod、Deployment、Service 都属于一个 Namespace,同一个 Namespace 中的资源需要命名的唯一性,不同的 Namespace 中的资源可以重名。
|
||||
|
||||
Namespace 一个用例,比如像在阿里巴巴,我们内部会有很多个 business units,在每一个 business units 之间,希望有一个视图上的隔离,并且在鉴权上也不一样,在 cuda 上面也不一样,我们就会用 Namespace 来去给每一个 BU 提供一个他所看到的这么一个看到的隔离的机制。
|
||||
|
||||
|
||||
|
||||
Kubernetes 的 API
|
||||
|
||||
下面我们介绍一下 Kubernetes 的 API 的基础知识。从 high-level 上看,Kubernetes API 是由 HTTP+JSON 组成的:用户访问的方式是 HTTP,访问的 API 中 content 的内容是 JSON 格式的。
|
||||
|
||||
Kubernetes 的 kubectl 也就是 command tool,Kubernetes UI,或者有时候用 curl,直接与 Kubernetes 进行沟通,都是使用 HTTP + JSON 这种形式。
|
||||
|
||||
下面有个例子:比如说,对于这个 Pod 类型的资源,它的 HTTP 访问的路径,就是 API,然后是 apiVesion: V1, 之后是相应的 Namespaces,以及 Pods 资源,最终是 Podname,也就是 Pod 的名字。
|
||||
|
||||
|
||||
|
||||
如果我们去提交一个 Pod,或者 get 一个 Pod 的时候,它的 content 内容都是用 JSON 或者是 YAML 表达的。上图中有个 yaml 的例子,在这个 yaml file 中,对 Pod 资源的描述也分为几个部分。
|
||||
|
||||
第一个部分,一般来讲会是 API 的 version。比如在这个例子中是 V1,它也会描述我在操作哪个资源;比如说我的 kind 如果是 pod,在 Metadata 中,就写上这个 Pod 的名字;比如说 nginx,我们也会给它打一些 label,我们等下会讲到 label 的概念。在 Metadata 中,有时候也会去写 annotation,也就是对资源的额外的一些用户层次的描述。
|
||||
|
||||
比较重要的一个部分叫做 Spec,Spec 也就是我们希望 Pod 达到的一个预期的状态。比如说它内部需要有哪些 container 被运行;比如说这里面有一个 nginx 的 container,它的 image 是什么?它暴露的 port 是什么?
|
||||
|
||||
当我们从 Kubernetes API 中去获取这个资源的时候,一般来讲在 Spec 下面会有一个项目叫 status,它表达了这个资源当前的状态;比如说一个 Pod 的状态可能是正在被调度、或者是已经 running、或者是已经被 terminates,就是被执行完毕了。
|
||||
|
||||
刚刚在 API 之中,我们讲了一个比较有意思的 metadata 叫做“label”,这个 label 可以是一组 KeyValuePair。
|
||||
|
||||
比如下图的第一个 pod 中,label 就可能是一个 color 等于 red,即它的颜色是红颜色。当然你也可以加其他 label,比如说 size: big 就是大小,定义为大的,它可以是一组 label。
|
||||
|
||||
这些 label 是可以被 selector,也就是选择器所查询的。这个能力实际上跟我们的 sql 类型的 select 语句是非常相似的,比如下图中的三个 Pod 资源中,我们就可以进行 select。name color 等于 red,就是它的颜色是红色的,我们也可以看到,只有两个被选中了,因为只有他们的 label 是红色的,另外一个 label 中写的 color 等于 yellow,也就是它的颜色是黄色,是不会被选中的。
|
||||
|
||||
|
||||
|
||||
通过 label,kubernetes 的 API 层就可以对这些资源进行一个筛选,那这些筛选也是 kubernetes 对资源的集合所表达默认的一种方式。
|
||||
|
||||
例如说,我们刚刚介绍的 Deployment,它可能是代表一组的 Pod,它是一组 Pod 的抽象,一组 Pod 就是通过 label selector 来表达的。当然我们刚才讲到说 service 对应的一组 Pod,就是一个 service 要对应一个或者多个的 Pod,来对它们进行统一的访问,这个描述也是通过 label selector 来进行 select 选取的一组 Pod。
|
||||
|
||||
所以可以看到 label 是一个非常核心的 kubernetes API 的概念,我们在接下来的课程中也会着重地去讲解和介绍 label 这个概念,以及如何更好地去使用它。
|
||||
|
||||
以一个 demo 结尾
|
||||
|
||||
最后一部分,我想以一个例子来结束,让大家跟我一起来尝试一个 kubernetes,在尝试 Kubernetes 之前,我希望大家能在本机上安装一下 Kubernetes,安装一个 Kubernetes 沙箱环境。
|
||||
|
||||
安装这个沙箱环境,主要有三个步骤:
|
||||
|
||||
|
||||
首先需要安装一个虚拟机,来在虚拟机中启动 Kubernetes。我们会推荐大家利用 virtualbox 来作为虚拟机的运行环境;
|
||||
|
||||
|
||||
安装 VirtualBox: https://www.virtualbox.org/wiki/Downloads
|
||||
|
||||
|
||||
其次我们需要在虚拟机中启动 Kubernetes,Kubernetes 有一个非常有意思的项目,叫 minikube,也就是启动一个最小的 local 的 Kubernetes 的一个环境。
|
||||
|
||||
|
||||
minikube 我们推荐使用下面写到的阿里云的版本,它和官方 minikube 的主要区别就是把 minikube 中所需要的 Google 上的依赖换成国内访问比较快的一些镜像,这样就方便了大家的安装工作;
|
||||
|
||||
安装 MiniKube(中国版): https://yq.aliyun.com/articles/221687
|
||||
|
||||
|
||||
最后在安装完 virtualbox 和 minikube 之后,大家可以对 minikube 进行启动,也就是下面这个命令。
|
||||
|
||||
|
||||
启动命令:minikube start —vm-driver virtualbox
|
||||
|
||||
如果大家不是 Mac 系统,其他操作系统请访问下面这个链接,查看其它操作系统如何安装 minikube 沙箱环境。
|
||||
|
||||
https://kubernetes.io/docs/tasks/tools/install-minikube/
|
||||
|
||||
当大家安装好之后,我会跟大家一起做一个例子,来做三件事情:
|
||||
|
||||
|
||||
提交一个 nginx deployment;
|
||||
|
||||
|
||||
kubectl apply -f https://k8s.io/examples/application/deployment.yaml
|
||||
|
||||
|
||||
升级 nginx deployment;
|
||||
|
||||
|
||||
kubectl apply -f https://k8s.io/examples/application/deployment-update.yaml
|
||||
|
||||
|
||||
扩容 nginx deployment。
|
||||
|
||||
|
||||
kubectl apply -f https://k8s.io/examples/application/deployment-update.yaml
|
||||
|
||||
第一步,我们提交一个 nginx 的 Deployment,然后对这个 Deployment 进行一次版本升级,也就是改变它中间 Pod 的版本。最后我们也会尝试对 nginx 进行一次扩容,进行一次水平的伸缩,下面就让大家一起跟我来尝试这三个操作吧。
|
||||
|
||||
首先,我们先看一下 minikube 的 status,可以看到 kubelet master 和 kubectl 都是配置好的。
|
||||
|
||||
|
||||
|
||||
下一步我们利用 kubectl 来看一下这个集群中节选的状态,可以看到这个master 的节点已经是 running 状态:
|
||||
|
||||
|
||||
|
||||
我们就以这个为节点,下面我们尝试去看一下现在集群中 Deployment 这个资源:
|
||||
|
||||
|
||||
|
||||
可以看到集群中没有任何的 Deployment,我们可以利用 watch 这个语义去看集群中 Deployment 这个资源的变化情况。
|
||||
|
||||
下面我们去做刚才想要的三个操作:第一个操作是去创建一个 Deployment。可以看到下面第一个图,这是一个 API 的 content,它的 kind 是 Deployment,name 是 nginx-deployment, 有图中它的 replicas 数目是2,它的镜像版本是 1.7.9。
|
||||
|
||||
|
||||
|
||||
我们下面还是回到 kubectl 这个 commnd 来执行这次 Deployment 的真正的操作。我们可以看到一个简单的操作,就会去让 Deployment 不停地生成副本。
|
||||
|
||||
|
||||
|
||||
Deployment 副本数目是 2 个,下面也可以 describe 一下现在的 Deployment 的状态。我们知道之前是没有这个 Deployment 的,现在我们去 describe 这个 nginx-deployment。
|
||||
|
||||
下图中可以看到:有一个 nginx-deployment 已经被生成了,它的 replicas 数目也是我们想要的、selector 也是我们想要的、它的 image 的版本也是 1.7.9。还可以看到,里面的 deployment-controller 这种版本控制器也是在管理它的生成。
|
||||
|
||||
|
||||
|
||||
下面我们去升级这个 Deployment 版本,首先下载另外一个 yaml 文件 deployment-update.yaml,可以看到这里面的 image 本身的版本号从 1.7.9 升级到 1.8。
|
||||
|
||||
|
||||
|
||||
接下来我们重新 apply 新的 deployment-update 这个 yaml 文件。
|
||||
|
||||
可以看到,在另一边的屏幕上显示出了这个 Deployment 升级的一些操作,最终它的 up-to-date 值从 0 变成了 2,也就是说所有的容器都是最新版本的,所有的 Pod 都是最新版本的。我们也可以 discribe 具体去看一下是不是所有 Pod 的版本都被更新了,可以看到这个 image 的版本由 1.7.9 真正更新到了 1.8。
|
||||
|
||||
最后,我们也可以看到 controller 又执行了几次新的操作,这个控制器维护了整个 Deployment 和 Pod 状态。
|
||||
|
||||
|
||||
|
||||
最后我们演示一下给 Deployment 做水平扩张,下载另一个 yaml 文件 deployment-scale.yaml,这里面的 replicas 数目已经从 2 改成了 4。
|
||||
|
||||
|
||||
|
||||
回到最开始的窗口,用 kubectl 去 apply 这个新的 deployment-scale.yaml 文件,在另外一个窗口上可以看到,当我们执行了 deployment-scale 操作之后,它的容器 Pod 数目从 2 变成了 4。我们可以再一次 describ 一下当前集群中的 deployment 的情况,可以看到它的 replicas 的数目从 2 变到了 4,同时也可以看到 controller 又做了几次新的操作,这个 scale up 成功了。
|
||||
|
||||
|
||||
|
||||
最后,让我们利用 delete 操作把我们刚才生成的 Deployment 给删除掉。kubectl delete deployment,也是刚才我们本身的 deployment name,当我们把它删除掉之后,我们今天所有的操作就完成了。
|
||||
|
||||
我们再去重新 get 这个 Deployment,也会显示这个资源不再存在,这个集群又回到了最开始干净的状态。
|
||||
|
||||
|
||||
|
||||
以上这就是这堂课中所有的内容了,我们关注了 kubernetes 的核心概念以及 kubernetes 的架构设计,希望大家能在这节课中有所收获,也希望大家能关注云原生技术课堂中的其他内容,谢谢大家的观看!
|
||||
|
||||
本节总结
|
||||
|
||||
|
||||
Kubernetes 是一个自动化的容器编排平台,它负责应用的部署、应用的弹性以及应用的管理,这些都是基于容器的;
|
||||
Kubernetes 架构是一个比较典型的二层架构和 server-client 架构。
|
||||
|
||||
|
||||
|
||||
|
||||
|
269
专栏/CNCFX阿里巴巴云原生技术公开课/04理解Pod和容器设计模式.md
Normal file
269
专栏/CNCFX阿里巴巴云原生技术公开课/04理解Pod和容器设计模式.md
Normal file
@ -0,0 +1,269 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 理解 Pod 和容器设计模式
|
||||
为什么需要 Pod
|
||||
|
||||
容器的基本概念
|
||||
|
||||
现在来看第一个问题:为什么需要 Pod?我们知道 Pod 是 Kubernetes 项目里面一个非常重要的概念,也是非常重要的一个原子调度单位,但是为什么我们会需要这样一个概念呢?我们在使用容器 Docker 的时候,也没有这个说法。其实如果要理解 Pod,我们首先要理解容器,所以首先来回顾一下容器的概念:
|
||||
|
||||
容器的本质实际上是一个进程,是一个视图被隔离,资源受限的进程。
|
||||
|
||||
容器里面 PID=1 的进程就是应用本身,这意味着管理虚拟机等于管理基础设施,因为我们是在管理机器,但管理容器却等于直接管理应用本身。这也是之前说过的不可变基础设施的一个最佳体现,这个时候,你的应用就等于你的基础设施,它一定是不可变的。
|
||||
|
||||
在以上面的例子为前提的情况下,Kubernetes 又是什么呢?我们知道,很多人都说 Kubernetes 是云时代的操作系统,这个非常有意思,因为如果以此类推,容器镜像就是这个操作系统的软件安装包,它们之间是这样的一个类比关系。
|
||||
|
||||
|
||||
|
||||
真实操作系统里的例子
|
||||
|
||||
如果说 Kubernetes 就是操作系统的话,那么我们不妨看一下真实的操作系统的例子。
|
||||
|
||||
例子里面有一个程序叫做 Helloworld,这个 Helloworld 程序实际上是由一组进程组成的,需要注意一下,这里说的进程实际上等同于 Linux 中的线程。
|
||||
|
||||
因为 Linux 中的线程是轻量级进程,所以如果从 Linux 系统中去查看 Helloworld 中的 pstree,将会看到这个 Helloworld 实际上是由四个线程组成的,分别是 {api、main、log、compute}。也就是说,四个这样的线程共同协作,共享 Helloworld 程序的资源,组成了 Helloworld 程序的真实工作情况。
|
||||
|
||||
这是操作系统里面进程组或者线程组中一个非常真实的例子,以上就是进程组的一个概念。
|
||||
|
||||
|
||||
|
||||
那么大家不妨思考一下,在真实的操作系统里面,一个程序往往是根据进程组来进行管理的。Kubernetes 把它类比为一个操作系统,比如说 Linux。针对于容器我们前面提到可以类比为进程,就是前面的 Linux 线程。那么 Pod 又是什么呢?实际上 Pod 就是我们刚刚提到的进程组,也就是 Linux 里的线程组。
|
||||
|
||||
进程组概念
|
||||
|
||||
说到进程组,首先建议大家至少有个概念上的理解,然后我们再详细的解释一下。
|
||||
|
||||
还是前面那个例子:Helloworld 程序由四个进程组成,这些进程之间会共享一些资源和文件。那么现在有一个问题:假如说现在把 Helloworld 程序用容器跑起来,你会怎么去做?
|
||||
|
||||
当然,最自然的一个解法就是,我现在就启动一个 Docker 容器,里面运行四个进程。可是这样会有一个问题,这种情况下容器里面 PID=1 的进程该是谁? 比如说,它应该是我的 main 进程,那么问题来了,“谁”又负责去管理剩余的 3 个进程呢?
|
||||
|
||||
这个核心问题在于,容器的设计本身是一种“单进程”模型,不是说容器里只能起一个进程,由于容器的应用等于进程,所以只能去管理 PID=1 的这个进程,其他再起来的进程其实是一个托管状态。 所以说服务应用进程本身就具有“进程管理”的能力。
|
||||
|
||||
比如说 Helloworld 的程序有 system 的能力,或者直接把容器里 PID=1 的进程直接改成 systemd,否则这个应用,或者是容器是没有办法去管理很多个进程的。因为 PID=1 进程是应用本身,如果现在把这个 PID=1 的进程给 kill 了,或者它自己运行过程中死掉了,那么剩下三个进程的资源就没有人回收了,这个是非常非常严重的一个问题。
|
||||
|
||||
而反过来真的把这个应用本身改成了 systemd,或者在容器里面运行了一个 systemd,将会导致另外一个问题:使得管理容器,不再是管理应用本身了,而等于是管理 systemd,这里的问题就非常明显了。比如说我这个容器里面 run 的程序或者进程是 systemd,那么接下来,这个应用是不是退出了?是不是 fail 了?是不是出现异常失败了?实际上是没办法直接知道的,因为容器管理的是 systemd。这就是为什么在容器里面运行一个复杂程序往往比较困难的一个原因。
|
||||
|
||||
这里再帮大家梳理一下:由于容器实际上是一个“单进程”模型,所以如果你在容器里启动多个进程,只有一个可以作为 PID=1 的进程,而这时候,如果这个 PID=1 的进程挂了,或者说失败退出了,那么其他三个进程就会自然而然的成为孤儿,没有人能够管理它们,没有人能够回收它们的资源,这是一个非常不好的情况。
|
||||
|
||||
|
||||
注意:Linux 容器的“单进程”模型,指的是容器的生命周期等同于 PID=1 的进程(容器应用进程)的生命周期,而不是说容器里不能创建多进程。当然,一般情况下,容器应用进程并不具备进程管理能力,所以你通过 exec 或者 ssh 在容器里创建的其他进程,一旦异常退出(比如 ssh 终止)是很容易变成孤儿进程的。
|
||||
|
||||
|
||||
反过来,其实可以在容器里面 run 一个 systemd,用它来管理其他所有的进程。这样会产生第二个问题:实际上没办法直接管理我的应用了,因为我的应用被 systemd 给接管了,那么这个时候应用状态的生命周期就不等于容器生命周期。这个管理模型实际上是非常非常复杂的。
|
||||
|
||||
|
||||
|
||||
Pod = “进程组”
|
||||
|
||||
在 kubernetes 里面,Pod 实际上正是 kubernetes 项目为你抽象出来的一个可以类比为进程组的概念。
|
||||
|
||||
前面提到的,由四个进程共同组成的一个应用 Helloworld,在 Kubernetes 里面,实际上会被定义为一个拥有四个容器的 Pod,这个概念大家一定要非常仔细的理解。
|
||||
|
||||
就是说现在有四个职责不同、相互协作的进程,需要放在容器里去运行,在 Kubernetes 里面并不会把它们放到一个容器里,因为这里会遇到两个问题。那么在 Kubernetes 里会怎么去做呢?它会把四个独立的进程分别用四个独立的容器启动起来,然后把它们定义在一个 Pod 里面。
|
||||
|
||||
所以当 Kubernetes 把 Helloworld 给拉起来的时候,你实际上会看到四个容器,它们共享了某些资源,这些资源都属于 Pod,所以我们说 Pod 在 Kubernetes 里面只有一个逻辑单位,没有一个真实的东西对应说这个就是 Pod,不会有的。真正起来在物理上存在的东西,就是四个容器。这四个容器,或者说是多个容器的组合就叫做 Pod。并且还有一个概念一定要非常明确,Pod 是 Kubernetes 分配资源的一个单位,因为里面的容器要共享某些资源,所以 Pod 也是 Kubernetes 的原子调度单位。
|
||||
|
||||
|
||||
|
||||
上面提到的 Pod 设计,也不是 Kubernetes 项目自己想出来的, 而是早在 Google 研发 Borg 的时候,就已经发现了这样一个问题。这个在 Borg paper 里面有非常非常明确的描述。简单来说 Google 工程师发现在 Borg 下面部署应用时,很多场景下都存在着类似于“进程与进程组”的关系。更具体的是,这些应用之前往往有着密切的协作关系,使得它们必须部署在同一台机器上并且共享某些信息。
|
||||
|
||||
以上就是进程组的概念,也是 Pod 的用法。
|
||||
|
||||
为什么 Pod 必须是原子调度单位?
|
||||
|
||||
可能到这里大家会有一些问题:虽然了解这个东西是一个进程组,但是为什么要把 Pod 本身作为一个概念抽象出来呢?或者说能不能通过调度把 Pod 这个事情给解决掉呢?为什么 Pod 必须是 Kubernetes 里面的原子调度单位?
|
||||
|
||||
下面我们通过一个例子来解释。
|
||||
|
||||
假如现在有两个容器,它们是紧密协作的,所以它们应该被部署在一个 Pod 里面。具体来说,第一个容器叫做 App,就是业务容器,它会写日志文件;第二个容器叫做 LogCollector,它会把刚刚 App 容器写的日志文件转发到后端的 ElasticSearch 中。
|
||||
|
||||
两个容器的资源需求是这样的:App 容器需要 1G 内存,LogCollector 需要 0.5G 内存,而当前集群环境的可用内存是这样一个情况:Node*A:1.25G 内存,Node*B:2G 内存。
|
||||
|
||||
假如说现在没有 Pod 概念,就只有两个容器,这两个容器要紧密协作、运行在一台机器上。可是,如果调度器先把 App 调度到了 Node*A 上面,接下来会怎么样呢?这时你会发现:LogCollector 实际上是没办法调度到 Node*A 上的,因为资源不够。其实此时整个应用本身就已经出问题了,调度已经失败了,必须去重新调度。
|
||||
|
||||
|
||||
|
||||
以上就是一个非常典型的成组调度失败的例子。英文叫做:Task co-scheduling 问题,这个问题不是说不能解,在很多项目里面,这样的问题都有解法。
|
||||
|
||||
比如说在 Mesos 里面,它会做一个事情,叫做资源囤积(resource hoarding):即当所有设置了 Affinity 约束的任务都达到时,才开始统一调度,这是一个非常典型的成组调度的解法。
|
||||
|
||||
所以上面提到的“App”和“LogCollector”这两个容器,在 Mesos 里面,他们不会说立刻调度,而是等两个容器都提交完成,才开始统一调度。这样也会带来新的问题,首先调度效率会损失,因为需要等待。由于需要等还会有外一个情况会出现,就是产生死锁,就是互相等待的一个情况。这些机制在 Mesos 里都是需要解决的,也带来了额外的复杂度。
|
||||
|
||||
另一种解法是 Google 的解法。它在 Omega 系统(就是 Borg 下一代)里面,做了一个非常复杂且非常厉害的解法,叫做乐观调度。比如说:不管这些冲突的异常情况,先调度,同时设置一个非常精妙的回滚机制,这样经过冲突后,通过回滚来解决问题。这个方式相对来说要更加优雅,也更加高效,但是它的实现机制是非常复杂的。这个有很多人也能理解,就是悲观锁的设置一定比乐观锁要简单。
|
||||
|
||||
而像这样的一个 Task co-scheduling 问题,在 Kubernetes 里,就直接通过 Pod 这样一个概念去解决了。因为在 Kubernetes 里,这样的一个 App 容器和 LogCollector 容器一定是属于一个 Pod 的,它们在调度时必然是以一个 Pod 为单位进行调度,所以这个问题是根本不存在的。
|
||||
|
||||
再次理解 Pod
|
||||
|
||||
在讲了前面这些知识点之后,我们来再次理解一下 Pod,首先 Pod 里面的容器是“超亲密关系”。
|
||||
|
||||
这里有个“超”字需要大家理解,正常来说,有一种关系叫做亲密关系,这个亲密关系是一定可以通过调度来解决的。
|
||||
|
||||
|
||||
|
||||
比如说现在有两个 Pod,它们需要运行在同一台宿主机上,那这样就属于亲密关系,调度器一定是可以帮助去做的。但是对于超亲密关系来说,有一个问题,即它必须通过 Pod 来解决。因为如果超亲密关系赋予不了,那么整个 Pod 或者说是整个应用都无法启动。
|
||||
|
||||
什么叫做超亲密关系呢?大概分为以下几类:
|
||||
|
||||
|
||||
比如说两个进程之间会发生文件交换,前面提到的例子就是这样,一个写日志,一个读日志;
|
||||
两个进程之间需要通过 localhost 或者说是本地的 Socket 去进行通信,这种本地通信也是超亲密关系;
|
||||
这两个容器或者是微服务之间,需要发生非常频繁的 RPC 调用,出于性能的考虑,也希望它们是超亲密关系;
|
||||
两个容器或者是应用,它们需要共享某些 Linux Namespace。最简单常见的一个例子,就是我有一个容器需要加入另一个容器的 Network Namespace。这样我就能看到另一个容器的网络设备,和它的网络信息。
|
||||
|
||||
|
||||
像以上几种关系都属于超亲密关系,它们都是在 Kubernetes 中会通过 Pod 的概念去解决的。
|
||||
|
||||
现在我们理解了 Pod 这样的概念设计,理解了为什么需要 Pod。它解决了两个问题:
|
||||
|
||||
|
||||
我们怎么去描述超亲密关系;
|
||||
我们怎么去对超亲密关系的容器或者说是业务去做统一调度,这是 Pod 最主要的一个诉求。
|
||||
|
||||
|
||||
Pod 的实现机制
|
||||
|
||||
Pod 要解决的问题
|
||||
|
||||
像 Pod 这样一个东西,本身是一个逻辑概念。那在机器上,它究竟是怎么实现的呢?这就是我们要解释的第二个问题。
|
||||
|
||||
既然说 Pod 要解决这个问题,核心就在于如何让一个 Pod 里的多个容器之间最高效的共享某些资源和数据。
|
||||
|
||||
因为容器之间原本是被 Linux Namespace 和 cgroups 隔开的,所以现在实际要解决的是怎么去打破这个隔离,然后共享某些事情和某些信息。这就是 Pod 的设计要解决的核心问题所在。
|
||||
|
||||
所以说具体的解法分为两个部分:网络和存储。
|
||||
|
||||
共享网络
|
||||
|
||||
第一个问题是 Pod 里的多个容器怎么去共享网络?下面是个例子:
|
||||
|
||||
比如说现在有一个 Pod,其中包含了一个容器 A 和一个容器 B,它们两个就要共享 Network Namespace。在 Kubernetes 里的解法是这样的:它会在每个 Pod 里,额外起一个 Infra container 小容器来共享整个 Pod 的 Network Namespace。
|
||||
|
||||
Infra container 是一个非常小的镜像,大概 100~200KB 左右,是一个汇编语言写的、永远处于“暂停”状态的容器。由于有了这样一个 Infra container 之后,其他所有容器都会通过 Join Namespace 的方式加入到 Infra container 的 Network Namespace 中。
|
||||
|
||||
所以说一个 Pod 里面的所有容器,它们看到的网络视图是完全一样的。即:它们看到的网络设备、IP地址、Mac地址等等,跟网络相关的信息,其实全是一份,这一份都来自于 Pod 第一次创建的这个 Infra container。这就是 Pod 解决网络共享的一个解法。
|
||||
|
||||
在 Pod 里面,一定有一个 IP 地址,是这个 Pod 的 Network Namespace 对应的地址,也是这个 Infra container 的 IP 地址。所以大家看到的都是一份,而其他所有网络资源,都是一个 Pod 一份,并且被 Pod 中的所有容器共享。这就是 Pod 的网络实现方式。
|
||||
|
||||
由于需要有一个相当于说中间的容器存在,所以整个 Pod 里面,必然是 Infra container 第一个启动。并且整个 Pod 的生命周期是等同于 Infra container 的生命周期的,与容器 A 和 B 是无关的。这也是为什么在 Kubernetes 里面,它是允许去单独更新 Pod 里的某一个镜像的,即:做这个操作,整个 Pod 不会重建,也不会重启,这是非常重要的一个设计。
|
||||
|
||||
|
||||
|
||||
共享存储
|
||||
|
||||
第二问题:Pod 怎么去共享存储?Pod 共享存储就相对比较简单。
|
||||
|
||||
比如说现在有两个容器,一个是 Nginx,另外一个是非常普通的容器,在 Nginx 里放一些文件,让我能通过 Nginx 访问到。所以它需要去 share 这个目录。我 share 文件或者是 share 目录在 Pod 里面是非常简单的,实际上就是把 volume 变成了 Pod level。然后所有容器,就是所有同属于一个 Pod 的容器,他们共享所有的 volume。
|
||||
|
||||
|
||||
|
||||
比如说上图的例子,这个 volume 叫做 shared-data,它是属于 Pod level 的,所以在每一个容器里可以直接声明:要挂载 shared-data 这个 volume,只要你声明了你挂载这个 volume,你在容器里去看这个目录,实际上大家看到的就是同一份。这个就是 Kubernetes 通过 Pod 来给容器共享存储的一个做法。
|
||||
|
||||
所以在之前的例子中,应用容器 App 写了日志,只要这个日志是写在一个 volume 中,只要声明挂载了同样的 volume,这个 volume 就可以立刻被另外一个 LogCollector 容器给看到。以上就是 Pod 实现存储的方式。
|
||||
|
||||
详解容器设计模式
|
||||
|
||||
现在我们知道了为什么需要 Pod,也了解了 Pod 这个东西到底是怎么实现的。最后,以此为基础,详细介绍一下 Kubernetes 非常提倡的一个概念,叫做容器设计模式。
|
||||
|
||||
举例
|
||||
|
||||
接下来将会用一个例子来给大家进行讲解。
|
||||
|
||||
比如我现在有一个非常常见的一个诉求:我现在要发布一个应用,这个应用是 JAVA 写的,有一个 WAR 包需要把它放到 Tomcat 的 web APP 目录下面,这样就可以把它启动起来了。可是像这样一个 WAR 包或 Tomcat 这样一个容器的话,怎么去做,怎么去发布?这里面有几种做法。
|
||||
|
||||
|
||||
|
||||
|
||||
第一种方式:可以把 WAR 包和 Tomcat 打包放进一个镜像里面。但是这样带来一个问题,就是现在这个镜像实际上揉进了两个东西。那么接下来,无论是我要更新 WAR 包还是说我要更新 Tomcat,都要重新做一个新的镜像,这是比较麻烦的;
|
||||
第二种方式:就是镜像里面只打包 Tomcat。它就是一个 Tomcat,但是需要使用数据卷的方式,比如说 hostPath,从宿主机上把 WAR 包挂载进我们 Tomcat 容器中,挂到我的 web APP 目录下面,这样把这个容器启用起来之后,里面就能用了。
|
||||
|
||||
|
||||
但是这时会发现一个问题:这种做法一定需要维护一套分布式存储系统。因为这个容器可能第一次启动是在宿主机 A 上面,第二次重新启动就可能跑到 B 上去了,容器它是一个可迁移的东西,它的状态是不保持的。所以必须维护一套分布式存储系统,使容器不管是在 A 还是在 B 上,都可以找到这个 WAR 包,找到这个数据。
|
||||
|
||||
|
||||
注意,即使有了分布式存储系统做 Volume,你还需要负责维护 Volume 里的 WAR 包。比如:你需要单独写一套 Kubernetes Volume 插件,用来在每次 Pod 启动之前,把应用启动所需的 WAR 包下载到这个 Volume 里,然后才能被应用挂载使用到。
|
||||
|
||||
|
||||
这样操作带来的复杂程度还是比较高的,且这个容器本身必须依赖于一套持久化的存储插件(用来管理 Volume 里的 WAR 包内容)。
|
||||
|
||||
InitContainer
|
||||
|
||||
所以大家有没有考虑过,像这样的组合方式,有没有更加通用的方法?哪怕在本地 Kubernetes 上,没有分布式存储的情况下也能用、能玩、能发布。
|
||||
|
||||
实际上方法是有的,在 Kubernetes 里面,像这样的组合方式,叫做 Init Container。
|
||||
|
||||
|
||||
|
||||
还是同样一个例子:在上图的 yaml 里,首先定义一个 Init Container,它只做一件事情,就是把 WAR 包从镜像里拷贝到一个 Volume 里面,它做完这个操作就退出了,所以 Init Container 会比用户容器先启动,并且严格按照定义顺序来依次执行。
|
||||
|
||||
然后,这个关键在于刚刚拷贝到的这样一个目的目录:APP 目录,实际上是一个 Volume。而我们前面提到,一个 Pod 里面的多个容器,它们是可以共享 Volume 的,所以现在这个 Tomcat 容器,只是打包了一个 Tomcat 镜像。但在启动的时候,要声明使用 APP 目录作为我的 Volume,并且要把它们挂载在 Web APP 目录下面。
|
||||
|
||||
而这个时候,由于前面已经运行过了一个 Init Container,已经执行完拷贝操作了,所以这个 Volume 里面已经存在了应用的 WAR 包:就是 sample.war,绝对已经存在这个 Volume 里面了。等到第二步执行启动这个 Tomcat 容器的时候,去挂这个 Volume,一定能在里面找到前面拷贝来的 sample.war。
|
||||
|
||||
所以可以这样去描述:这个 Pod 就是一个自包含的,可以把这一个 Pod 在全世界任何一个 Kubernetes 上面都顺利启用起来。不用担心没有分布式存储、Volume 不是持久化的,它一定是可以公布的。
|
||||
|
||||
所以这是一个通过组合两个不同角色的容器,并且按照这样一些像 Init Container 这样一种编排方式,统一的去打包这样一个应用,把它用 Pod 来去做的非常典型的一个例子。像这样的一个概念,在 Kubernetes 里面就是一个非常经典的容器设计模式,叫做:“Sidecar”。
|
||||
|
||||
容器设计模式:Sidecar
|
||||
|
||||
什么是 Sidecar?就是说其实在 Pod 里面,可以定义一些专门的容器,来执行主业务容器所需要的一些辅助工作,比如我们前面举的例子,其实就干了一个事儿,这个 Init Container,它就是一个 Sidecar,它只负责把镜像里的 WAR 包拷贝到共享目录里面,以便被 Tomcat 能够用起来。
|
||||
|
||||
其它有哪些操作呢?比如说:
|
||||
|
||||
|
||||
原本需要在容器里面执行 SSH 需要干的一些事情,可以写脚本、一些前置的条件,其实都可以通过像 Init Container 或者另外像 Sidecar 的方式去解决;
|
||||
当然还有一个典型例子就是我的日志收集,日志收集本身是一个进程,是一个小容器,那么就可以把它打包进 Pod 里面去做这个收集工作;
|
||||
还有一个非常重要的东西就是 Debug 应用,实际上现在 Debug 整个应用都可以在应用 Pod 里面再次定义一个额外的小的 Container,它可以去 exec 应用 pod 的 namespace;
|
||||
查看其他容器的工作状态,这也是它可以做的事情。不再需要去 SSH 登陆到容器里去看,只要把监控组件装到额外的小容器里面就可以了,然后把它作为一个 Sidecar 启动起来,跟主业务容器进行协作,所以同样业务监控也都可以通过 Sidecar 方式来去做。
|
||||
|
||||
|
||||
这种做法一个非常明显的优势就是在于其实将辅助功能从我的业务容器解耦了,所以我就能够独立发布 Sidecar 容器,并且更重要的是这个能力是可以重用的,即同样的一个监控 Sidecar 或者日志 Sidecar,可以被全公司的人共用的。这就是设计模式的一个威力。
|
||||
|
||||
|
||||
|
||||
Sidecar:应用与日志收集
|
||||
|
||||
接下来,我们再详细细化一下 Sidecar 这样一个模式,它还有一些其他的场景。
|
||||
|
||||
比如说前面提到的应用日志收集,业务容器将日志写在一个 Volume 里面,而由于 Volume 在 Pod 里面是被共享的,所以日志容器 —— 即 Sidecar 容器一定可以通过共享该 Volume,直接把日志文件读出来,然后存到远程存储里面,或者转发到另外一个例子。现在业界常用的 Fluentd 日志进程或日志组件,基本上都是这样的工作方式。
|
||||
|
||||
|
||||
|
||||
Sidecar:代理容器
|
||||
|
||||
Sidecar 的第二个用法,可以称作为代理容器 Proxy。什么叫做代理容器呢?
|
||||
|
||||
假如现在有个 Pod 需要访问一个外部系统,或者一些外部服务,但是这些外部系统是一个集群,那么这个时候如何通过一个统一的、简单的方式,用一个 IP 地址,就把这些集群都访问到?有一种方法就是:修改代码。因为代码里记录了这些集群的地址;另外还有一种解耦的方法,即通过 Sidecar 代理容器。
|
||||
|
||||
简单说,单独写一个这么小的 Proxy,用来处理对接外部的服务集群,它对外暴露出来只有一个 IP 地址就可以了。所以接下来,业务容器主要访问 Proxy,然后由 Proxy 去连接这些服务集群,这里的关键在于 Pod 里面多个容器是通过 localhost 直接通信的,因为它们同属于一个 network Namespace,网络视图都一样,所以它们俩通信 localhost,并没有性能损耗。
|
||||
|
||||
所以说代理容器除了做了解耦之外,并不会降低性能,更重要的是,像这样一个代理容器的代码就又可以被全公司重用了。
|
||||
|
||||
|
||||
|
||||
Sidecar:适配器容器
|
||||
|
||||
Sidecar 的第三个设计模式 —— 适配器容器 Adapter,什么叫 Adapter 呢?
|
||||
|
||||
现在业务暴露出来的 API,比如说有个 API 的一个格式是 A,但是现在有一个外部系统要去访问我的业务容器,它只知道的一种格式是 API B ,所以要做一个工作,就是把业务容器怎么想办法改掉,要去改业务代码。但实际上,你可以通过一个 Adapter 帮你来做这层转换。
|
||||
|
||||
|
||||
|
||||
现在有个例子:现在业务容器暴露出来的监控接口是 /metrics,访问这个这个容器的 metrics 的这个 URL 就可以拿到了。可是现在,这个监控系统升级了,它访问的 URL 是 /health,我只认得暴露出 health 健康检查的 URL,才能去做监控,metrics 不认识。那这个怎么办?那就需要改代码了,但可以不去改代码,而是额外写一个 Adapter,用来把所有对 health 的这个请求转发给 metrics 就可以了,所以这个 Adapter 对外暴露的是 health 这样一个监控的 URL,这就可以了,你的业务就又可以工作了。
|
||||
|
||||
这样的关键还在于 Pod 之中的容器是通过 localhost 直接通信的,所以没有性能损耗,并且这样一个 Adapter 容器可以被全公司重用起来,这些都是设计模式给我们带来的好处。
|
||||
|
||||
本节总结
|
||||
|
||||
|
||||
Pod 是 Kubernetes 项目里实现“容器设计模式”的核心机制;
|
||||
“容器设计模式”是 Google Borg 的大规模容器集群管理最佳实践之一,也是 Kubernetes 进行复杂应用编排的基础依赖之一;
|
||||
所有“设计模式”的本质都是:解耦和重用。
|
||||
|
||||
|
||||
|
||||
|
||||
|
302
专栏/CNCFX阿里巴巴云原生技术公开课/05应用编排与管理:核心原理.md
Normal file
302
专栏/CNCFX阿里巴巴云原生技术公开课/05应用编排与管理:核心原理.md
Normal file
@ -0,0 +1,302 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 应用编排与管理:核心原理
|
||||
资源元信息
|
||||
|
||||
1. Kubernetes 资源对象
|
||||
|
||||
首先,我们来回顾一下 Kubernetes 的资源对象组成:主要包括了 Spec、Status 两部分。其中 Spec 部分用来描述期望的状态,Status 部分用来描述观测到的状态。
|
||||
|
||||
今天我们将为大家介绍 K8s 的另外一个部分,即元数据部分。该部分主要包括了用来识别资源的标签:Labels, 用来描述资源的注解;Annotations, 用来描述多个资源之间相互关系的 OwnerReference。这些元数据在 K8s 运行中有非常重要的作用。后续课程中将会反复讲到。
|
||||
|
||||
2. labels
|
||||
|
||||
第一个元数据,也是最重要的一个元数据是:资源标签。资源标签是一种具有标识型的 Key:Value 元数据,这里展示了几个常见的标签。
|
||||
|
||||
前三个标签都打在了 Pod 对象上,分别标识了对应的应用环境、发布的成熟度和应用的版本。从应用标签的例子可以看到,标签的名字包括了一个域名的前缀,用来描述打标签的系统和工具, 最后一个标签打在 Node 对象上,还在域名前增加了版本的标识 beta 字符串。
|
||||
|
||||
标签主要用来筛选资源和组合资源,可以使用类似于 SQL 查询 select,来根据 Label 查询相关的资源。
|
||||
|
||||
|
||||
|
||||
3. Selector
|
||||
|
||||
最常见的 Selector 就是相等型 Selector。现在举一个简单的例子:
|
||||
|
||||
假设系统中有四个 Pod,每个 Pod 都有标识系统层级和环境的标签,我们通过 Tie:front 这个标签,可以匹配左边栏的 Pod,相等型 Selector 还可以包括多个相等条件,多个相等条件之间是逻辑”与“的关系。
|
||||
|
||||
在刚才的例子中,通过 Tie=front,Env=dev 的Selector,我们可以筛选出所有 Tie=front,而且 Env=dev 的 Pod,也就是下图中左上角的 Pod。另外一种 Selector 是集合型 Selector,在例子中,Selector 筛选所有环境是 test 或者 gray 的 Pod。
|
||||
|
||||
除了 in 的集合操作外,还有 notin 集合操作,比如 tie notin(front,back),将会筛选所有 tie 不是 front 且不是 back 的 Pod。另外,也可以根据是否存在某 lable 的筛选,如:Selector release,筛选所有带 release 标签的 Pod。集合型和相等型的 Selector,也可以用“,”来连接,同样的标识逻辑”与“的关系。
|
||||
|
||||
|
||||
|
||||
4. Annotations
|
||||
|
||||
另外一种重要的元数据是:annotations。一般是系统或者工具用来存储资源的非标示性信息,可以用来扩展资源的 spec/status 的描述,这里给了几个 annotations 的例子:
|
||||
|
||||
第一个例子,存储了阿里云负载器的证书 ID,我们可以看到 annotations 一样可以拥有域名的前缀,标注中也可以包含版本信息。第二个 annotation存储了 nginx 接入层的配置信息,我们可以看到 annotations 中包括“,”这样无法出现在 label 中的特殊字符。第三个 annotations 一般可以在 kubectl apply 命令行操作后的资源中看到, annotation 值是一个结构化的数据,实际上是一个 json 串,标记了上一次 kubectl 操作的资源的 json 的描述。
|
||||
|
||||
|
||||
|
||||
5. Ownereference
|
||||
|
||||
我们当时讲到最后一个元数据叫做 Ownereference,所谓所有者,一般就是指集合类的资源,比如说 Pod 集合,就有 replicaset、statefulset,这个将在后序的课程中讲到。
|
||||
|
||||
集合类资源的控制器会创建对应的归属资源。比如:replicaset 控制器在操作中会创建 Pod,被创建 Pod 的 Ownereference 就指向了创建 Pod 的 replicaset,Ownereference 使得用户可以方便地查找一个创建资源的对象,另外,还可以用来实现级联删除的效果。** **
|
||||
|
||||
操作演示
|
||||
|
||||
这里通过 kubectl 命令去连接我们 ACK 中已经创建好的一个 K8s 集群,然后来展示一下怎么查看和修改 K8s 对象中的元数据,主要就是 Pod 的一个标签、注解,还有对应的 Ownerference。
|
||||
|
||||
首先我们看一下集群里现在的配置情况:
|
||||
|
||||
查看 Pod,现在没有任何的一个 Pod;
|
||||
|
||||
|
||||
kubectl get pods
|
||||
|
||||
|
||||
然后用事先准备好的一个 Pod 的 yaml,创建一个 Pod 出来;
|
||||
|
||||
|
||||
kubectl apply -f pod1.yaml
|
||||
kubectl apply -f pod2.yaml
|
||||
|
||||
|
||||
现在查看一下 Pod 打的标签,我们用 –show-labels 这个选项,可以看到这两个 Pod 都打上了一个部署环境和层级的标签;
|
||||
|
||||
|
||||
kubectl get pods —show-labels
|
||||
|
||||
|
||||
我们也可以通过另外一种方式来查看具体的资源信息。首先查看 nginx1 第一个 Pod 的一个信息,用 -o yaml 的方式输出,可以看到这个 Pod 元数据里面包括了一个 lables 的字段,里面有两个 lable;
|
||||
|
||||
|
||||
kubectl get pods nginx1 -o yaml | less
|
||||
|
||||
|
||||
现在再想一下,怎么样对 Pod 已有的 lable 进行修改?我们先把它的部署环境,从开发环境改成测试环境,然后指定 Pod 名字,在环境再加上它的一个值 test ,看一下能不能成功。 这里报了一个错误,可以看到,它其实是说现在这个 label 已经有值了;
|
||||
|
||||
|
||||
kubectl label pods nginx1 env=test
|
||||
|
||||
|
||||
如果想覆盖掉它的话,得额外再加上一个覆盖的选项。加上之后呢,我们应该可以看到这个打标已经成功了;
|
||||
|
||||
|
||||
kubectl label pods nginx1 env=test —overwrite
|
||||
|
||||
|
||||
我们再看一下现在集群的 lable 设置情况,首先可以看到 nginx1 的确已经加上了一个部署环境 test 标签;
|
||||
|
||||
|
||||
kubectl get pods —show-labels
|
||||
|
||||
|
||||
如果想要对 Pod 去掉一个标签,也是跟打标签一样的操作,但是 env 后就不是等号了。只加上 label 名字,后面不加等号,改成用减号表示去除 label 的 k:v;
|
||||
|
||||
|
||||
kubectl label pods nginx tie-
|
||||
|
||||
|
||||
可以看到这个 label,去标已经完全成功;
|
||||
|
||||
|
||||
kubectl get pods —show-labels
|
||||
|
||||
|
||||
|
||||
|
||||
下面来看一下配置的 label 值,的确能看到 nginx1 的这个 Pod 少了一个 tie=front 的标签。有了这个 Pod 标签之后,可以看一下怎样用 label Selector 进行匹配?首先 label Selector 是通过 -l 这个选项来进行指定的 ,指定的时候,先试一下用相等型的一个 label 来筛选,所以我们指定的是部署环境等于测试的一个 Pod,我们可以看到能够筛选出一台;
|
||||
|
||||
|
||||
kubectl get pods —show-labels -l env=test
|
||||
|
||||
|
||||
假如说有多个相等的条件需要指定的,实际上这是一个与的关系,假如说 env 再等于 dev,我们实际上是一个 Pod 都拿不到的;
|
||||
|
||||
|
||||
kubectl get pods —show-labels -l env=test,env=dev
|
||||
|
||||
|
||||
然后假如说 env=dev,但是 tie=front,我们能够匹配到第二个 Pod,也就是 nginx2;
|
||||
|
||||
|
||||
kubectl get pods —show-labels -l env=dev,tie=front
|
||||
|
||||
|
||||
我们还可以再试一下怎么样用集合型的 label Selector 来进行筛选。这一次我们还是想要匹配出所有部署环境是 test 或者是 dev 的一个 Pod,所以在这里加上一个引号,然后在括号里面指定所有部署环境的一个集合。这次能把两个创建的 Pod 都筛选出来;
|
||||
|
||||
|
||||
kubectl get pods —show-labels -l ’env in (dev,test)’
|
||||
|
||||
|
||||
我们再试一下怎样对 Pod 增加一个注解,注解的话,跟打标是一样的操作,但是把 label 命令改成 annotate 命令;然后,一样指定类型和对应的名字。后面就不是加上 label 的 k:v 了,而是加上 annotation 的 k:v。这里我们可以指定一个任意的字符串,比如说加上空格、加上逗号都可以;
|
||||
|
||||
|
||||
kubectl annotate pods nginx1 my-annotate=‘my annotate,ok’
|
||||
|
||||
|
||||
然后,我们再看一下这个 Pod 的一些元数据,我们这边能够看到这个 Pod 的元数据里面 annotations,这是有一个 my-annotate 这个 Annotations;
|
||||
|
||||
|
||||
kubectl get pods nging1 -o yaml | less
|
||||
|
||||
|
||||
然后我们这里其实也能够看到有一个 kubectl apply 的时候,kubectl 工具增加了一个 annotation,这也是一个 json 串。
|
||||
|
||||
|
||||
|
||||
然后我们再演示一下看 Pod 的 Ownereference 是怎么出来的。原来的 Pod 都是直接通过创建 Pod 这个资源方式来创建的,这次换一种方式来创建:通过创建一个 ReplicaSet 对象来创建 Pod 。首先创建一个 ReplicaSet 对象,这个 ReplicaSet 对象可以具体查看一下;
|
||||
|
||||
|
||||
kubectl apply -f rs.yaml
|
||||
kubectl get replicasets nginx-replicasets -o yaml |less
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
我们可以关注一下这个 ReplicaSet 里面 spec 里面,提到会创建两个 Pod,然后 selector 通过匹配部署环境是 product 生产环境的这个标签来进行匹配。所以我们可以看一下,现在集群中的 Pod 情况;
|
||||
|
||||
|
||||
|
||||
**kubectl get pods **
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
将会发现多了两个 Pod,仔细查看这两个 Pod,可以看到 ReplicaSet 创建出来的 Pod 有一个特点,即它会带有 Ownereference,然后 Ownereference 里面指向了是一个 replicasets 类型,名字就叫做 nginx-replicasets;
|
||||
|
||||
|
||||
|
||||
kubectl get pods nginx-replicasets-rhd68 -o yaml | less
|
||||
|
||||
|
||||
|
||||
|
||||
控制器模式
|
||||
|
||||
1、控制循环
|
||||
|
||||
控制型模式最核心的就是控制循环的概念。在控制循环中包括了控制器,被控制的系统,以及能够观测系统的传感器,三个逻辑组件。
|
||||
|
||||
当然这些组件都是逻辑的,外界通过修改资源 spec 来控制资源,控制器比较资源 spec 和 status,从而计算一个 diff,diff 最后会用来决定执行对系统进行什么样的控制操作,控制操作会使得系统产生新的输出,并被传感器以资源 status 形式上报,控制器的各个组件将都会是独立自主地运行,不断使系统向 spec 表示终态趋近。
|
||||
|
||||
|
||||
|
||||
2、Sensor
|
||||
|
||||
控制循环中逻辑的传感器主要由 Reflector、Informer、Indexer 三个组件构成。
|
||||
|
||||
Reflector 通过 List 和 Watch K8s server 来获取资源的数据。List 用来在 Controller 重启以及 Watch 中断的情况下,进行系统资源的全量更新;而 Watch 则在多次 List 之间进行增量的资源更新;Reflector 在获取新的资源数据后,会在 Delta 队列中塞入一个包括资源对象信息本身以及资源对象事件类型的 Delta 记录,Delta 队列中可以保证同一个对象在队列中仅有一条记录,从而避免 Reflector 重新 List 和 Watch 的时候产生重复的记录。
|
||||
|
||||
Informer 组件不断地从 Delta 队列中弹出 delta 记录,然后把资源对象交给 indexer,让 indexer 把资源记录在一个缓存中,缓存在默认设置下是用资源的命名空间来做索引的,并且可以被 Controller Manager 或多个 Controller 所共享。之后,再把这个事件交给事件的回调函数
|
||||
|
||||
|
||||
|
||||
控制循环中的控制器组件主要由事件处理函数以及 worker 组成,事件处理函数之间会相互关注资源的新增、更新、删除的事件,并根据控制器的逻辑去决定是否需要处理。对需要处理的事件,会把事件关联资源的命名空间以及名字塞入一个工作队列中,并且由后续的 worker 池中的一个 Worker 来处理,工作队列会对存储的对象进行去重,从而避免多个 Woker 处理同一个资源的情况。
|
||||
|
||||
Worker 在处理资源对象时,一般需要用资源的名字来重新获得最新的资源数据,用来创建或者更新资源对象,或者调用其他的外部服务,Worker 如果处理失败的时候,一般情况下会把资源的名字重新加入到工作队列中,从而方便之后进行重试。
|
||||
|
||||
3、控制循环例子-扩容
|
||||
|
||||
这里举一个简单的例子来说明一下控制循环的工作原理。
|
||||
|
||||
ReplicaSet 是一个用来描述无状态应用的扩缩容行为的资源, ReplicaSet controler 通过监听 ReplicaSet 资源来维持应用希望的状态数量,ReplicaSet 中通过 selector 来匹配所关联的 Pod,在这里考虑 ReplicaSet rsA 的,replicas 从 2 被改到 3 的场景。
|
||||
|
||||
|
||||
|
||||
首先,Reflector 会 watch 到 ReplicaSet 和 Pod 两种资源的变化,为什么我们还会 watch pod 资源的变化稍后会讲到。发现 ReplicaSet 发生变化后,在 delta 队列中塞入了对象是 rsA,而且类型是更新的记录。
|
||||
|
||||
Informer 一方面把新的 ReplicaSet 更新到缓存中,并与 Namespace nsA 作为索引。另外一方面,调用 Update 的回调函数,ReplicaSet 控制器发现 ReplicaSet 发生变化后会把字符串的 nsA/rsA 字符串塞入到工作队列中,工作队列后的一个 Worker 从工作队列中取到了 nsA/rsA 这个字符串的 key,并且从缓存中取到了最新的 ReplicaSet 数据。
|
||||
|
||||
Worker 通过比较 ReplicaSet 中 spec 和 status 里的数值,发现需要对这个 ReplicaSet 进行扩容,因此 ReplicaSet 的 Worker 创建了一个 Pod,这个 pod 中的 Ownereference 取向了 ReplicaSet rsA。
|
||||
|
||||
|
||||
|
||||
然后 Reflector Watch 到的 Pod 新增事件,在 delta 队列中额外加入了 Add 类型的 deta 记录,一方面把新的 Pod 记录通过 Indexer 存储到了缓存中,另一方面调用了 ReplicaSet 控制器的 Add 回调函数,Add 回调函数通过检查 pod ownerReferences 找到了对应的 ReplicaSet,并把包括 ReplicaSet 命名空间和字符串塞入到了工作队列中。
|
||||
|
||||
ReplicaSet 的 Woker 在得到新的工作项之后,从缓存中取到了新的 ReplicaSet 记录,并得到了其所有创建的 Pod,因为 ReplicaSet 的状态不是最新的,也就是所有创建 Pod 的数量不是最新的。因此在此时 ReplicaSet 更新 status 使得 spec 和 status 达成一致。
|
||||
|
||||
|
||||
|
||||
控制器模式总结
|
||||
|
||||
1、两种 API 设计方法
|
||||
|
||||
Kubernetes 控制器模式依赖声明式的 API。另外一种常见的 API 类型是命令式 API。为什么 Kubernetes 采用声明式 API,而不是命令式 API 来设计整个控制器呢?
|
||||
|
||||
首先,比较两种 API 在交互行为上的差别。在生活中,常见的命令式的交互方式是家长和孩子交流方式,因为孩子欠缺目标意识,无法理解家长期望,家长往往通过一些命令,教孩子一些明确的动作,比如说:吃饭、睡觉类似的命令。我们在容器编排体系中,命令式 API 就是通过向系统发出明确的操作来执行的。
|
||||
|
||||
而常见的声明式交互方式,就是老板对自己员工的交流方式。老板一般不会给自己的员工下很明确的决定,实际上可能老板对于要操作的事情本身,还不如员工清楚。因此,老板通过给员工设置可量化的业务目标的方式,来发挥员工自身的主观能动性。比如说,老板会要求某个产品的市场占有率达到 80%,而不会指出要达到这个市场占有率,要做的具体操作细节。
|
||||
|
||||
类似的,在容器编排体系中,我们可以执行一个应用实例副本数保持在 3 个,而不用明确的去扩容 Pod 或是删除已有的 Pod,来保证副本数在三个。
|
||||
|
||||
|
||||
|
||||
2、命令式 API 的问题
|
||||
|
||||
在理解两个交互 API 的差别后,可以分析一下命令式 API 的问题。
|
||||
|
||||
|
||||
命令 API 最大的一个问题在于错误处理;
|
||||
|
||||
|
||||
在大规模的分布式系统中,错误是无处不在的。一旦发出的命令没有响应,调用方只能通过反复重试的方式来试图恢复错误,然而盲目的重试可能会带来更大的问题。
|
||||
|
||||
假设原来的命令,后台实际上已经执行完成了,重试后又多执行了一个重试的命令操作。为了避免重试的问题,系统往往还需要在执行命令前,先记录一下需要执行的命令,并且在重启等场景下,重做待执行的命令,而且在执行的过程中,还需要考虑多个命令的先后顺序、覆盖关系等等一些复杂的逻辑情况。
|
||||
|
||||
|
||||
实际上许多命令式的交互系统后台往往还会做一个巡检的系统,用来修正命令处理超时、重试等一些场景造成数据不一致的问题;
|
||||
|
||||
|
||||
然而,因为巡检逻辑和日常操作逻辑是不一样的,往往在测试上覆盖不够,在错误处理上不够严谨,具有很大的操作风险,因此往往很多巡检系统都是人工来触发的。
|
||||
|
||||
|
||||
最后,命令式 API 在处理多并发访问时,也很容易出现问题;
|
||||
|
||||
|
||||
假如有多方并发的对一个资源请求进行操作,并且一旦其中有操作出现了错误,就需要重试。那么最后哪一个操作生效了,就很难确认,也无法保证。很多命令式系统往往在操作前会对系统进行加锁,从而保证整个系统最后生效行为的可预见性,但是加锁行为会降低整个系统的操作执行效率。
|
||||
|
||||
|
||||
相对的,声明式 API 系统里天然地记录了系统现在和最终的状态。
|
||||
|
||||
|
||||
不需要额外的操作数据。另外因为状态的幂等性,可以在任意时刻反复操作。在声明式系统运行的方式里,正常的操作实际上就是对资源状态的巡检,不需要额外开发巡检系统,系统的运行逻辑也能够在日常的运行中得到测试和锤炼,因此整个操作的稳定性能够得到保证。
|
||||
|
||||
最后,因为资源的最终状态是明确的,我们可以合并多次对状态的修改。可以不需要加锁,就支持多方的并发访问。
|
||||
|
||||
|
||||
|
||||
3、控制器模式总结
|
||||
|
||||
最后我们总结一下:
|
||||
|
||||
|
||||
Kubernetes 所采用的控制器模式,是由声明式 API 驱动的。确切来说,是基于对 Kubernetes 资源对象的修改来驱动的;
|
||||
Kubernetes 资源之后,是关注该资源的控制器。这些控制器将异步的控制系统向设置的终态驱近;
|
||||
这些控制器是自主运行的,使得系统的自动化和无人值守成为可能;
|
||||
因为 Kubernetes 的控制器和资源都是可以自定义的,因此可以方便的扩展控制器模式。特别是对于有状态应用,我们往往通过自定义资源和控制器的方式,来自动化运维操作。这个也就是后续会介绍的 operator 的场景。
|
||||
|
||||
|
||||
|
||||
|
||||
本节总结
|
||||
|
||||
本节课的主要内容就到此为止了,这里为大家简单总结一下:
|
||||
|
||||
|
||||
Kubernetes 资源对象中的元数据部分,主要包括了用来识别资源的标签:Labels, 用来描述资源的注解;Annotations, 用来描述多个资源之间相互关系的 OwnerReference。这些元数据在 K8s 运行中有非常重要的作用;
|
||||
控制型模式中最核心的就是控制循环的概念;
|
||||
两种 API 设计方法:声明式 API 和命令式 API ;Kubernetes 所采用的控制器模式,是由声明式 API 驱动的。
|
||||
|
||||
|
||||
|
||||
|
||||
|
302
专栏/CNCFX阿里巴巴云原生技术公开课/06应用编排与管理.md
Normal file
302
专栏/CNCFX阿里巴巴云原生技术公开课/06应用编排与管理.md
Normal file
@ -0,0 +1,302 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 应用编排与管理
|
||||
需求来源
|
||||
|
||||
背景问题
|
||||
|
||||
首先,我们来看一下背景问题。如下图所示:如果我们直接管理集群中所有的 Pod,应用 A、B、C 的 Pod,其实是散乱地分布在集群中。
|
||||
|
||||
|
||||
|
||||
现在有以下的问题:
|
||||
|
||||
|
||||
首先,如何保证集群内可用 Pod 的数量?也就是说我们应用 A 四个 Pod 如果出现了一些宿主机故障,或者一些网络问题,如何能保证它可用的数量?
|
||||
如何为所有 Pod 更新镜像版本?我们是否要某一个 Pod 去重建新版本的 Pod?
|
||||
然后在更新过程中,如何保证服务的可用性?
|
||||
以及更新过程中,如果发现了问题,如何快速回滚到上一个版本?
|
||||
|
||||
|
||||
Deployment:管理部署发布的控制器
|
||||
|
||||
这里就引入了我们今天课程的主题:Deployment 管理部署发布的控制器。
|
||||
|
||||
|
||||
|
||||
可以看到我们通过 Deployment 将应用 A、B、C 分别规划到不同的 Deployment 中,每个 Deployment 其实是管理的一组相同的应用 Pod,这组 Pod 我们认为它是相同的一个副本,那么 Deployment 能帮我们做什么事情呢?
|
||||
|
||||
首先,Deployment 定义了一种 Pod 期望数量,比如说应用 A,我们期望 Pod 数量是四个,那么这样的话,controller 就会持续维持 Pod 数量为期望的数量。当我们与 Pod 出现了网络问题或者宿主机问题的话,controller 能帮我们恢复,也就是新扩出来对应的 Pod,来保证可用的 Pod 数量与期望数量一致;
|
||||
|
||||
配置 Pod 发布方式,也就是说 controller 会按照用户给定的策略来更新 Pod,而且更新过程中,也可以设定不可用 Pod 数量在多少范围内;
|
||||
|
||||
如果更新过程中发生问题的话,即所谓“一键”回滚,也就是说你通过一条命令或者一行修改能够将 Deployment 下面所有 Pod 更新为某一个旧版本 。
|
||||
|
||||
用例解读
|
||||
|
||||
Deployment 语法
|
||||
|
||||
下面我们用一个简单的用例来解读一下如何操作 Deployment。
|
||||
|
||||
|
||||
|
||||
上图可以看到一个最简单的 Deployment 的 yaml 文件。
|
||||
|
||||
“apiVersion:apps/v1”,也就是说 Deployment 当前所属的组是 apps,版本是 v1。“metadata”是我们看到的 Deployment 元信息,也就是往期回顾中的 Labels、Selector、Pod.image,这些都是在往期中提到的知识点。
|
||||
|
||||
Deployment 作为一个 K8s 资源,它有自己的 metadata 元信息,这里我们定义的 Deployment.name 是 nginx.Deployment。Deployment.spec 中首先要有一个核心的字段,即 replicas,这里定义期望的 Pod 数量为三个;selector 其实是 Pod 选择器,那么所有扩容出来的 Pod,它的 Labels 必须匹配 selector 层上的 image.labels,也就是 app.nginx。
|
||||
|
||||
就如上面的 Pod 模板 template 中所述,这个 template 它其实包含了两部分内容:
|
||||
|
||||
|
||||
一部分是我们期望 Pod 的 metadata,其中包含了 labels,即跟 selector.matchLabels 相匹配的一个 Labels;
|
||||
第二部分是 template 包含的一个 Pod.spec。这里 Pod.spec 其实是 Deployment 最终创建出来 Pod 的时候,它所用的 Pod.spec,这里定义了一个 container.nginx,它的镜像版本是 nginx:1.7.9。
|
||||
|
||||
|
||||
下面是遇到的新知识点:
|
||||
|
||||
|
||||
第一个是 replicas,就是 Deployment 中期望的或者终态数量;
|
||||
第二个是 template,也就是 Pod 相关的一个模板。
|
||||
|
||||
|
||||
查看 Deployment 状态
|
||||
|
||||
当我们创建出一个 Deployment 的时候,可以通过 kubectl get deployment,看到 Deployment 总体的一个状态。如下图所示:
|
||||
|
||||
|
||||
|
||||
上图中可以看到:
|
||||
|
||||
|
||||
DESIRED:期望的 Pod 数量是 3 个;
|
||||
CURRENT:当前实际 Pod 数量是 3 个;
|
||||
UP-TO-DATE:其实是到达最新的期望版本的 Pod 数量;
|
||||
AVAILABLE:这个其实是运行过程中可用的 Pod 数量。后面会提到,这里 AVAILABLE 并不简单是可用的,也就是 Ready 状态的,它其实包含了一些可用超过一定时间长度的 Pod;
|
||||
AGE:deployment 创建的时长,如上图 Deployment 就是已经创建了 80 分钟。
|
||||
|
||||
|
||||
查看 Pod
|
||||
|
||||
最后我们可以查看一下 Pod。如下图所示:
|
||||
|
||||
|
||||
|
||||
上图中有三个 Pod,Pod 名字格式我们不难看到。
|
||||
|
||||
最前面一段:nginx-deployment,其实是 Pod 所属 Deployment.name;中间一段:template-hash,这里三个 Pod 是一样的,因为这三个 Pod 其实都是同一个 template 中创建出来的。
|
||||
|
||||
最后一段,是一个 random 的字符串,我们通过 get.pod 可以看到,Pod 的 ownerReferences 即 Pod 所属的 controller 资源,并不是 Deployment,而是一个 ReplicaSet。这个 ReplicaSet 的 name,其实是 nginx-deployment 加上 pod.template-hash,后面会提到。所有的 Pod 都是 ReplicaSet 创建出来的,而 ReplicaSet 它对应的某一个具体的 Deployment.template 版本。
|
||||
|
||||
更新镜像
|
||||
|
||||
接下来我们可以看一下,如何对一个给定的 Deployment 更新它所有Pod的镜像版本呢?这里我们可以执行一个 kubectl 命令:
|
||||
|
||||
kubectl set image deployment.v1.apps/nginx-deployment nginx=nginx:1.9.1
|
||||
|
||||
首先 kubectl 后面有一个 set image 固定写法,这里指的是设定镜像;其次是一个 deployment.v1.apps,这里也是一个固定写法,写的是我们要操作的资源类型,deployment 是资源名、v1 是资源版本、apps 是资源组,这里也可以简写为 deployment 或者 deployment.apps,比如说写为 deployment 的时候,默认将使用 apps 组 v1 版本。
|
||||
|
||||
第三部分是要更新的 deployment 的 name,也就是我们的 nginx-deployment;再往后的 nginx 其实指的是 template,也就是 Pod 中的 container.name;这里我们可以注意到:一个 Pod 中,其实可能存在多个 container,而我们指定想要更新的镜像的 container.name,就是 nginx。
|
||||
|
||||
最后,指定我们这个容器期望更新的镜像版本,这里指的是 nginx: 1.9.1。如下图所示:当执行完这条命令之后,可以看到 deployment 中的 template.spec 已经更新为 nginx: 1.9.1。
|
||||
|
||||
|
||||
|
||||
快速回滚
|
||||
|
||||
如果我们在发布过程中遇到了问题,也支持快速回滚。通过 kubectl 执行的话,其实是“kubectl rollout undo”这个命令,可以回滚到 Deployment 上一版本;通过“rollout undo”加上“to-revision”来指定可以回滚到某一个具体的版本。
|
||||
|
||||
|
||||
|
||||
DeploymeStatus
|
||||
|
||||
最后我们来看一下 DeploymeStatus。前面的课程我们学习到,每一个资源都有它的 spec.Status。这里可以看一下,deploymentStatus 中描述的三个其实是它的 conversion 状态,也就是 Processing、Complete 以及 Failed。
|
||||
|
||||
|
||||
|
||||
以 Processing 为例:Processing 指的是 Deployment 正在处于扩容和发布中。比如说 Processing 状态的 deployment,它所有的 replicas 及 Pod 副本全部达到最新版本,而且是 available,这样的话,就可以进入 complete 状态。而 complete 状态如果发生了一些扩缩容的话,也会进入 processing 这个处理工作状态。
|
||||
|
||||
如果在处理过程中遇到一些问题:比如说拉镜像失败了,或者说 readiness probe 检查失败了,就会进入 failed 状态;如果在运行过程中即 complete 状态,中间运行时发生了一些 pod readiness probe 检查失败,这个时候 deployment 也会进入 failed 状态。进入 failed 状态之后,除非所有点 replicas 均变成 available,而且是 updated 最新版本,deployment 才会重新进入 complete 状态。
|
||||
|
||||
操作演示
|
||||
|
||||
Deployment 创建及状态
|
||||
|
||||
下面我们来进行操作演示:这里连接一个阿里云服务集群。我们可以看到当前集群已经有几个可用的 node。
|
||||
|
||||
|
||||
|
||||
首先创建对应的 deployment。可以看到 deployment 中的 desired、current、up-to-date 以及 available 已经都达到了可用的期望状态。
|
||||
|
||||
|
||||
|
||||
Deployment 的结构
|
||||
|
||||
这里看到 spec 中的 replicas 是三个,selector 以及 template labels中定义的标签都是 app:nginx,spec 中的 image 是我们期望的 nginx: 1.7.9;status 中的 available.replicas,readReplicas 以及 updatedReplicas 都是 3 个。
|
||||
|
||||
|
||||
|
||||
Pod 状态
|
||||
|
||||
我们可以再选择一个 Pod 看一下状态:
|
||||
|
||||
可以看到:Pod 中 ownerReferences 的功能是 ReplicaSet;pod.spec.container 里的镜像是 1.7.9。这个 Pod 已经是 Running 状态,而且它的 conditions.status 是“true”,表示它的服务已经可用了。
|
||||
|
||||
|
||||
|
||||
更新升级
|
||||
|
||||
当前只有最新版本的 replicaset,那么现在尝试对 deployment 做一次升级。
|
||||
|
||||
|
||||
|
||||
“kubectl set image”这个操作命令,后面接 “deployment”,加 deployment.name,最后指定容器名,以及我们期望升级的镜像版本。
|
||||
|
||||
|
||||
|
||||
接下来我们看下 deployment 中的 template 中的 image 已经更新为 1.9.1。
|
||||
|
||||
|
||||
|
||||
这个时候我们再 get pod 看一下状态。
|
||||
|
||||
|
||||
|
||||
三个 pod 已经升级为新版本,pod 名字中的 pod-template-hash 也已更新。
|
||||
|
||||
|
||||
|
||||
可以看到:旧版本 replicaset 的 spec 数量以及 pod 数量是都是 0,新版本的 pod 数量是 3 个。
|
||||
|
||||
|
||||
|
||||
假设又做了一次更新,这个时候 get.pod 其实可以看到:当前的 pod 其实是有两个旧版本的处于 running,另一个旧版本是在删除中;而两个新版本的 pod,一个已经进入 running,一个还在 creating 中。
|
||||
|
||||
这时我们可用的 pod 数量即非删除状态的 pod 数量,其实是 4 个,已经超过了 replica 原先在 deployment 设置的数量 3 个。这个原因是我们在 deployment 中有 maxavailable 和 maxsugar 两个操作,这两个配置可以限制我们在发布过程中的一些策略。在后面架构设计中会讲到这个问题。
|
||||
|
||||
** **
|
||||
|
||||
历史版本保留 revisionHistoryLimit
|
||||
|
||||
上图看到,我们当前最新版本的 replicaset 是 3 个 pod,另外还有两个历史版本的 replicaset,那么会不会存在一种情况:就是随着 deployment 持续的更新,这个旧版本的 replicaset 会越积越多呢?其实 deployment 提供了一个机制来避免这个问题:在 deployment spec 中,有一个 revisionHistoryLimit,它的默认值为 10,它其实保证了保留历史版本的 replicaset 的数量,我们尝试把它改为 1。
|
||||
|
||||
|
||||
|
||||
由上面第二张图,可以看到两个 replicaset,也就是说,除了当前版本的 replicaset 之外,旧版本的 replicaset 其实只保留了一个。
|
||||
|
||||
回滚
|
||||
|
||||
最后再尝试做一下回滚。首先再来看一下 replicaset,这时发现旧版本的 replicaset 数量从 0 个增到 2 个,而新版本的 replicaset 数量从 3 个削减为 1 个,表示它已经开始在做回滚的操作。然后再观察一下, 旧版本的数量已经是 3 个,即已经回滚成功,而新版本的 pod 数量变为 0 个。
|
||||
|
||||
|
||||
|
||||
我们最后再 get pod 看一下:
|
||||
|
||||
|
||||
|
||||
这时,3 个 pod.template-hash 已经更新为旧版本的 hash,但其实这 3 个 pod 都是重新创建出来的,而并非我们在前一版本中创建的 3 个 pod。换句话说,也就是我们回滚的时候,其实是创建了 3 个旧版本的 pod,而并非把先前的 3 个 pod 找回来。
|
||||
|
||||
架构设计
|
||||
|
||||
管理模式
|
||||
|
||||
|
||||
|
||||
我们来看一下架构设计。首先简单看一下管理模式:Deployment 只负责管理不同版本的 ReplicaSet,由 ReplicaSet 来管理具体的 Pod 副本数,每个 ReplicaSet 对应 Deployment template 的一个版本。在上文的例子中可以看到,每一次修改 template,都会生成一个新的 ReplicaSet,这个 ReplicaSet 底下的 Pod 其实都是相同的版本。
|
||||
|
||||
如上图所示:Deployment 创建 ReplicaSet,而 ReplicaSet 创建 Pod。他们的 OwnerRef 其实都对应了其控制器的资源。
|
||||
|
||||
Deployment 控制器
|
||||
|
||||
我们先简单看一下控制器实现原理。
|
||||
|
||||
首先,我们所有的控制器都是通过 Informer 中的 Event 做一些 Handler 和 Watch。这个地方 Deployment 控制器,其实是关注 Deployment 和 ReplicaSet 中的 event,收到事件后会加入到队列中。而 Deployment controller 从队列中取出来之后,它的逻辑会判断 Check Paused,这个 Paused 其实是 Deployment 是否需要新的发布,如果 Paused 设置为 true 的话,就表示这个 Deployment 只会做一个数量上的维持,不会做新的发布。
|
||||
|
||||
|
||||
|
||||
如上图,可以看到如果 Check paused 为 Yes 也就是 true 的话,那么只会做 Sync replicas。也就是说把 replicas sync 同步到对应的 ReplicaSet 中,最后再 Update Deployment status,那么 controller 这一次的 ReplicaSet 就结束了。
|
||||
|
||||
那么如果 paused 为 false 的话,它就会做 Rollout,也就是通过 Create 或者是 Rolling 的方式来做更新,更新的方式其实也是通过 Create/Update/Delete 这种 ReplicaSet 来做实现的。
|
||||
|
||||
ReplicaSet 控制器
|
||||
|
||||
|
||||
|
||||
当 Deployment 分配 ReplicaSet 之后,ReplicaSet 控制器本身也是从 Informer 中 watch 一些事件,这些事件包含了 ReplicaSet 和 Pod 的事件。从队列中取出之后,ReplicaSet controller 的逻辑很简单,就只管理副本数。也就是说如果 controller 发现 replicas 比 Pod 数量大的话,就会扩容,而如果发现实际数量超过期望数量的话,就会删除 Pod。
|
||||
|
||||
上面 Deployment 控制器的图中可以看到,Deployment 控制器其实做了更复杂的事情,包含了版本管理,而它把每一个版本下的数量维持工作交给 ReplicaSet 来做。
|
||||
|
||||
扩/缩容模拟
|
||||
|
||||
下面来看一些操作模拟,比如说扩容模拟。这里有一个 Deployment,它的副本数是 2,对应的 ReplicaSet 有 Pod1 和 Pod2。这时如果我们修改 Deployment replicas, controller 就会把 replicas 同步到当前版本的 ReplicaSet 中,这个 ReplicaSet 发现当前有 2 个 Pod,不满足当前期望 3 个,就会创建一个新的 Pod3。
|
||||
|
||||
|
||||
|
||||
发布模拟
|
||||
|
||||
我们再模拟一下发布,发布的情况会稍微复杂一点。这里可以看到 Deployment 当前初始的 template,比如说 template1 这个版本。template1 这个 ReplicaSet 对应的版本下有三个 Pod:Pod1,Pod2,Pod3。
|
||||
|
||||
这时修改 template 中一个容器的 image, Deployment controller 就会新建一个对应 template2 的 ReplicaSet。创建出来之后 ReplicaSet 会逐渐修改两个 ReplicaSet 的数量,比如它会逐渐增加 ReplicaSet2 中 replicas 的期望数量,而逐渐减少 ReplicaSet1 中的 Pod 数量。
|
||||
|
||||
那么最终达到的效果是:新版本的 Pod 为 Pod4、Pod5和Pod6,旧版本的 Pod 已经被删除了,这里就完成了一次发布。
|
||||
|
||||
|
||||
|
||||
回滚模拟
|
||||
|
||||
来看一下回滚模拟,根据上面的发布模拟可以知道 Pod4、Pod5、Pod6 已经发布完成。这时发现当前的业务版本是有问题的,如果做回滚的话,不管是通过 rollout 命令还是通过回滚修改 template,它其实都是把 template 回滚为旧版本的 template1。
|
||||
|
||||
这个时候 Deployment 会重新修改 ReplicaSet1 中 Pod 的期望数量,把期望数量修改为 3 个,且会逐渐减少新版本也就是 ReplicaSet2 中的 replica 数量,最终的效果就是把 Pod 从旧版本重新创建出来。
|
||||
|
||||
|
||||
|
||||
发布模拟的图中可以看到,其实初始版本中 Pod1、Pod2、Pod3 是旧版本,而回滚之后其实是 Pod7、Pod8、Pod9。就是说它的回滚并不是把之前的 Pod 重新找出来,而是说重新创建出符合旧版本 template 的 Pod。
|
||||
|
||||
spec 字段解析
|
||||
|
||||
最后再来简单看一些 Deployment 中的字段解析。首先看一下 Deployment 中其他的 spec 字段:
|
||||
|
||||
|
||||
MinReadySeconds:Deployment 会根据 Pod ready 来看 Pod 是否可用,但是如果我们设置了 MinReadySeconds 之后,比如设置为 30 秒,那 Deployment 就一定会等到 Pod ready 超过 30 秒之后才认为 Pod 是 available 的。Pod available 的前提条件是 Pod ready,但是 ready 的 Pod 不一定是 available 的,它一定要超过 MinReadySeconds 之后,才会判断为 available;
|
||||
revisionHistoryLimit:保留历史 revision,即保留历史 ReplicaSet 的数量,默认值为 10 个。这里可以设置为一个或两个,如果回滚可能性比较大的话,可以设置数量超过 10;
|
||||
paused:paused 是标识,Deployment 只做数量维持,不做新的发布,这里在 Debug 场景可能会用到;
|
||||
progressDeadlineSeconds:前面提到当 Deployment 处于扩容或者发布状态时,它的 condition 会处于一个 processing 的状态,processing 可以设置一个超时时间。如果超过超时时间还处于 processing,那么 controller 将认为这个 Pod 会进入 failed 的状态。
|
||||
|
||||
|
||||
|
||||
|
||||
升级策略字段解析
|
||||
|
||||
最后来看一下升级策略字段解析。
|
||||
|
||||
Deployment 在 RollingUpdate 中主要提供了两个策略,一个是 MaxUnavailable,另一个是 MaxSurge。这两个字段解析的意思,可以看下图中详细的 comment,或者简单解释一下:
|
||||
|
||||
|
||||
MaxUnavailable:滚动过程中最多有多少个 Pod 不可用;
|
||||
MaxSurge:滚动过程中最多存在多少个 Pod 超过预期 replicas 数量。
|
||||
|
||||
|
||||
上文提到,ReplicaSet 为 3 的 Deployment 在发布的时候可能存在一种情况:新版本的 ReplicaSet 和旧版本的 ReplicaSet 都可能有两个 replicas,加在一起就是 4 个,超过了我们期望的数量三个。这是因为我们默认的 MaxUnavailable 和 MaxSurge 都是 25%,默认 Deployment 在发布的过程中,可能有 25% 的 replica 是不可用的,也可能超过 replica 数量 25% 是可用的,最高可以达到 125% 的 replica 数量。
|
||||
|
||||
这里其实可以根据用户实际场景来做设置。比如当用户的资源足够,且更注重发布过程中的可用性,可设置 MaxUnavailable 较小、MaxSurge 较大。但如果用户的资源比较紧张,可以设置 MaxSurge 较小,甚至设置为 0,这里要注意的是 MaxSurge 和 MaxUnavailable 不能同时为 0。
|
||||
|
||||
理由不难理解,当 MaxSurge 为 0 的时候,必须要删除 Pod,才能扩容 Pod;如果不删除 Pod 是不能新扩 Pod 的,因为新扩出来的话,总共的 Pod 数量就会超过期望数量。而两者同时为 0 的话,MaxSurge 保证不能新扩 Pod,而 MaxUnavailable 不能保证 ReplicaSet 中有 Pod 是 available 的,这样就会产生问题。所以说这两个值不能同时为 0。用户可以根据自己的实际场景来设置对应的、合适的值。
|
||||
|
||||
|
||||
|
||||
本节总结
|
||||
|
||||
本节课的主要内容就到此为止了,这里为大家简单总结一下。
|
||||
|
||||
|
||||
Deployment 是 Kubernetes 中常见的一种 Workload,支持部署管理多版本的 Pod;
|
||||
Deployment 管理多版本的方式,是针对每个版本的 template 创建一个 ReplicaSet,由 ReplicaSet 维护一定数量的 Pod 副本,而 Deployment 只需要关心不同版本的 ReplicaSet 里要指定多少数量的 Pod;
|
||||
因此,Deployment 发布部署的根本原理,就是 Deployment 调整不同版本 ReplicaSet 里的终态副本数,以此来达到多版本 Pod 的升级和回滚。
|
||||
|
||||
|
||||
|
||||
|
||||
|
0
专栏/CNCFX阿里巴巴云原生技术公开课/07应用编排与管理:Job&DaemonSet.md
Normal file
0
专栏/CNCFX阿里巴巴云原生技术公开课/07应用编排与管理:Job&DaemonSet.md
Normal file
286
专栏/CNCFX阿里巴巴云原生技术公开课/08应用配置管理.md
Normal file
286
专栏/CNCFX阿里巴巴云原生技术公开课/08应用配置管理.md
Normal file
@ -0,0 +1,286 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 应用配置管理
|
||||
本节课程要点
|
||||
|
||||
|
||||
ConfigMaps 和 Secret 资源的创建和使用;
|
||||
Pod 身份认证的实现和原理;
|
||||
容器资源、安全、前置校验等配置和使用。
|
||||
|
||||
|
||||
细分为以下八个方面:
|
||||
|
||||
|
||||
|
||||
需求来源
|
||||
|
||||
背景问题
|
||||
|
||||
首先一起来看一下需求来源。大家应该都有过这样的经验,就是用一个容器镜像来启动一个 container。要启动这个容器,其实有很多需要配套的问题待解决:
|
||||
|
||||
|
||||
第一,比如说一些可变的配置。因为我们不可能把一些可变的配置写到镜像里面,当这个配置需要变化的时候,可能需要我们重新编译一次镜像,这个肯定是不能接受的;
|
||||
第二就是一些敏感信息的存储和使用。比如说应用需要使用一些密码,或者用一些 token;
|
||||
第三就是我们容器要访问集群自身。比如我要访问 kube-apiserver,那么本身就有一个身份认证的问题;
|
||||
第四就是容器在节点上运行之后,它的资源需求;
|
||||
第五个就是容器在节点上,它们是共享内核的,那么它的一个安全管控怎么办?
|
||||
最后一点我们说一下容器启动之前的一个前置条件检验。比如说,一个容器启动之前,我可能要确认一下 DNS 服务是不是好用?又或者确认一下网络是不是联通的?那么这些其实就是一些前置的校验。
|
||||
|
||||
|
||||
Pod 的配置管理
|
||||
|
||||
在 Kubernetes 里面,它是怎么做这些配置管理的呢?如下图所示:
|
||||
|
||||
|
||||
|
||||
|
||||
可变配置就用 ConfigMap;
|
||||
敏感信息是用 Secret;
|
||||
身份认证是用 ServiceAccount 这几个独立的资源来实现的;
|
||||
资源配置是用 Resources;
|
||||
安全管控是用 SecurityContext;
|
||||
前置校验是用 InitContainers 这几个在 spec 里面加的字段,来实现的这些配置管理。
|
||||
|
||||
|
||||
ConfigMap
|
||||
|
||||
ConfigMap 介绍
|
||||
|
||||
下面我们来介绍第一个部分,就是 ConfigMap。我们先来介绍 ConfigMap 它是用来做什么的、以及它带来的一个好处。它其实主要是管理一些可变配置信息,比如说我们应用的一些配置文件,或者说它里面的一些环境变量,或者一些命令行参数。
|
||||
|
||||
它的好处在于它可以让一些可变配置和容器镜像进行解耦,这样也保证了容器的可移植性。看一下下图中右边的编排文件截图。
|
||||
|
||||
|
||||
|
||||
这是 ConfigMap 本身的一个定义,它包括两个部分:一个是 ConfigMap 元信息,我们关注 name 和 namespace 这两个信息。接下来这个 data 里面,可以看到它管理了两个配置文件。它的结构其实是这样的:从名字看ConfigMap中包含Map单词,Map 其实就是 key:value,key 是一个文件名,value 是这个文件的内容。
|
||||
|
||||
ConfigMap 创建
|
||||
|
||||
看过介绍之后,再具体看一下它是怎么创建的。我们推荐用 kubectl 这个命令来创建,它带的参数主要有两个:一个是指定 name,第二个是 DATA。其中 DATA 可以通过指定文件或者指定目录,以及直接指定键值对,下面可以看一下这个例子。
|
||||
|
||||
|
||||
|
||||
指定文件的话,文件名就是 Map 中的 key,文件内容就是 Map 中的 value。然后指定键值对就是指定数据键值对,即:key:value 形式,直接映射到 Map 的key:value。
|
||||
|
||||
ConfigMap 使用
|
||||
|
||||
创建完了之后,应该怎么使用呢?
|
||||
|
||||
|
||||
|
||||
如上图所示,主要是在 pod 里来使用 ConfigMap:
|
||||
|
||||
|
||||
第一种是环境变量。环境变量的话通过 valueFrom,然后 ConfigMapKeyRef 这个字段,下面的 name 是指定 ConfigMap 名,key 是 ConfigMap.data 里面的 key。这样的话,在 busybox 容器启动后容器中执行 env 将看到一个 SPECIAL*LEVEL*KEY 环境变量;
|
||||
第二个是命令行参数。命令行参数其实是第一行的环境变量直接拿到 cmd 这个字段里面来用;
|
||||
最后一个是通过 volume 挂载的方式直接挂到容器的某一个目录下面去。上面的例子是把 special-config 这个 ConfigMap 里面的内容挂到容器里面的 /etc/config 目录下,这个也是使用的一种方式。
|
||||
|
||||
|
||||
ConfigMap 注意要点
|
||||
|
||||
现在对 ConfigMap 的使用做一个总结,以及它的一些注意点,注意点一共列了以下五条:
|
||||
|
||||
|
||||
ConfigMap 文件的大小。虽然说 ConfigMap 文件没有大小限制,但是在 ETCD 里面,数据的写入是有大小限制的,现在是限制在 1MB 以内;
|
||||
第二个注意点是 pod 引入 ConfigMap 的时候,必须是相同的 Namespace 中的 ConfigMap,前面其实可以看到,ConfigMap.metadata 里面是有 namespace 字段的;
|
||||
第三个是 pod 引用的 ConfigMap。假如这个 ConfigMap 不存在,那么这个 pod 是无法创建成功的,其实这也表示在创建 pod 前,必须先把要引用的 ConfigMap 创建好;
|
||||
第四点就是使用 envFrom 的方式。把 ConfigMap 里面所有的信息导入成环境变量时,如果 ConfigMap 里有些 key 是无效的,比如 key 的名字里面带有数字,那么这个环境变量其实是不会注入容器的,它会被忽略。但是这个 pod 本身是可以创建的。这个和第三点是不一样的方式,是 ConfigMap 文件存在基础上,整体导入成环境变量的一种形式;
|
||||
最后一点是:什么样的 pod 才能使用 ConfigMap?这里只有通过 K8s api 创建的 pod 才能使用 ConfigMap,比如说通过用命令行 kubectl 来创建的 pod,肯定是可以使用 ConfigMap 的,但其他方式创建的 pod,比如说 kubelet 通过 manifest 创建的 static pod,它是不能使用 ConfigMap 的。
|
||||
|
||||
|
||||
Secret
|
||||
|
||||
Secret 介绍
|
||||
|
||||
现在我们讲一下 Secret,Secret 是一个主要用来存储密码 token 等一些敏感信息的资源对象。其中,敏感信息是采用 base-64 编码保存起来的,我们来看下图中 Secret 数据的定义。
|
||||
|
||||
|
||||
|
||||
元数据的话,里面主要是 name、namespace 两个字段;接下来是 type,它是非常重要的一个字段,是指 Secret 的一个类型。Secret 类型种类比较多,下面列了常用的四种类型:
|
||||
|
||||
|
||||
第一种是 Opaque,它是普通的 Secret 文件;
|
||||
第二种是 service-account-token,是用于 service-account 身份认证用的 Secret;
|
||||
第三种是 dockerconfigjson,这是拉取私有仓库镜像的用的一种 Secret;
|
||||
第四种是 bootstrap.token,是用于节点接入集群校验用的 Secret。
|
||||
|
||||
|
||||
再接下来是 data,是存储的 Secret 的数据,它也是 key-value 的形式存储的。
|
||||
|
||||
Secret 创建
|
||||
|
||||
接下来我们看一下 Secret 的创建。
|
||||
|
||||
|
||||
|
||||
如上图所示,有两种创建方式:
|
||||
|
||||
|
||||
系统创建:比如 K8s 为每一个 namespace 的默认用户(default ServiceAccount)创建 Secret;
|
||||
用户手动创建:手动创建命令,推荐 kubectl 这个命令行工具,它相对 ConfigMap 会多一个 type 参数。其中 data 也是一样,它也是可以指定文件和键值对的。type 的话,要是你不指定的话,默认是 Opaque 类型。
|
||||
|
||||
|
||||
上图中两个例子。第一个是通过指定文件,创建了一个拉取私有仓库镜像的 Secret,指定的文件是 /root/.docker/config.json。type 的话指定的是 dockerconfigjson,另外一个我们指定键值对,我们 type 没有指定,默认是 Opaque。键值对是 key:value 的形式,其中对 value 内容进行 base64 加密。创建 Secret 就是这么一个情况。
|
||||
|
||||
Secret 使用
|
||||
|
||||
创建完 Secret 之后,再来看一下如何使用它。它主要是被 pod 来使用,一般是通过 volume 形式挂载到容器里指定的目录,然后容器里的业务进程再到目录下读取 Secret 来进行使用。另外在需要访问私有镜像仓库时,也是通过引用 Secret 来实现。
|
||||
|
||||
|
||||
|
||||
我们先来看一下挂载到用户指定目录的方式:
|
||||
|
||||
|
||||
第一种方式:如上图左侧所示,用户直接指定,把 mysecret 挂载到容器 /etc/foo 目录下面;
|
||||
第二种方式:如上图右侧所示,系统自动生成,把 serviceaccount-secret 自动挂载到容器 /var/run/secrets/kubernetes.io/serviceaccount 目录下,它会生成两个文件,一个是 ca.crt,一个是 token。这是两个保存了认证信息的证书文件。
|
||||
|
||||
|
||||
使用私有镜像库
|
||||
|
||||
下面看一下用 Secret 来使用私有镜像仓库。首先,私有镜像仓库的信息是存储在 Secret 里面的(具体参照上述的Secret创建章节),然后拉取私有仓库镜像,那么通过下图中两种方法的配置就可以:
|
||||
|
||||
|
||||
第一种方式:如下图左侧所示,直接在 pod 里面,通过 imagePullSecrets 字段来配置;
|
||||
第二种方式是自动注入。用户提前在 pod 会使用的 serviceaccount 里配置 imagePullSecrets,Pod创建时系统自动注入这个 imagePullSecrets。
|
||||
|
||||
|
||||
|
||||
|
||||
Secret 使用注意要点
|
||||
|
||||
最后来看一下 Secret 使用的一些注意点,下面列了三点:
|
||||
|
||||
|
||||
第一个是 Secret 的文件大小限制。这个跟 ConfigMap 一样,也是 1MB;
|
||||
第二个是 Secret 采用了 base-64 编码,但是它跟明文也没有太大区别。所以说,如果有一些机密信息要用 Secret 来存储的话,还是要很慎重考虑。也就是说谁会来访问你这个集群,谁会来用你这个 Secret,还是要慎重考虑,因为它如果能够访问这个集群,就能拿到这个 Secret。
|
||||
|
||||
|
||||
如果是对 Secret 敏感信息要求很高,对加密这块有很强的需求,推荐可以使用 Kubernetes 和开源的 vault做一个解决方案,来解决敏感信息的加密和权限管理。
|
||||
|
||||
|
||||
第三个就是 Secret 读取的最佳实践,建议不要用 list/watch,如果用 list/watch 操作的话,会把 namespace 下的所有 Secret 全部拉取下来,这样其实暴露了更多的信息。推荐使用 GET 的方法,这样只获取你自己需要的那个 Secret。
|
||||
|
||||
|
||||
ServiceAccount
|
||||
|
||||
ServiceAccount 介绍
|
||||
|
||||
接下来,我们讲一下 ServiceAccount。ServiceAccount 首先是用于解决 pod 在集群里面的身份认证问题,身份认证信息是存在于 Secret 里面。
|
||||
|
||||
|
||||
|
||||
先看一下上面的左侧截图,可以看到最下面的红框里,有一个 Secret 字段,它指定 ServiceAccount 用哪一个 Secret,这个是 K8s 自动为 ServiceAccount 加上的。然后再来看一下上图中的右侧截图,它对应的 Secret 的 data 里有两块数据,一个是 ca.crt,一个是 token。ca.crt 用于对服务端的校验,token 用于 Pod 的身份认证,它们都是用 base64 编码过的。然后可以看到 metadata 即元信息里,其实是有关联 ServiceAccount 信息的(这个 secret 被哪个 ServiceAccount 使用)。最后我们注意一下 type,这个就是 service-account-token 这种类型。
|
||||
|
||||
举例:Pod 里的应用访问它所属的 K8s 集群
|
||||
|
||||
介绍完 ServiceAccount 以及它对应的 secret 后,我们来看一下,pod 是怎么利用 ServiceAccount 或者说它是怎么利用 secret 来访问所属 K8s 集群的。
|
||||
|
||||
其实 pod 创建的时候,首先它会把这个 secret 挂载到容器固定的目录下,这是 K8s 功能上实现的。它要把这个 ca.crt 和 token 这两个文件挂载到固定目录下面。
|
||||
|
||||
pod 要访问集群的时候,它是怎么来利用这个文件的呢?我们看一下下面的代码截图:
|
||||
|
||||
|
||||
|
||||
我们在 Go 里面实现 Pod 访问 K8s 集群时,一般直接会调一个 InClusterConfig 方法,来生成这个访问服务 Client 的一些信息。然后可以看一下,最后这个 Config 里面有两部分信息:
|
||||
|
||||
|
||||
一个是 tlsClientConfig,这个主要是用于 ca.crt 校验服务端;
|
||||
第二个是 Bearer Token,这个就是 pod 的身份认证。在服务端,会利用 token 对 pod 进行一个身份认证。
|
||||
|
||||
|
||||
再次回到上图左侧。认证完之后 pod 的身份信息会有两部分:一个是 Group,一个是 User。身份认证是就是认证这两部分信息。接着可以使用 RBAC 功能,对 pod 进行一个授权管理。
|
||||
|
||||
假如 RBAC 没有配置的话,默认的 pod 具有资源 GET 权限,就是可以从所属的 K8s 集群里 get 数据。如果是需要更多的权限,那么就需要 自行配置 RBAC 。RBAC 的相关知识,我们在后面的课程里面会详细介绍,大家可以关注一下。
|
||||
|
||||
Resource
|
||||
|
||||
容器资源配合管理
|
||||
|
||||
下面介绍一下 Resource,即:容器的一个资源配置管理。
|
||||
|
||||
目前内部支持类型有三种:CPU、内存,以及临时存储。当用户觉得这三种不够,有自己的一些资源,比如说 GPU,或者其他资源,也可以自己来定义,但配置时,指定的数量必须为整数。目前资源配置主要分成 request 和 limit 两种类型,一个是需要的数量,一个是资源的界限。CPU、内存以及临时存储都是在 container 下的 Resource 字段里进行一个声明。
|
||||
|
||||
|
||||
|
||||
举个例子,wordpress 容器的资源需求,一个是 request ,一个是 limits,它分别对需要的资源和资源临界进行一个声明。
|
||||
|
||||
Pod 服务质量 (QoS) 配置
|
||||
|
||||
根据 CPU 对容器内存资源的需求,我们对 pod 的服务质量进行一个分类,分别是 Guaranteed、Burstable 和 BestEffort。
|
||||
|
||||
|
||||
Guaranteed :pod 里面每个容器都必须有内存和 CPU 的 request 以及 limit 的一个声明,且 request 和 limit 必须是一样的,这就是 Guaranteed;
|
||||
Burstable:Burstable 至少有一个容器存在内存和 CPU 的一个 request;
|
||||
BestEffort:只要不是 Guaranteed 和 Burstable,那就是 BestEffort。
|
||||
|
||||
|
||||
那么这个服务质量是什么样的呢?资源配置好后,当这个节点上 pod 容器运行,比如说节点上 memory 配额资源不足,kubelet会把一些低优先级的,或者说服务质量要求不高的(如:BestEffort、Burstable)pod 驱逐掉。它们是按照先去除 BestEffort,再去除 Burstable 的一个顺序来驱逐 pod 的。
|
||||
|
||||
SecurityContext
|
||||
|
||||
SecurityContext 介绍
|
||||
|
||||
SecurityContext 主要是用于限制容器的一个行为,它能保证系统和其他容器的安全。这一块的能力不是 Kubernetes 或者容器 runtime 本身的能力,而是 Kubernetes 和 runtime 通过用户的配置,最后下传到内核里,再通过内核的机制让 SecurityContext 来生效。所以这里讲的内容,会比较简单或者说比较抽象一点。
|
||||
|
||||
SecurityContext 主要分为三个级别:
|
||||
|
||||
|
||||
第一个是容器级别,仅对容器生效;
|
||||
第二个是 pod 级别,对 pod 里所有容器生效;
|
||||
第三个是集群级别,就是 PSP,对集群内所有 pod 生效。
|
||||
|
||||
|
||||
权限和访问控制设置项,现在一共列有七项(这个数量后续可能会变化):
|
||||
|
||||
|
||||
第一个就是通过用户 ID 和组 ID 来控制文件访问权限;
|
||||
第二个是 SELinux,它是通过策略配置来控制用户或者进程对文件的访问控制;
|
||||
第三个是特权容器;
|
||||
第四个是 Capabilities,它也是给特定进程来配置一个 privileged 能力;
|
||||
第五个是 AppArmor,它也是通过一些配置文件来控制可执行文件的一个访问控制权限,比如说一些端口的读写;
|
||||
第六个是一个对系统调用的控制;
|
||||
第七个是对子进程能否获取比父亲更多的权限的一个限制。
|
||||
|
||||
|
||||
最后其实都是落到内核来控制它的一些权限。
|
||||
|
||||
|
||||
|
||||
上图是对 pod 级别和容器级别配置 SecurityContext 的一个例子,如果大家对这些内容有更多的需求,可以根据这些信息去搜索更深入的资料来学习。
|
||||
|
||||
InitContainer
|
||||
|
||||
InitContainer 介绍
|
||||
|
||||
接下来看一下 InitContainer,首先介绍 InitContainer 和普通 container 的区别,有以下三点内容:
|
||||
|
||||
|
||||
InitContainer 首先会比普通 container 先启动,并且直到所有的 InitContainer 执行成功后,普通 container 才会被启动;
|
||||
InitContainer 之间是按定义的次序去启动执行的,执行成功一个之后再执行第二个,而普通的 container 是并发启动的;
|
||||
InitContainer 执行成功后就结束退出,而普通容器可能会一直在执行。它可能是一个 longtime 的,或者说失败了会重启,这个也是 InitContainer 和普通 container 不同的地方。
|
||||
|
||||
|
||||
根据上面三点内容,我们看一下 InitContainer 的一个用途。它其实主要为普通 container 服务,比如说它可以为普通 container 启动之前做一个初始化,或者为它准备一些配置文件, 配置文件可能是一些变化的东西。再比如做一些前置条件的校验,如网络是否联通。
|
||||
|
||||
|
||||
|
||||
上面的截图是 flannel 组件的 InitContainer 的一个配置,它的 InitContainer 主要是为 kube-flannel 这个普通容器启动之前准备一些网络配置文件。
|
||||
|
||||
结束语
|
||||
|
||||
|
||||
ConfigMap 和 Secret: 首先介绍了 ConfigMap 和 Secret 的创建方法和使用场景,然后对 ConfigMap 和 Secret 的常见使用注意点进行了分类和整理。最后介绍了私有仓库镜像的使用和配置;
|
||||
Pod 身份认证: 首先介绍了 ServiceAccount 和 Secret 的关联关系,然后从源码角度对 Pod 身份认证流程和实现细节进行剖析,同时引出了 Pod 的权限管理(即 RBAC 的配置管理);
|
||||
容器资源和安全: 首先介绍了容器常见资源类型 (CPU/Memory) 的配置,然后对 Pod 服务质量分类进行详细的介绍。同时对 SecurityContext 有效层级和权限配置项进行简要说明;
|
||||
InitContainer: 首先介绍了 InitContainer 和普通 container 的区别以及 InitContainer 的用途。然后基于实际用例对 InitContainer 的用途进行了说明。
|
||||
|
||||
|
||||
好的,我们今天的内容讲到这里,谢谢大家。
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user