Jekyll2022-05-12T14:50:39+08:00www.sindrilin.com/feed.xmlsindrilin的小巢本站记录我在学习之旅的沿途风景sindrilinsindrilin@foxmail.com图形学笔记-光照2022-05-12T08:00:00+08:002022-05-12T08:00:00+08:00www.sindrilin.com/2022/05/12/graphics_light<h2 id="开篇">开篇</h2>
<blockquote>
<p>计算机图形学(Computer Graphics)是一种使用数学算法将二维或三维图形转换为计算机显示器的栅格形式的科学</p>
</blockquote>
<p>本文为学习图形学过程中,使用<code class="language-plaintext highlighter-rouge">iOS</code>平台开发语言<strong>模拟图像渲染和变化</strong>的笔记</p>
<h2 id="系列往期">系列往期</h2>
<p><a href="http://sindrilin.com/2022/05/02/graphics_pixels_render.html">图形学笔记-渲染</a></p>
<p><a href="http://sindrilin.com/2022/05/05/graphics_transform.html">图形学笔记-变换</a></p>
<h2 id="物体显色">物体显色</h2>
<p>自然光是由多种不同波长的光组成的,通过凸透镜等工具折射反射后,会呈现出不同波长的光,表现为不同的颜色,例如彩虹形成的七种颜色就是自然光折射呈七种不同波长的光线进入肉眼后看到的。由于不同物质会吸收不同波长的光,反射其不能吸收的光,反射的可见光就被会被当做该物体的颜色。打个比方,使用<code class="language-plaintext highlighter-rouge">RGB</code>颜色系统,自然光可以认为是白色,其表示为</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lightColor = RGB(1, 1, 1)
</code></pre></div></div>
<p>一个看起来是红色的物体,自然光在入射到物体上时,发生了光线的吸收和反射,将这一过程的用代码可以描述成</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation RedItem
- (RGBColor)absorbLight:(RGBColor)lightColor {
RGBColor itemColor = lightColor;
itemColor.green = 0;
itemColor.blue = 0;
return itemColor;
}
@end
</code></pre></div></div>
<p>描述物体吸收自然光的过程并不直观,更好的方式是去计算物体反射的部分,用代码描述就可以表示为</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation RedItem
- (instancetype)init {
if (self = [super init]) {
self.itemColor = RGBColor(1, 0, 0);
}
return self;
}
- (RGBColor)absorbLight:(RGBColor)lightColor {
return self.itemColor * lightColor;
}
@end
</code></pre></div></div>
<h2 id="光照意义">光照意义</h2>
<p>在不考虑自然光的情况下,日常中使用的光线是随距离衰减的,比如手电筒、手机闪光灯等,光照的意义之一在于帮助人类估算距离,距离更远的物体必然色彩更不明显;反之如果没有合适的光照,会有错误的视觉效果</p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3ade28f34d8e46a1b7833c51fa12f9a5~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>以上一篇《变换》做距离,如下图,垂直方向上为沿着<code class="language-plaintext highlighter-rouge">x</code>轴每次向上旋转<code class="language-plaintext highlighter-rouge">30°</code>,水平方向上为沿着<code class="language-plaintext highlighter-rouge">y</code>轴每次向左旋转<code class="language-plaintext highlighter-rouge">30°</code>。在不考虑透视的情况下,图像和向下旋转、向右旋转相同角度也是一样的,甚至还可以被看做只是<code class="language-plaintext highlighter-rouge">x</code>轴和<code class="language-plaintext highlighter-rouge">y</code>轴上的缩放。因此如果能模拟</p>
<h2 id="光线计算">光线计算</h2>
<p>本文谈及的<code class="language-plaintext highlighter-rouge">点光源</code>和<code class="language-plaintext highlighter-rouge">球光源</code>是基于模拟光照计算的描述,并不指代其原含义(ps:命名请勿较真)</p>
<h3 id="点光源">点光源</h3>
<p>点光源是一种光线强度只和入射角度有关,当入射光线与物体面垂直(<code class="language-plaintext highlighter-rouge">x</code>与<code class="language-plaintext highlighter-rouge">y</code>轴坐标相等时)时,光线强度最高</p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/98f47126b082489fb0abdb64ae44f90e~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>由图可以看出点光源的计算非常简单,即求点光源到入射点夹角的余弦<code class="language-plaintext highlighter-rouge">cosθ</code>即可,在给定了点光源坐标点<code class="language-plaintext highlighter-rouge">lightPosition</code>和入射点坐标<code class="language-plaintext highlighter-rouge">pixelPosition</code>的情况下,入射光强度计算为</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RGBColor calculateColor(RGBColor color, Position lightPosition, Position pixelPosition) {
Position referencePosition = MakePosition(lightPosition.x, lightPosition.y, pixelPosition.z);
float referenceDistance = PositionDistance(referencePosition, lightPosition);
float pixelDistance = PositionDistance(pixelPosition, lightPosition);
return color * (referenceDistance / pixelDistance);
}
</code></pre></div></div>
<p>通过添加点光源,就能明显感受到旋转感</p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/540c742be73747cbb94bad606b20584c~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<h3 id="球光源">球光源</h3>
<p>将光源看做是一个球体,光线不断的向外扩散,光线从光源中心出发后,在不同时刻形成了以光源为中心的一个个球体,球体的表面积<code class="language-plaintext highlighter-rouge">S=4πr²</code>看做是形成球体的光线强度的总和</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a8bad0d30aae473c97690ecf5aa42ffc~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>以光线到光源中心的距离生成半径为<code class="language-plaintext highlighter-rouge">R</code>的球体,任意扩散两个时间点<code class="language-plaintext highlighter-rouge">t1</code>和<code class="language-plaintext highlighter-rouge">t2</code>,分别形成的光线球体的强度和<code class="language-plaintext highlighter-rouge">S1</code>和<code class="language-plaintext highlighter-rouge">S2</code>是相等的。基于这个理论,计算像素点的光照强度时,可以换算成计算像素点到光源的距离形成的<code class="language-plaintext highlighter-rouge">R</code>的光球表面单个点的能量。为了便于计算,我们需要引入一些变量</p>
<ul>
<li>标准光强距离<code class="language-plaintext highlighter-rouge">Intensity</code>:表示球体表面单个点光线强度为<code class="language-plaintext highlighter-rouge">1</code>时的<code class="language-plaintext highlighter-rouge">R</code>值,光球强度总和为<code class="language-plaintext highlighter-rouge">4πI²</code></li>
</ul>
<p>基于标准值,可以得到任意像素点受到的光照强度计算</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation SDLLightSource
- (void)setIntensity:(CGFloat)intensity {
_intensity = intensity;
self.totalPower = 4 * M_PI * intensity * intensity;
}
- (CGFloat)lightIntensityAtPosition:(Position)position {
float distance = PositionDistance(self.position, poisition);
return self.totalPower / (4 * M_PI * distance * distance);
}
@end
</code></pre></div></div>
<p>通过计算公式可以看出球光源单点的光线强度随距离平方衰减,比点光源计算量也要大,但换来了更真实的光照效果</p>
<p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e1fa13ddb76348dba87cfcabe5d7b794~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<h3 id="光照颜色">光照颜色</h3>
<p>有时候光源不一定标准的<code class="language-plaintext highlighter-rouge">RGB(1, 1, 1)</code>的白色,但无论光源颜色是否为白色,都不影响效果</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1161bc3580b0478caf2c68ccb9ffb2f6~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<h2 id="半透明照射">半透明照射</h2>
<p>上面采用的例子最顶层的图像都是非透明的,现在试着把大黄脸改成<code class="language-plaintext highlighter-rouge">60%</code>的透明度</p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/290ce7dc0cd0497b88fe64d7c12b5254~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>看起来还可以,但实际上是不对的,上面两个图像分别和光源做了单独的光照计算后再进行像素混合计算,这个过程忽略了一个问题,如下图所示,光线穿过半透明的大黄脸后,必然会被吸收掉一部分,那么穿过大黄脸的光线颜色是什么?</p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/53c708fe9f354b8c9c812488e8801d97~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>读书的时候有个小游戏,给一张半透明的红色纸片,和一张黄色的纸张,打开手电筒让光线穿过红色纸片照射到黄色纸片上,最终会在黄色纸片上出现橙色</p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/467b613a46b649c5a80c6b832f0e2f29~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>由此可以假设一些规则</p>
<ol>
<li>光线穿过半透明物体时,物体的透明度为<code class="language-plaintext highlighter-rouge">alpha</code>,穿透的光线比例为<code class="language-plaintext highlighter-rouge">1-alpha</code></li>
<li>穿透色必定为物体不可吸收的颜色(物体反射色)</li>
</ol>
<p>假设<code class="language-plaintext highlighter-rouge">A</code>像素点是半透明像素点,<code class="language-plaintext highlighter-rouge">B</code>是在<code class="language-plaintext highlighter-rouge">A</code>像素点后方的像素点,可以得到</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>B = A * lightColor * (1 - A.alpha)
</code></pre></div></div>
<h3 id="效果">效果</h3>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7f783be785824088a8b9678f0d7b36e3~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>sindrilinsindrilin@foxmail.com开篇 计算机图形学(Computer Graphics)是一种使用数学算法将二维或三维图形转换为计算机显示器的栅格形式的科学 本文为学习图形学过程中,使用iOS平台开发语言模拟图像渲染和变化的笔记 系列往期 图形学笔记-渲染 图形学笔记-变换 物体显色 自然光是由多种不同波长的光组成的,通过凸透镜等工具折射反射后,会呈现出不同波长的光,表现为不同的颜色,例如彩虹形成的七种颜色就是自然光折射呈七种不同波长的光线进入肉眼后看到的。由于不同物质会吸收不同波长的光,反射其不能吸收的光,反射的可见光就被会被当做该物体的颜色。打个比方,使用RGB颜色系统,自然光可以认为是白色,其表示为 lightColor = RGB(1, 1, 1) 一个看起来是红色的物体,自然光在入射到物体上时,发生了光线的吸收和反射,将这一过程的用代码可以描述成 @implementation RedItem - (RGBColor)absorbLight:(RGBColor)lightColor { RGBColor itemColor = lightColor; itemColor.green = 0; itemColor.blue = 0; return itemColor; } @end 描述物体吸收自然光的过程并不直观,更好的方式是去计算物体反射的部分,用代码描述就可以表示为 @implementation RedItem - (instancetype)init { if (self = [super init]) { self.itemColor = RGBColor(1, 0, 0); } return self; } - (RGBColor)absorbLight:(RGBColor)lightColor { return self.itemColor * lightColor; } @end 光照意义 在不考虑自然光的情况下,日常中使用的光线是随距离衰减的,比如手电筒、手机闪光灯等,光照的意义之一在于帮助人类估算距离,距离更远的物体必然色彩更不明显;反之如果没有合适的光照,会有错误的视觉效果 以上一篇《变换》做距离,如下图,垂直方向上为沿着x轴每次向上旋转30°,水平方向上为沿着y轴每次向左旋转30°。在不考虑透视的情况下,图像和向下旋转、向右旋转相同角度也是一样的,甚至还可以被看做只是x轴和y轴上的缩放。因此如果能模拟 光线计算 本文谈及的点光源和球光源是基于模拟光照计算的描述,并不指代其原含义(ps:命名请勿较真) 点光源 点光源是一种光线强度只和入射角度有关,当入射光线与物体面垂直(x与y轴坐标相等时)时,光线强度最高 由图可以看出点光源的计算非常简单,即求点光源到入射点夹角的余弦cosθ即可,在给定了点光源坐标点lightPosition和入射点坐标pixelPosition的情况下,入射光强度计算为 RGBColor calculateColor(RGBColor color, Position lightPosition, Position pixelPosition) { Position referencePosition = MakePosition(lightPosition.x, lightPosition.y, pixelPosition.z); float referenceDistance = PositionDistance(referencePosition, lightPosition); float pixelDistance = PositionDistance(pixelPosition, lightPosition); return color * (referenceDistance / pixelDistance); } 通过添加点光源,就能明显感受到旋转感 球光源 将光源看做是一个球体,光线不断的向外扩散,光线从光源中心出发后,在不同时刻形成了以光源为中心的一个个球体,球体的表面积S=4πr²看做是形成球体的光线强度的总和 以光线到光源中心的距离生成半径为R的球体,任意扩散两个时间点t1和t2,分别形成的光线球体的强度和S1和S2是相等的。基于这个理论,计算像素点的光照强度时,可以换算成计算像素点到光源的距离形成的R的光球表面单个点的能量。为了便于计算,我们需要引入一些变量 标准光强距离Intensity:表示球体表面单个点光线强度为1时的R值,光球强度总和为4πI² 基于标准值,可以得到任意像素点受到的光照强度计算 @implementation SDLLightSource - (void)setIntensity:(CGFloat)intensity { _intensity = intensity; self.totalPower = 4 * M_PI * intensity * intensity; } - (CGFloat)lightIntensityAtPosition:(Position)position { float distance = PositionDistance(self.position, poisition); return self.totalPower / (4 * M_PI * distance * distance); } @end 通过计算公式可以看出球光源单点的光线强度随距离平方衰减,比点光源计算量也要大,但换来了更真实的光照效果 光照颜色 有时候光源不一定标准的RGB(1, 1, 1)的白色,但无论光源颜色是否为白色,都不影响效果 半透明照射 上面采用的例子最顶层的图像都是非透明的,现在试着把大黄脸改成60%的透明度 看起来还可以,但实际上是不对的,上面两个图像分别和光源做了单独的光照计算后再进行像素混合计算,这个过程忽略了一个问题,如下图所示,光线穿过半透明的大黄脸后,必然会被吸收掉一部分,那么穿过大黄脸的光线颜色是什么? 读书的时候有个小游戏,给一张半透明的红色纸片,和一张黄色的纸张,打开手电筒让光线穿过红色纸片照射到黄色纸片上,最终会在黄色纸片上出现橙色 由此可以假设一些规则 光线穿过半透明物体时,物体的透明度为alpha,穿透的光线比例为1-alpha 穿透色必定为物体不可吸收的颜色(物体反射色) 假设A像素点是半透明像素点,B是在A像素点后方的像素点,可以得到 B = A * lightColor * (1 - A.alpha) 效果图形学笔记-变换2022-05-05T08:00:00+08:002022-05-05T08:00:00+08:00www.sindrilin.com/2022/05/05/graphics_transform<h2 id="开篇">开篇</h2>
<blockquote>
<p>计算机图形学(Computer Graphics)是一种使用数学算法将二维或三维图形转换为计算机显示器的栅格形式的科学</p>
</blockquote>
<p>本文为学习图形学过程中,使用<code class="language-plaintext highlighter-rouge">iOS</code>平台开发语言<strong>模拟图像渲染和变化</strong>的笔记</p>
<h2 id="系列往期">系列往期</h2>
<p><a href="http://sindrilin.com/2022/05/02/graphics_pixels_render.html">图形学笔记-渲染</a></p>
<h2 id="欧拉角">欧拉角</h2>
<p>欧拉角表示三维空间中可以任意旋转的<code class="language-plaintext highlighter-rouge">3</code>个值,分别是俯仰角(<code class="language-plaintext highlighter-rouge">Pitch</code>)、偏航角(<code class="language-plaintext highlighter-rouge">Yaw</code>)和滚转角(<code class="language-plaintext highlighter-rouge">Roll</code>),可以认为是物体基于自身坐标系分别沿着<code class="language-plaintext highlighter-rouge">X</code>轴、<code class="language-plaintext highlighter-rouge">Y</code>轴和<code class="language-plaintext highlighter-rouge">Z</code>轴做旋转的变换</p>
<p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/acc6a4fa53bc403685f9da633d0dd0d8~tplv-k3u1fbpfcp-zoom-1.image" alt="" /></p>
<h2 id="变换矩阵">变换矩阵</h2>
<p>向量<code class="language-plaintext highlighter-rouge">AB</code>旋转<code class="language-plaintext highlighter-rouge">β</code>角度到了<code class="language-plaintext highlighter-rouge">AB'</code>的位置,可以得到</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>B'.x = |AB| * cos(α+β) = |AB| * (cosα*cosβ - sinα*sinβ) = B.x * cosβ - B.y * sinβ
B'.y = |AB| * sin(α+β) = |AB| * (cosα*sinβ + sinα*cosβ) = B.x * sinβ + B.y * cosβ
</code></pre></div></div>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4ab78d1b6bc043af976848ef45f1b01a~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>可以得到右手坐标系下的旋转矩阵:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#define DEGRESS_TO_RADIANS($degress) (($degress) / (180.0 / M_PI))
#define MATRIX_SIZE 4
typedef float Matrix4[MATRIX_SIZE][MATRIX_SIZE];
void rightHandTransformMatrix(Matrix4 matrix, RotateAxis axis, float angle) {
switch (axis) {
case X:
matrix[1][1] = cos(DEGRESS_TO_RADIANS(angle));
matrix[1][2] = -sin(DEGRESS_TO_RADIANS(angle));
matrix[2][1] = sin(DEGRESS_TO_RADIANS(angle));
matrix[2][2] = cos(DEGRESS_TO_RADIANS(angle));
break;
case Y:
matrix[0][0] = cos(DEGRESS_TO_RADIANS(angle));
matrix[0][2] = sin(DEGRESS_TO_RADIANS(angle));
matrix[2][0] = -sin(DEGRESS_TO_RADIANS(angle));
matrix[2][2] = cos(DEGRESS_TO_RADIANS(angle));
break;
case Z:
matrix[0][0] = cos(DEGRESS_TO_RADIANS(angle));
matrix[0][1] = -sin(DEGRESS_TO_RADIANS(angle));
matrix[1][0] = sin(DEGRESS_TO_RADIANS(angle));
matrix[1][1] = cos(DEGRESS_TO_RADIANS(angle));
break;
}
}
</code></pre></div></div>
<p>由于<code class="language-plaintext highlighter-rouge">iOS</code>设备采用的是左上角坐标原点,<code class="language-plaintext highlighter-rouge">y</code>轴向下增加,因此适用的是左手坐标系,将变换矩阵的<code class="language-plaintext highlighter-rouge">sin</code>取反即完成适配</p>
<h2 id="深度">深度</h2>
<p>三维坐标系中,通常<code class="language-plaintext highlighter-rouge">z</code>轴代表了物体的距离视角的距离信息,<code class="language-plaintext highlighter-rouge">z</code>轴数值越大,表示越接近显示屏幕。在下图有过<code class="language-plaintext highlighter-rouge">ABC</code>三个点形成的平面,和过<code class="language-plaintext highlighter-rouge">DEF</code>三个点形成的平面,由于<code class="language-plaintext highlighter-rouge">ABC</code>平面的<code class="language-plaintext highlighter-rouge">z</code>值大于<code class="language-plaintext highlighter-rouge">DEF</code>平面,由于<code class="language-plaintext highlighter-rouge">ABC</code>平面非透明,所以<code class="language-plaintext highlighter-rouge">DEF</code>不会被显示</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/64287887d2124128a0a4beebe0027f26~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>当<code class="language-plaintext highlighter-rouge">ABC</code>平面沿着<code class="language-plaintext highlighter-rouge">x</code>轴旋转后,直接的观感就是整个平面的高度坍缩。假如把坐标轴的刻度当做屏幕渲染点(<code class="language-plaintext highlighter-rouge">dp</code>),就可以当做旋转发生后,单个<code class="language-plaintext highlighter-rouge">dp</code>容纳了比原图更多的像素,最终<code class="language-plaintext highlighter-rouge">dp</code>渲染的颜色由深度(<code class="language-plaintext highlighter-rouge">z</code>轴数值)更大的像素表示</p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c967184947954380897b24e1cb1c50e7~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<h2 id="变换实现">变换实现</h2>
<p>针对变换实现,加入了<code class="language-plaintext highlighter-rouge">Position</code>的三维坐标模型,和<code class="language-plaintext highlighter-rouge">SDLTransform</code>用来做矩阵变换的工具类</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>typedef struct Position {
float x;
float y;
float z;
} Position;
extern Position MakePosition(float x, float y, float z);
@interface SDLTransform : NSObject
/// 坐标轴的旋转角度
@property (nonatomic, assign) CGFloat pitch;
@property (nonatomic, assign) CGFloat yaw;
@property (nonatomic, assign) CGFloat roll;
/// 平移
@property (nonatomic, assign) Position translation;
@end
</code></pre></div></div>
<h3 id="变换顺序">变换顺序</h3>
<p>对于要渲染的内容分别先做平移、然后旋转,或是先旋转、再旋转的两种处理最终的计算结果完全不同,借用<a href="https://www.bilibili.com/video/BV1X7411F744?p=3">GAMES 101</a>课程的截图说明这两种先后顺序最终的变换结果</p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/71d5c504b3aa4df09ac39b3ced548a22~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b9df3787fa8f4b699bf7b79158fd0cf6~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<p>因此在做变换之前,必须先将渲染图像修正到正确的中心坐标再做处理,才能得到预期的结果:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation SDLTransform
- (Position)transformCoordinate:(Position)coordinate origin:(Position)origin {
coordinate = MinusPosition(coordinate, origin);
if (self.pitch != NONE_PITCH) {
Matrix4 mat4;
rotateMatrix(mat4, X, self.pitch);
[self transformPosition:coordinate mat:mat4];
}
if (self.yaw != NONE_YAW) {
// Y轴旋转
}
// Z轴旋转 & 平移
return AddPosition(coordinate, origin);
}
@end
</code></pre></div></div>
<h3 id="点渲染">点渲染</h3>
<p>由于旋转后一个屏幕渲染点(<code class="language-plaintext highlighter-rouge">dp</code>)可以容纳多个像素,如果存在不透明的像素,那么所有<code class="language-plaintext highlighter-rouge">z</code>轴数值低于这个像素的其他像素都可以不做渲染</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation SDLRenderPoint
- (void)appendPixel:(RGBAColor)color depth:(float)depth {
NSInteger depthIndex = [self insertIndexOf:depth];
if (color.alpha == NONE_ALPHA) {
[self removeAllPixelsAfterDepth:depth];
}
[self insertPixel:(RGBAColor)color atIndex:depthIndex];
}
@end
</code></pre></div></div>
<p>最终是单个渲染点的颜色混合计算</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation SDLRenderPoint
- (RGBAColor)renderColor {
if ([self isEmpty]) {
return clearColor();
}
return [self mixedPixelsWithOptions:SDLMixedReverse:usingBlock:^RGBAColor(RGBAColor current, RGBAColor previous) {
return [self mixedForegroundColor:current backgroundColor:previous];
}];
}
@end
</code></pre></div></div>
<h3 id="效果">效果</h3>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c4179fa120614b7eb90286e6d5e34067~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>sindrilinsindrilin@foxmail.com开篇 计算机图形学(Computer Graphics)是一种使用数学算法将二维或三维图形转换为计算机显示器的栅格形式的科学 本文为学习图形学过程中,使用iOS平台开发语言模拟图像渲染和变化的笔记 系列往期 图形学笔记-渲染 欧拉角 欧拉角表示三维空间中可以任意旋转的3个值,分别是俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),可以认为是物体基于自身坐标系分别沿着X轴、Y轴和Z轴做旋转的变换 变换矩阵 向量AB旋转β角度到了AB'的位置,可以得到 B'.x = |AB| * cos(α+β) = |AB| * (cosα*cosβ - sinα*sinβ) = B.x * cosβ - B.y * sinβ B'.y = |AB| * sin(α+β) = |AB| * (cosα*sinβ + sinα*cosβ) = B.x * sinβ + B.y * cosβ 可以得到右手坐标系下的旋转矩阵: #define DEGRESS_TO_RADIANS($degress) (($degress) / (180.0 / M_PI)) #define MATRIX_SIZE 4 typedef float Matrix4[MATRIX_SIZE][MATRIX_SIZE]; void rightHandTransformMatrix(Matrix4 matrix, RotateAxis axis, float angle) { switch (axis) { case X: matrix[1][1] = cos(DEGRESS_TO_RADIANS(angle)); matrix[1][2] = -sin(DEGRESS_TO_RADIANS(angle)); matrix[2][1] = sin(DEGRESS_TO_RADIANS(angle)); matrix[2][2] = cos(DEGRESS_TO_RADIANS(angle)); break; case Y: matrix[0][0] = cos(DEGRESS_TO_RADIANS(angle)); matrix[0][2] = sin(DEGRESS_TO_RADIANS(angle)); matrix[2][0] = -sin(DEGRESS_TO_RADIANS(angle)); matrix[2][2] = cos(DEGRESS_TO_RADIANS(angle)); break; case Z: matrix[0][0] = cos(DEGRESS_TO_RADIANS(angle)); matrix[0][1] = -sin(DEGRESS_TO_RADIANS(angle)); matrix[1][0] = sin(DEGRESS_TO_RADIANS(angle)); matrix[1][1] = cos(DEGRESS_TO_RADIANS(angle)); break; } } 由于iOS设备采用的是左上角坐标原点,y轴向下增加,因此适用的是左手坐标系,将变换矩阵的sin取反即完成适配 深度 三维坐标系中,通常z轴代表了物体的距离视角的距离信息,z轴数值越大,表示越接近显示屏幕。在下图有过ABC三个点形成的平面,和过DEF三个点形成的平面,由于ABC平面的z值大于DEF平面,由于ABC平面非透明,所以DEF不会被显示 当ABC平面沿着x轴旋转后,直接的观感就是整个平面的高度坍缩。假如把坐标轴的刻度当做屏幕渲染点(dp),就可以当做旋转发生后,单个dp容纳了比原图更多的像素,最终dp渲染的颜色由深度(z轴数值)更大的像素表示 变换实现 针对变换实现,加入了Position的三维坐标模型,和SDLTransform用来做矩阵变换的工具类 typedef struct Position { float x; float y; float z; } Position; extern Position MakePosition(float x, float y, float z); @interface SDLTransform : NSObject /// 坐标轴的旋转角度 @property (nonatomic, assign) CGFloat pitch; @property (nonatomic, assign) CGFloat yaw; @property (nonatomic, assign) CGFloat roll; /// 平移 @property (nonatomic, assign) Position translation; @end 变换顺序 对于要渲染的内容分别先做平移、然后旋转,或是先旋转、再旋转的两种处理最终的计算结果完全不同,借用GAMES 101课程的截图说明这两种先后顺序最终的变换结果 因此在做变换之前,必须先将渲染图像修正到正确的中心坐标再做处理,才能得到预期的结果: @implementation SDLTransform - (Position)transformCoordinate:(Position)coordinate origin:(Position)origin { coordinate = MinusPosition(coordinate, origin); if (self.pitch != NONE_PITCH) { Matrix4 mat4; rotateMatrix(mat4, X, self.pitch); [self transformPosition:coordinate mat:mat4]; } if (self.yaw != NONE_YAW) { // Y轴旋转 } // Z轴旋转 & 平移 return AddPosition(coordinate, origin); } @end 点渲染 由于旋转后一个屏幕渲染点(dp)可以容纳多个像素,如果存在不透明的像素,那么所有z轴数值低于这个像素的其他像素都可以不做渲染 @implementation SDLRenderPoint - (void)appendPixel:(RGBAColor)color depth:(float)depth { NSInteger depthIndex = [self insertIndexOf:depth]; if (color.alpha == NONE_ALPHA) { [self removeAllPixelsAfterDepth:depth]; } [self insertPixel:(RGBAColor)color atIndex:depthIndex]; } @end 最终是单个渲染点的颜色混合计算 @implementation SDLRenderPoint - (RGBAColor)renderColor { if ([self isEmpty]) { return clearColor(); } return [self mixedPixelsWithOptions:SDLMixedReverse:usingBlock:^RGBAColor(RGBAColor current, RGBAColor previous) { return [self mixedForegroundColor:current backgroundColor:previous]; }]; } @end 效果图形学笔记-渲染2022-05-02T08:00:00+08:002022-05-02T08:00:00+08:00www.sindrilin.com/2022/05/02/graphics_render<h2 id="开篇">开篇</h2>
<blockquote>
<p>计算机图形学(Computer Graphics)是一种使用数学算法将二维或三维图形转换为计算机显示器的栅格形式的科学</p>
</blockquote>
<p>本文为学习图形学过程中,使用<code class="language-plaintext highlighter-rouge">iOS</code>平台开发语言<strong>模拟图像渲染和变化</strong>的笔记</p>
<h2 id="像素渲染">像素渲染</h2>
<p>将像素展示到屏幕上需要借助<code class="language-plaintext highlighter-rouge">CoreGraphics</code>的接口,假设需要渲染<code class="language-plaintext highlighter-rouge">width * height</code>的图像,需要先分配色彩空间为<code class="language-plaintext highlighter-rouge">ARGB</code>的位图</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#define BYTES_PER_RGB 4
typedef struct SDLBitmapContext {
CGSize size;
uint32_t *buffer;
CGContextRef cgContext;
CGColorSpaceRef colorSpace;
} SDLBitmapContext;
SDLBitmapContext sdlCreateArgbBitmapContext(size_t width, size_t height) {
SDLBitmapContext context = identityBitmapContext();
size_t bytesPerRow = width * BYTES_PER_RGB;
size_t bytesCount = bytesPerRow * height;
context.size = CGSizeMake(width, height);
context.colorSpace = CGColorSpaceCreateDeviceRGB();
if (context.colorSpace == NULL) {
return identityBitmapContext();
}
context.buffer = (uint32_t *)malloc((unsigned long)bytesCount);
if (context.buffer == NULL) {
sdlFreeBitmapContext(&context);
return identityBitmapContext();
}
memset(context.buffer, 0x0, bytesCount);
context.cgContext = CGBitmapContextCreate(context.buffer, width, height, 8, bytesPerRow, context.colorSpace, kCGImageAlphaPremultipliedFirst);
if (context.cgContext == NULL) {
sdlFreeBitmapContext(&context);
return identityBitmapContext();
}
return context;
}
</code></pre></div></div>
<p>在分配完位图后,遍历位图的内存将像素数据写入<code class="language-plaintext highlighter-rouge">buffer</code>中</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>enum {
ALPHA = 0,
RED = 1,
GREEN = 2,
BLUE = 3,
};
void sdlForeachBitmapContext(SDLBitmapContext context, SDLBitmapPixelBody body) {
uint32_t *imageBuffer = CGBitmapContextGetData(context.cgContext);
if (imageBuffer == NULL) {
return;
}
for (size_t row = 0; row < context.size.width; row++) {
for (size_t column = 0; column < context.size.height; column++) {
uint8_t *pixelPtr = (uint8_t *)imageBuffer;
body(pixelPtr, row, column);
}
}
}
sdlForeachBitmapContext(context, ^(uint8_t *pixelPtr, size_t row, size_t column) {
RGBAColor color = randomColor();
pixelPtr[ALPHA] = color.alpha;
pixelPtr[RED] = color.red;
pixelPtr[GREEN] = color.green;
pixelPtr[BLUE] = color.blue;
});
</code></pre></div></div>
<p>最后从位图上下文取出图片即可展示</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UIImage *sdlGenerateImage(SDLBitmapContext context) {
if (!isBitmapContextValid(context)) {
return nil;
}
CGImageRef imageRef = CGBitmapContextCreateImage(context.cgContext);
UIImage *image = [UIImage imageWithCGImage:imageRef];
CGImageRelease(imageRef);
return image;
}
- (void)renderImage:(SDLBitmapContext context) {
UIImage *image = sdlGenerateImage(context);
self.renderImageView.image = image;
}
</code></pre></div></div>
<p>随机像素渲染效果</p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d5af3cb786ee42129af736244c5384a8~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<h2 id="图片渲染">图片渲染</h2>
<p>图片的渲染和随机像素渲染的过程相似,同样需要生成<code class="language-plaintext highlighter-rouge">width * height</code>大小尺寸的位图,然后将图片写入到位图中,这次使用一个<code class="language-plaintext highlighter-rouge">PixelsStorage</code>模型来存储所有的像素信息</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@interface SDLPixelsStorage : NSObject
/// 图片尺寸
@property (nonatomic, readonly) CGSize imageSize;
/// 像素存储buffer
@property (nonatomic, readonly) RGBAColor *pixelColors;
/// 生成指定大小的像素缓存区
- (void)createImageWithSize:(CGSize)size;
/// 遍历所有像素点
- (void)enumeratePixelsWithVisitor:(SDLVisitor)visitor;
/// 修改坐标点的像素值
- (void)modifyColor:(RGBAColor)color atPoint:(CGPoint)point;
@end
- (SDLPixelsStorage *)readPixelsFromImage:(UIImage *)image {
SDLBitmapContext context = sdlCreateArgbBitmapContext(image.size.width, image.size.height);
SDLPixelsStorage *storage = [[SDLPixelsStorage alloc] init];
[storage createImageWithSize:size];
sdlDrawImageToBitmap(context, image.CGImage);
sdlForeachBitmapPixel(context, ^(uint8_t *pixelPtr, size_t row, size_t column) {
RGBAColor color = (RGBAColor){
pixelPtr[RED],
pixelPtr[GREEN],
pixelPtr[BLUE],
pixelPtr[ALPHA]
};
[storage modifyColor:color atPoint:CGPointMake(row, column)];
});
return storage;
}
- (void)viewDidLoad {
[super viewDidLoad];
SDLPixelsStorage *storage = [self readPixelsFromImage:[UIImage imageNamed:@"emoji"]];
[[[SDLRenderCanvas alloc] initWithDisplayView:self.view pixelsStorage:storage] render];
}
</code></pre></div></div>
<p>渲染效果</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9c243840700548e2adb074a5af26bfdc~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<h3 id="锯齿问题">锯齿问题</h3>
<p>由于<code class="language-plaintext highlighter-rouge">retina</code>屏幕的特性,在<code class="language-plaintext highlighter-rouge">3x</code>机型上单个坐标点共有<code class="language-plaintext highlighter-rouge">3 * 3 = 9</code>个像素,要达成最佳的图像渲染需要使用<code class="language-plaintext highlighter-rouge">3 * width * 3 * height</code>尺寸大小的位图,在此不做演示</p>
<h3 id="为什么需要pixelsstorage">为什么需要PixelsStorage</h3>
<p>在后续文章中会模拟视图的旋转变换,需要对像素的每一个坐标点做矩阵变换得到新的坐标点,抽象出像素存储器的类可以很好的支持运算</p>
<h2 id="混合图像">混合图像</h2>
<p>像素渲染时遵循一个规则:当像素<code class="language-plaintext highlighter-rouge">A</code>、<code class="language-plaintext highlighter-rouge">B</code>在同一坐标点且满足<code class="language-plaintext highlighter-rouge">A.z < B.z</code>时,假设<code class="language-plaintext highlighter-rouge">B</code>的透明度为<code class="language-plaintext highlighter-rouge">alpha</code>,坐标点的颜色计算公式为:</p>
<blockquote>
<p>A * (1 - B.alpha) + B * B.alpha</p>
</blockquote>
<p>当<code class="language-plaintext highlighter-rouge">B.alpha</code>等于<code class="language-plaintext highlighter-rouge">1</code>时,<code class="language-plaintext highlighter-rouge">A</code>像素永远不会被表达,反之<code class="language-plaintext highlighter-rouge">B.alpha</code>不为1时,显示的颜色为像素的叠加。为了实现图像混合的效果,新增加一个<code class="language-plaintext highlighter-rouge">PixelsComposite</code>类实现效果</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation SDLPixelsComposite
- (SDLPixelsStorage *)compose {
SDLPixelsStorage *composeStorage = [self normalizedStorage];
SDLPixelsStorage *currentPixelsStorage = [[SDLPixelsStorage alloc] init];
[currentPixelsStorage createImageWithSize:composeStorage.imageSize];
// 遍历所有图像
for (SDLPixelsComponent *component in self.pixelsComponents) {
CGFloat widthScale = composeStorage.imageSize.width / component.size.width;
CGFloat heightScale = composeStorage.imageSize.height / component.size.height;
[component.pixels enumeratePixelsWithVisitor:^(RGBAColor color, CGPoint point) {
// 读取当前图像的色彩信息存储到currentPixelsStorage中
}];
// 混合图像像素
CGFloat alpha = component.alpha;
[composeStorage enumratePixelsWithVisitor:^(RGBAColor color, CGPoint point) {
// A * (1 - B.alpha)
RGBAColor ultimateColor = multipleColor(color, 1 - alpha);
// B * B.alpha
RGBAColor currentColor = multipleColor([currentPixelsStorage colorAtPoint:point]);
ultimateColor = addColor(ultimateColor, currentColor);
[composeStorage modifyColor:ultimateColor atPoint:point];
}];
}
return composeStorage;
}
@end
- (void)viewDidLoad {
[super viewDidLoad];
SDLPixelsComposite *composite = [[SDLPixelsComposite alloc] init];
[composite appendPixels:[self readPixelsFromImage:@"container"] alpha:1];
[composite appendPixels:[self readPixelsFromImage:@"emoji"] alpha:0.2];
[[[SDLRenderCanvas alloc] initWithDisplayView:self.view pixelsStorage:[composite compose] render];
}
</code></pre></div></div>
<p>渲染效果</p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/dc96d0cee1654461978d8ad13ec5cd6c~tplv-k3u1fbpfcp-watermark.image?" alt="" /></p>
<h2 id="相关">相关</h2>
<p><a href="https://learnopengl-cn.github.io">LearnOpenGL</a></p>
<p><a href="https://www.bilibili.com/video/BV1X7411F744?spm_id_from=333.337.search-card.all.click">GAMES 101</a></p>sindrilinsindrilin@foxmail.com开篇 计算机图形学(Computer Graphics)是一种使用数学算法将二维或三维图形转换为计算机显示器的栅格形式的科学 本文为学习图形学过程中,使用iOS平台开发语言模拟图像渲染和变化的笔记 像素渲染 将像素展示到屏幕上需要借助CoreGraphics的接口,假设需要渲染width * height的图像,需要先分配色彩空间为ARGB的位图 #define BYTES_PER_RGB 4 typedef struct SDLBitmapContext { CGSize size; uint32_t *buffer; CGContextRef cgContext; CGColorSpaceRef colorSpace; } SDLBitmapContext; SDLBitmapContext sdlCreateArgbBitmapContext(size_t width, size_t height) { SDLBitmapContext context = identityBitmapContext(); size_t bytesPerRow = width * BYTES_PER_RGB; size_t bytesCount = bytesPerRow * height; context.size = CGSizeMake(width, height); context.colorSpace = CGColorSpaceCreateDeviceRGB(); if (context.colorSpace == NULL) { return identityBitmapContext(); } context.buffer = (uint32_t *)malloc((unsigned long)bytesCount); if (context.buffer == NULL) { sdlFreeBitmapContext(&context); return identityBitmapContext(); } memset(context.buffer, 0x0, bytesCount); context.cgContext = CGBitmapContextCreate(context.buffer, width, height, 8, bytesPerRow, context.colorSpace, kCGImageAlphaPremultipliedFirst); if (context.cgContext == NULL) { sdlFreeBitmapContext(&context); return identityBitmapContext(); } return context; } 在分配完位图后,遍历位图的内存将像素数据写入buffer中 enum { ALPHA = 0, RED = 1, GREEN = 2, BLUE = 3, }; void sdlForeachBitmapContext(SDLBitmapContext context, SDLBitmapPixelBody body) { uint32_t *imageBuffer = CGBitmapContextGetData(context.cgContext); if (imageBuffer == NULL) { return; } for (size_t row = 0; row < context.size.width; row++) { for (size_t column = 0; column < context.size.height; column++) { uint8_t *pixelPtr = (uint8_t *)imageBuffer; body(pixelPtr, row, column); } } } sdlForeachBitmapContext(context, ^(uint8_t *pixelPtr, size_t row, size_t column) { RGBAColor color = randomColor(); pixelPtr[ALPHA] = color.alpha; pixelPtr[RED] = color.red; pixelPtr[GREEN] = color.green; pixelPtr[BLUE] = color.blue; }); 最后从位图上下文取出图片即可展示 UIImage *sdlGenerateImage(SDLBitmapContext context) { if (!isBitmapContextValid(context)) { return nil; } CGImageRef imageRef = CGBitmapContextCreateImage(context.cgContext); UIImage *image = [UIImage imageWithCGImage:imageRef]; CGImageRelease(imageRef); return image; } - (void)renderImage:(SDLBitmapContext context) { UIImage *image = sdlGenerateImage(context); self.renderImageView.image = image; } 随机像素渲染效果 图片渲染 图片的渲染和随机像素渲染的过程相似,同样需要生成width * height大小尺寸的位图,然后将图片写入到位图中,这次使用一个PixelsStorage模型来存储所有的像素信息 @interface SDLPixelsStorage : NSObject /// 图片尺寸 @property (nonatomic, readonly) CGSize imageSize; /// 像素存储buffer @property (nonatomic, readonly) RGBAColor *pixelColors; /// 生成指定大小的像素缓存区 - (void)createImageWithSize:(CGSize)size; /// 遍历所有像素点 - (void)enumeratePixelsWithVisitor:(SDLVisitor)visitor; /// 修改坐标点的像素值 - (void)modifyColor:(RGBAColor)color atPoint:(CGPoint)point; @end - (SDLPixelsStorage *)readPixelsFromImage:(UIImage *)image { SDLBitmapContext context = sdlCreateArgbBitmapContext(image.size.width, image.size.height); SDLPixelsStorage *storage = [[SDLPixelsStorage alloc] init]; [storage createImageWithSize:size]; sdlDrawImageToBitmap(context, image.CGImage); sdlForeachBitmapPixel(context, ^(uint8_t *pixelPtr, size_t row, size_t column) { RGBAColor color = (RGBAColor){ pixelPtr[RED], pixelPtr[GREEN], pixelPtr[BLUE], pixelPtr[ALPHA] }; [storage modifyColor:color atPoint:CGPointMake(row, column)]; }); return storage; } - (void)viewDidLoad { [super viewDidLoad]; SDLPixelsStorage *storage = [self readPixelsFromImage:[UIImage imageNamed:@"emoji"]]; [[[SDLRenderCanvas alloc] initWithDisplayView:self.view pixelsStorage:storage] render]; } 渲染效果 锯齿问题 由于retina屏幕的特性,在3x机型上单个坐标点共有3 * 3 = 9个像素,要达成最佳的图像渲染需要使用3 * width * 3 * height尺寸大小的位图,在此不做演示 为什么需要PixelsStorage 在后续文章中会模拟视图的旋转变换,需要对像素的每一个坐标点做矩阵变换得到新的坐标点,抽象出像素存储器的类可以很好的支持运算 混合图像 像素渲染时遵循一个规则:当像素A、B在同一坐标点且满足A.z < B.z时,假设B的透明度为alpha,坐标点的颜色计算公式为: A * (1 - B.alpha) + B * B.alpha 当B.alpha等于1时,A像素永远不会被表达,反之B.alpha不为1时,显示的颜色为像素的叠加。为了实现图像混合的效果,新增加一个PixelsComposite类实现效果 @implementation SDLPixelsComposite - (SDLPixelsStorage *)compose { SDLPixelsStorage *composeStorage = [self normalizedStorage]; SDLPixelsStorage *currentPixelsStorage = [[SDLPixelsStorage alloc] init]; [currentPixelsStorage createImageWithSize:composeStorage.imageSize]; // 遍历所有图像 for (SDLPixelsComponent *component in self.pixelsComponents) { CGFloat widthScale = composeStorage.imageSize.width / component.size.width; CGFloat heightScale = composeStorage.imageSize.height / component.size.height; [component.pixels enumeratePixelsWithVisitor:^(RGBAColor color, CGPoint point) { // 读取当前图像的色彩信息存储到currentPixelsStorage中 }]; // 混合图像像素 CGFloat alpha = component.alpha; [composeStorage enumratePixelsWithVisitor:^(RGBAColor color, CGPoint point) { // A * (1 - B.alpha) RGBAColor ultimateColor = multipleColor(color, 1 - alpha); // B * B.alpha RGBAColor currentColor = multipleColor([currentPixelsStorage colorAtPoint:point]); ultimateColor = addColor(ultimateColor, currentColor); [composeStorage modifyColor:ultimateColor atPoint:point]; }]; } return composeStorage; } @end - (void)viewDidLoad { [super viewDidLoad]; SDLPixelsComposite *composite = [[SDLPixelsComposite alloc] init]; [composite appendPixels:[self readPixelsFromImage:@"container"] alpha:1]; [composite appendPixels:[self readPixelsFromImage:@"emoji"] alpha:0.2]; [[[SDLRenderCanvas alloc] initWithDisplayView:self.view pixelsStorage:[composite compose] render]; } 渲染效果 相关 LearnOpenGL GAMES 101iOS中的内嵌汇编2019-10-23T08:00:00+08:002019-10-23T08:00:00+08:00www.sindrilin.com/2019/10/23/write_assembly_in_ios<p>写一篇在<code class="language-plaintext highlighter-rouge">iOS</code>上使用汇编的文章的想法在脑袋里面停留了很久了,但是迟迟没有动手。虽然早前在做启动耗时优化的工作中,也做过通过拦截<code class="language-plaintext highlighter-rouge">objc_msgSend</code>并插入汇编指令来统计方法调用耗时的工作,但也只仅此而已。刚好最近的时间项目在做安全加固,需要写更多的汇编来提高安全性(<strong>文章内汇编使用指令集为ARM64</strong>),也就有了本文</p>
<h2 id="内嵌汇编格式">内嵌汇编格式</h2>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__asm__ [关键词](
指令
: [输出操作数列表]
: [输入操作数列表]
: [被污染的寄存器列表]
);
</code></pre></div></div>
<p>比如函数中存在<code class="language-plaintext highlighter-rouge">a、b、c</code>三个变量,要实现<code class="language-plaintext highlighter-rouge">a = b + c</code>这句代码,汇编代码如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__asm__ volatile(
"mov x0, %[b]\n"
"mov x1, %[c]\n"
"add x2, x0, x1\n"
"mov %[a], x2\n"
: [a]"=r"(a)
: [b]"r"(b), [c]"r"(c)
);
</code></pre></div></div>
<h3 id="volatile">volatile</h3>
<p><code class="language-plaintext highlighter-rouge">volatile</code>关键字表示禁止编译器对汇编代码进行再优化,但基本上有没有声明编译后指令都没区别</p>
<h3 id="操作数">操作数</h3>
<p>操作数格式为<code class="language-plaintext highlighter-rouge">"[limits]constraint"</code>,分为权限和限定符两部分。比如<code class="language-plaintext highlighter-rouge">"=r"</code>表示参数是只写并存放在通用寄存器上</p>
<ul>
<li>
<p><code class="language-plaintext highlighter-rouge">limits</code></p>
<table>
<thead>
<tr>
<th>关键字</th>
<th>表意</th>
</tr>
</thead>
<tbody>
<tr>
<td>=</td>
<td>只写,通用用于输出操作数</td>
</tr>
<tr>
<td>+</td>
<td>读写,只能用于输出操作数</td>
</tr>
<tr>
<td>&</td>
<td>声明寄存器只能用于输出</td>
</tr>
</tbody>
</table>
</li>
<li>
<p><code class="language-plaintext highlighter-rouge">constraint</code></p>
<table>
<thead>
<tr>
<th>关键字</th>
<th>表意</th>
</tr>
</thead>
<tbody>
<tr>
<td>f</td>
<td>浮点寄存器f0~f7</td>
</tr>
<tr>
<td>G/H</td>
<td>浮点常量立即数</td>
</tr>
<tr>
<td>I/L/K</td>
<td>数据处理用到的立即数</td>
</tr>
<tr>
<td>J</td>
<td>值为-4095~4095的索引</td>
</tr>
<tr>
<td>l/r</td>
<td>寄存器r0~r15</td>
</tr>
<tr>
<td>M</td>
<td>0~32/2的幂次方的常量</td>
</tr>
<tr>
<td>m</td>
<td>内存地址</td>
</tr>
<tr>
<td>w</td>
<td>向量寄存器s0~s31</td>
</tr>
<tr>
<td>X</td>
<td>任何类型的操作数</td>
</tr>
</tbody>
</table>
</li>
</ul>
<h3 id="指令">指令</h3>
<p>由于<code class="language-plaintext highlighter-rouge">ARM64</code>的指令过多,可通过文末的扩展阅读查阅指令,这里只讲解指令中的一些关键字:</p>
<ul>
<li>
<p><code class="language-plaintext highlighter-rouge">%0~%N</code> / <code class="language-plaintext highlighter-rouge">%[param]</code></p>
<p>在使用<code class="language-plaintext highlighter-rouge">C</code>代码和汇编混编的情况下,<code class="language-plaintext highlighter-rouge">%</code>起头用来关联参数,通过<code class="language-plaintext highlighter-rouge">%[param]</code>可以声明参数名称,也可以使用匿名参数格式<code class="language-plaintext highlighter-rouge">%N</code>的方式顺序对应参数(<code class="language-plaintext highlighter-rouge">abc</code>参数会按照<code class="language-plaintext highlighter-rouge">012</code>的顺序匹配):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> __asm__ volatile(
"mov x0, %1\n"
"mov x1, %2\n"
"add x2, x0, x1\n"
"mov %0, x2\n"
: "=r"(a)
: "r"(b), "r"(c)
);
</code></pre></div> </div>
<p>在实操过程中,设备不一定支持<code class="language-plaintext highlighter-rouge">%N</code>的匿名参数格式,建议使用<code class="language-plaintext highlighter-rouge">%[param]</code>使可读性更强</p>
</li>
<li>
<p><code class="language-plaintext highlighter-rouge">[reg]</code></p>
<p>程序运行的多数情况下,寄存器内存储的是存放数据的地址,使用<code class="language-plaintext highlighter-rouge">[]</code>包裹住寄存器,表示将寄存器的存储值作为地址访问数据。下面的指令分别是取出地址<code class="language-plaintext highlighter-rouge">0x10086</code>存储的数据存放在<code class="language-plaintext highlighter-rouge">x1</code>寄存器上,然后存放到地址<code class="language-plaintext highlighter-rouge">0x100086</code>的内存中:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> "mov x0, #0x10086\n"
"mov x1, [x0]\n"
"mov x2, #0x100086\n"
"str x1, [x2]\n"
</code></pre></div> </div>
</li>
<li>
<p><code class="language-plaintext highlighter-rouge">#1</code> / <code class="language-plaintext highlighter-rouge">#0x1</code></p>
<p>使用<code class="language-plaintext highlighter-rouge">#</code>起头表示立即数(常数),建议使用<code class="language-plaintext highlighter-rouge">16进制</code>书写</p>
</li>
</ul>
<h3 id="调用规范">调用规范</h3>
<p><code class="language-plaintext highlighter-rouge">ARM64</code>调用约定采用<code class="language-plaintext highlighter-rouge">AAPCS64</code>,参数从左到右存放到<code class="language-plaintext highlighter-rouge">x0~x7</code>寄存器中,参数超出<code class="language-plaintext highlighter-rouge">8</code>个时,多余的从右往左入栈,根据返回值大小不同存放在<code class="language-plaintext highlighter-rouge">x0/x8</code>返回。寄存器规则如下:</p>
<table>
<thead>
<tr>
<th>寄存器</th>
<th>特殊名称</th>
<th>规则</th>
</tr>
</thead>
<tbody>
<tr>
<td>r31</td>
<td>SP</td>
<td>存放栈顶地址</td>
</tr>
<tr>
<td>r30</td>
<td>LR</td>
<td>存放函数返回地址</td>
</tr>
<tr>
<td>r29</td>
<td>FP</td>
<td>存放函数使用栈帧地址</td>
</tr>
<tr>
<td>r19~r28</td>
<td> </td>
<td>被调用方需要保护的寄存器</td>
</tr>
<tr>
<td>r18</td>
<td> </td>
<td>平台寄存器,不建议当做临时寄存器使用</td>
</tr>
<tr>
<td>r17</td>
<td>IP1</td>
<td>进程内使用寄存器,不建议当做临时寄存器使用</td>
</tr>
<tr>
<td>r16</td>
<td>IP0</td>
<td>同r17,同时作为软中断<code class="language-plaintext highlighter-rouge">svc</code>中的系统调用参数</td>
</tr>
<tr>
<td>r9~r15</td>
<td> </td>
<td>临时寄存器(汇编指令中嵌入函数地址参数时,会用于保存函数地址)</td>
</tr>
<tr>
<td>r8</td>
<td> </td>
<td>返回值寄存器(其他时候同r9~r15)</td>
</tr>
<tr>
<td>r0~r7</td>
<td> </td>
<td>传递存储调用参数,r0可作为返回值寄存器</td>
</tr>
<tr>
<td>NZCV</td>
<td> </td>
<td>状态寄存器</td>
</tr>
</tbody>
</table>
<h2 id="实战">实战</h2>
<h3 id="调试检测">调试检测</h3>
<p>在<code class="language-plaintext highlighter-rouge">iOS</code>应用安全加固中,通过<code class="language-plaintext highlighter-rouge">sysctl + kinfo_proc</code>的方案可以检测应用是否被调试:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__attribute__((__always_inline)) bool checkTracing() {
size_t size = sizeof(struct kinfo_proc);
struct kinfo_proc proc;
memset(&proc, 0, size);
int name[4];
name[0] = CTL_KERN;
name[1] = KERN_PROC;
name[2] = KERN_PROC_PID;
name[3] = getpid();
sysctl(name, 4, &proc, &size, NULL, 0);
return proc.kp_proc.p_flag & P_TRACED;
}
</code></pre></div></div>
<p>但由于<code class="language-plaintext highlighter-rouge">fishhook</code>这种直接修改懒符号地址的方案存在,直接使用<code class="language-plaintext highlighter-rouge">sysctl</code>是不安全的,因此多数开发者会将这一调用替换成内嵌汇编的方案执行:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>size_t size = sizeof(struct kinfo_proc);
struct kinfo_proc proc;
memset(&proc, 0, size);
int name[4];
name[0] = CTL_KERN;
name[1] = KERN_PROC;
name[2] = KERN_PROC_PID;
name[3] = getpid();
__asm__(
"mov x0, %[name_ptr]\n"
"mov x1, #4\n"
"mov x2, %[proc_ptr]\n"
"mov x3, %[size_ptr]\n"
"mov x4, #0x0\n"
"mov x5, #0x0\n"
"mov w16, #202\n"
"svc #0x80\n"
:
:[name_ptr]"r"(&name), [proc_ptr]"r"(&proc), [size_ptr]"r"(&size)
);
return proc.kp_proc.p_flag & P_TRACED;
</code></pre></div></div>
<h3 id="踩坑">踩坑</h3>
<p>使用<code class="language-plaintext highlighter-rouge">C</code>代码内嵌汇编开发的时候,有个致命的问题是函数入口会将临时变量入栈,并且将这些变量存放到寄存器中。上面的混编代码实际运行时,会出现下面的情况:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 函数入口生成的临时变量代码
add x0, sp, #0x24 // x0存放name
add x1, sp, #0x34 // x1存放proc
add x2, sp, #020 // x2存放size
......
// 内嵌汇编
mov x0, x0 // name正常赋值
mov x1, #4 // proc数据被破坏
mov x2, x1 // size数据被破坏
mov x3, x2
mov x4, #0x0
mov x5, #0x0
mov x12, #0xca
svc #0x80
</code></pre></div></div>
<p>编译后的代码由于临时变量顺序问题,导致了<code class="language-plaintext highlighter-rouge">svc</code>中断调用<code class="language-plaintext highlighter-rouge">sysctl</code>无法传入正确参数,最终卡死应用</p>
<h2 id="修复">修复</h2>
<h3 id="插入临时变量">插入临时变量</h3>
<p>通过编译后的指令得到一张对应表:</p>
<table>
<thead>
<tr>
<th>变量</th>
<th>寄存器</th>
<th>入参寄存器</th>
</tr>
</thead>
<tbody>
<tr>
<td>name</td>
<td>x0</td>
<td>x0</td>
</tr>
<tr>
<td>proc</td>
<td>x1</td>
<td>x2</td>
</tr>
<tr>
<td>size</td>
<td>x2</td>
<td>X3</td>
</tr>
</tbody>
</table>
<p>如果能够让存储临时变量的寄存器和<code class="language-plaintext highlighter-rouge">svc</code>中断时的入参寄存器保持一致,就不会遭到破坏</p>
<blockquote>
<p><code class="language-plaintext highlighter-rouge">ARM64</code>调用约定,参数从右往左入栈</p>
</blockquote>
<p>因为检测函数无入参,所以临时参数入参后依次存放到了<code class="language-plaintext highlighter-rouge">x0~x2</code>寄存器中,顺序为<code class="language-plaintext highlighter-rouge">name、proc、size</code>,因此需要只需要在<code class="language-plaintext highlighter-rouge">name</code>和<code class="language-plaintext highlighter-rouge">proc</code>中插入一个无用的临时变量,就能让参数对应起来:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>size_t size = sizeof(struct kinfo_proc);
struct kinfo_proc proc;
memset(&proc, 0, size);
int placeholder;
int name[4];
name[0] = CTL_KERN;
name[1] = KERN_PROC;
name[2] = KERN_PROC_PID;
name[3] = getpid();
</code></pre></div></div>
<p>编译后指令变为:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 函数入口生成的临时变量代码
add x0, sp, #0x24 // x0存放name
add x1, sp, #0x34 // x1存放placeholder
add x2, sp, 0x38 // x2存放proc
add x3, sp, #020 // x3存放size
......
// 内嵌汇编
mov x0, x0
mov x1, #4
mov x2, x2
mov x3, x3
mov x4, #0x0
mov x5, #0x0
mov x12, #0xca
svc #0x80
</code></pre></div></div>
<h3 id="修改指令顺序">修改指令顺序</h3>
<p>设置入参的指令会破坏寄存器上已有的值,那么保证设置入参之前,寄存器没被破坏就可以了:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__asm__(
"mov x0, %[name_ptr]\n"
"mov x3, %[size_ptr]\n"
"mov x2, %[proc_ptr]\n"
"mov x1, #4\n"
"mov x4, #0x0\n"
"mov x5, #0x0\n"
"mov w16, #202\n"
"svc #0x80\n"
:
:[name_ptr]"r"(&name), [proc_ptr]"r"(&proc), [size_ptr]"r"(&size)
);
</code></pre></div></div>
<p>编译后指令如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 内嵌汇编
mov x0, x0 // x0保存name
mov x3, x2 // x3保存size
mov x2, x1 // x2保存proc
mov x1, #4
mov x4, #0x0
mov x5, #0x0
mov x12, #0xca
svc #0x80
</code></pre></div></div>
<h3 id="全汇编实现">全汇编实现</h3>
<p>在和<code class="language-plaintext highlighter-rouge">C</code>代码混编的情况下,无法保证哪些寄存器会被破坏,那么直接使用汇编实现整个逻辑是一个不错的选择,需要注意<code class="language-plaintext highlighter-rouge">2</code>个问题:</p>
<ol>
<li>保证函数调用前后不会生成出入口指令,使用<code class="language-plaintext highlighter-rouge">__attribute__((naked))</code>来处理</li>
<li>所有变量存储在栈上,需要把控制好栈的使用</li>
<li>使用安全的寄存器(<code class="language-plaintext highlighter-rouge">r19~r28</code>)</li>
</ol>
<p>首先先判断需要多长的栈空间,根据函数<code class="language-plaintext highlighter-rouge">sysctl(name, 4, &proc, &size, NULL, 0)</code>判断</p>
<ul>
<li>参数<code class="language-plaintext highlighter-rouge">name</code>总共占用 <code class="language-plaintext highlighter-rouge">4 * int</code>空间,记为<code class="language-plaintext highlighter-rouge">0x10</code></li>
<li>参数<code class="language-plaintext highlighter-rouge">proc</code>在<code class="language-plaintext highlighter-rouge">arm64</code>下,<code class="language-plaintext highlighter-rouge">sizof()</code>计算长度为<code class="language-plaintext highlighter-rouge">0x288</code></li>
<li>参数<code class="language-plaintext highlighter-rouge">&size</code>指针长度为<code class="language-plaintext highlighter-rouge">0x8</code></li>
<li>共计<code class="language-plaintext highlighter-rouge">0x2a0</code></li>
</ul>
<p>函数入口时,需要对<code class="language-plaintext highlighter-rouge">FP/LR</code>寄存器进行入栈,保证函数能正确退出。另外<code class="language-plaintext highlighter-rouge">r19~r28</code>共计<code class="language-plaintext highlighter-rouge">10</code>个寄存器需要进行入栈保护,最终得出函数运行时的栈空间图:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>----------
| FP |
---------- sp + 0x2f8
| LR |
---------- sp + 0x2f0
| r20 |
---------- sp + 0x2e8
| r19 |
---------- sp + 0x2e0
| r22 |
---------- sp + 0x2d8
| r21 |
---------- sp + 0x2d0
| r24 |
---------- sp + 0x2c8
| r23 |
---------- sp + 0x2c0
| r26 |
---------- sp + 0x2b8
| r25 |
---------- sp + 0x2b0
| r28 |
---------- sp + 0x2a8
| r27 |
---------- sp + 0x2a0
| p_size |
---------- sp + 0x298
| proc |
---------- sp + 0x10
| name |
---------- sp
</code></pre></div></div>
<p>在保存<code class="language-plaintext highlighter-rouge">r19~r28</code>寄存器入栈后,使用其中五个寄存器来保存一些参数:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>------------------
| 参数 | 寄存器 |
------------------
| name | r19 |
------------------
| proc | r20 |
------------------
| p_size | r21 |
------------------
| size | r22 |
------------------
| sp | r23 |
------------------
| temp | r24 |
------------------
</code></pre></div></div>
<p>确认好栈上空间的使用后,可以开始分步骤实现:</p>
<h4 id="函数出入口">函数出入口</h4>
<p>在函数的出入口负责两件事情:<code class="language-plaintext highlighter-rouge">FP/LR</code>的出入栈、<code class="language-plaintext highlighter-rouge">r19~r28</code>的出入栈</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__asm__ volatile(
"stp x29, x30, [sp, #-0x10]!\n"
"stp x19, x20, [sp, #-0x10]!\n"
"stp x21, x22, [sp, #-0x10]!\n"
"stp x23, x24, [sp, #-0x10]!\n"
"stp x25, x26, [sp, #-0x10]!\n"
"stp x27, x28, [sp, #-0x10]!\n"
......
"ldp x19, x20, [sp], #0x10\n"
"ldp x21, x22, [sp], #0x10\n"
"ldp x23, x24, [sp], #0x10\n"
"ldp x25, x26, [sp], #0x10\n"
"ldp x27, x28, [sp], #0x10\n"
"ldp x29, x30, [sp], #0x10\n"
);
</code></pre></div></div>
<h4 id="栈开辟空间">栈开辟空间</h4>
<p>临时变量总共用到<code class="language-plaintext highlighter-rouge">0x2a0</code>的空间,并且需要使用<code class="language-plaintext highlighter-rouge">5</code>个寄存器保存变量</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__asm__ volatile(
......
"sub sp, sp, #0x2a0\n"
// 开辟栈空间,寄存器保存变量
"mov x19, sp\n" // x19 = name
"add, x20, sp, #0x10\n" // x20 = proc
"add, x21, sp, #0x298\n" // x21 = p_size
"mov x22, #0x288\n" // x22 = size
"mov x23, sp\n" // x23 = sp
"str x22, [x21]\n" // p_size = &size
"add sp, sp, #0x2a0\n"
......
);
</code></pre></div></div>
<h4 id="kinfo_proc">kinfo_proc</h4>
<p>确定<code class="language-plaintext highlighter-rouge">proc</code>的内存之后,需要将:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>size_t size = sizeof(struct kinfo_proc);
struct kinfo_proc proc;
memset(&proc, 0, size);
</code></pre></div></div>
<p>转换成对应的汇编,其中<code class="language-plaintext highlighter-rouge">proc</code>存储在<code class="language-plaintext highlighter-rouge">x20</code>,<code class="language-plaintext highlighter-rouge">x22</code>存储了<code class="language-plaintext highlighter-rouge">size</code>,<code class="language-plaintext highlighter-rouge">memset</code>一共需要三个参数,分别入参:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__asm__ volatile(
......
"mov x24, %[memset_ptr]\n"
"mov x0, x20\n"
"mov x1, #0x0\n"
"mov x2, x12\n"
"blr x24\n"
......
:
:[memset_ptr]"r"(memset)
);
</code></pre></div></div>
<h4 id="name">name</h4>
<p>由于<code class="language-plaintext highlighter-rouge">name</code>是<code class="language-plaintext highlighter-rouge">int</code>数组,在明确其存储位置的情况下,需要分别将<code class="language-plaintext highlighter-rouge">4</code>个<code class="language-plaintext highlighter-rouge">4字节</code>的参数存储到对应的内存位置,其位置分布如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-------------
| name[3] |
------------- sp + 0xc
| name[2] |
------------- sp + 0x8
| name[1] |
------------- sp + 0x4
| name[0] |
------------- sp
</code></pre></div></div>
<p>另外<code class="language-plaintext highlighter-rouge">name</code>需要使用到<code class="language-plaintext highlighter-rouge">getpid()</code>来配置参数,通过<code class="language-plaintext highlighter-rouge">svc</code>的中断可以获取这一参数(<code class="language-plaintext highlighter-rouge">svc</code>系统调用参数可以参考扩展阅读中的<code class="language-plaintext highlighter-rouge">Kernel Syscalls</code>)</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#define CTL_KERN 1
#define KERN_PROC 14
#define KERN_PROC_PID 1
__asm__ volatile(
......
// getpid
"mov x0, #0\n"
"mov w16, #20\n"
"mov x3, x0\n" // name[3]=getpid()
// 设置参数并存储
"mov x0, #0x1\n"
"mov x1, #0xe\n"
"mov x2, #0x1\n"
"str w0, [x23, 0x0]\n"
"str w1, [x23, 0x4]\n"
"str w2, [x23, 0x8]\n"
"str w3, [x23, 0xc]\n"
......
);
</code></pre></div></div>
<h4 id="sysctl">sysctl</h4>
<p>最后是调用<code class="language-plaintext highlighter-rouge">sysctl</code>,根据参数和寄存器对应关系入参调用即可:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>__asm__ volatile(
......
"mov x0, x19\n"
"mov x1, #0x4\n"
"mov x2, x20\n"
"mov x3, x21\n"
"mov x4, #0x0\n"
"mov x5, #0x0\n"
"mov w16, #202\n"
"svc #0x80\n"
......
);
</code></pre></div></div>
<h4 id="flag检测">flag检测</h4>
<p>最终需要返回<code class="language-plaintext highlighter-rouge">p_flag</code>和<code class="language-plaintext highlighter-rouge">P_TRACED</code>的与比较检测,这里需要通过获取<code class="language-plaintext highlighter-rouge">p_flag</code>在结构体中的偏移来访问数据,<code class="language-plaintext highlighter-rouge">struct extern_proc</code>的结构如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>struct extern_proc {
union {
struct {
struct proc *__p_forw; /* Doubly-linked run/sleep queue. */
struct proc *__p_back;
} p_st1;
struct timeval __p_starttime; /* process start time */
} p_un;
#define p_forw p_un.p_st1.__p_forw
#define p_back p_un.p_st1.__p_back
#define p_starttime p_un.__p_starttime
struct vmspace *p_vmspace; /* Address space. */
struct sigacts *p_sigacts; /* Signal actions, state (PROC ONLY). */
int p_flag; /* P_* flags. */
char p_stat; /* S* process status. */
pid_t p_pid; /* Process identifier. */
pid_t p_oppid; /* Save parent pid during ptrace. XXX */
int p_dupfd; /* Sideways return value from fdopen. XXX */
/* Mach related */
caddr_t user_stack; /* where user stack was allocated */
void *exit_thread; /* XXX Which thread is exiting? */
int p_debugger; /* allow to debug */
boolean_t sigwait; /* indication to suspend */
/* scheduling */
u_int p_estcpu; /* Time averaged value of p_cpticks. */
int p_cpticks; /* Ticks of cpu time. */
fixpt_t p_pctcpu; /* %cpu for this process during p_swtime */
void *p_wchan; /* Sleep address. */
char *p_wmesg; /* Reason for sleep. */
u_int p_swtime; /* Time swapped in or out. */
u_int p_slptime; /* Time since last blocked. */
struct itimerval p_realtimer; /* Alarm timer. */
struct timeval p_rtime; /* Real time. */
u_quad_t p_uticks; /* Statclock hits in user mode. */
u_quad_t p_sticks; /* Statclock hits in system mode. */
u_quad_t p_iticks; /* Statclock hits processing intr. */
int p_traceflag; /* Kernel trace points. */
struct vnode *p_tracep; /* Trace to vnode. */
int p_siglist; /* DEPRECATED. */
struct vnode *p_textvp; /* Vnode of executable. */
int p_holdcnt; /* If non-zero, don't swap. */
sigset_t p_sigmask; /* DEPRECATED. */
sigset_t p_sigignore; /* Signals being ignored. */
sigset_t p_sigcatch; /* Signals being caught by user. */
u_char p_priority; /* Process priority. */
u_char p_usrpri; /* User-priority based on p_cpu and p_nice. */
char p_nice; /* Process "nice" value. */
char p_comm[MAXCOMLEN + 1];
struct pgrp *p_pgrp; /* Pointer to process group. */
struct user *p_addr; /* Kernel virtual addr of u-area (PROC ONLY). */
u_short p_xstat; /* Exit status for wait; also stop signal. */
u_short p_acflag; /* Accounting flags. */
struct rusage *p_ru; /* Exit information. XXX */
};
</code></pre></div></div>
<p>其中<code class="language-plaintext highlighter-rouge">union p_un</code>的<code class="language-plaintext highlighter-rouge">size</code>为<code class="language-plaintext highlighter-rouge">0x10</code>,以及<code class="language-plaintext highlighter-rouge">p_flag</code>前面的两个指针分别占用<code class="language-plaintext highlighter-rouge">0x8</code>,可以确认结构体的内存占用图:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-------------------
| p_flag |
------------------- kinfo_proc + 0x20
| p_sigacts |
------------------- kinfo_proc + 0x18
| p_vmspace |
------------------- kinfo_proc + 0x10
| union p_un |
------------------- kinfo_proc
</code></pre></div></div>
<p>比对标记并且将检测结果存放到<code class="language-plaintext highlighter-rouge">x0</code>中返回:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#define P_TRACED 0x00000800
__asm__ volatile(
......
"ldr, x24, [x20, #0x20]\n" // x24 = proc.kp_proc.p_flag
"mov x25, #0x800\n" // x25 = P_TRACED
"blc x0, x24, x25\n" // x0 = x24 & x25
......
);
</code></pre></div></div>
<h2 id="扩展阅读">扩展阅读</h2>
<p><a href="https://www.theiphonewiki.com/wiki/Kernel_Syscalls">Kernel_Syscalls</a></p>
<p><a href="https://juejin.im/post/5cadeda55188251ad87b0eed">ARM64 架构之入栈/出栈操作</a></p>
<p><a href="https://juejin.im/post/5a786c555188257a6854b18c">深入iOS系统底层之CPU寄存器</a></p>
<p><a href="http://infocenter.arm.com/help/topic/com.arm.doc.ihi0055b/IHI0055B_aapcs64.pdf">Procedure Call Standard for the ARM 64-bit Architecture</a></p>sindrilinsindrilin@foxmail.com写一篇在iOS上使用汇编的文章的想法在脑袋里面停留了很久了,但是迟迟没有动手。虽然早前在做启动耗时优化的工作中,也做过通过拦截objc_msgSend并插入汇编指令来统计方法调用耗时的工作,但也只仅此而已。刚好最近的时间项目在做安全加固,需要写更多的汇编来提高安全性(文章内汇编使用指令集为ARM64),也就有了本文 内嵌汇编格式 __asm__ [关键词]( 指令 : [输出操作数列表] : [输入操作数列表] : [被污染的寄存器列表] ); 比如函数中存在a、b、c三个变量,要实现a = b + c这句代码,汇编代码如下: __asm__ volatile( "mov x0, %[b]\n" "mov x1, %[c]\n" "add x2, x0, x1\n" "mov %[a], x2\n" : [a]"=r"(a) : [b]"r"(b), [c]"r"(c) ); volatile volatile关键字表示禁止编译器对汇编代码进行再优化,但基本上有没有声明编译后指令都没区别 操作数 操作数格式为"[limits]constraint",分为权限和限定符两部分。比如"=r"表示参数是只写并存放在通用寄存器上 limits 关键字 表意 = 只写,通用用于输出操作数 + 读写,只能用于输出操作数 & 声明寄存器只能用于输出 constraint 关键字 表意 f 浮点寄存器f0~f7 G/H 浮点常量立即数 I/L/K 数据处理用到的立即数 J 值为-4095~4095的索引 l/r 寄存器r0~r15 M 0~32/2的幂次方的常量 m 内存地址 w 向量寄存器s0~s31 X 任何类型的操作数 指令 由于ARM64的指令过多,可通过文末的扩展阅读查阅指令,这里只讲解指令中的一些关键字: %0~%N / %[param] 在使用C代码和汇编混编的情况下,%起头用来关联参数,通过%[param]可以声明参数名称,也可以使用匿名参数格式%N的方式顺序对应参数(abc参数会按照012的顺序匹配): __asm__ volatile( "mov x0, %1\n" "mov x1, %2\n" "add x2, x0, x1\n" "mov %0, x2\n" : "=r"(a) : "r"(b), "r"(c) ); 在实操过程中,设备不一定支持%N的匿名参数格式,建议使用%[param]使可读性更强 [reg] 程序运行的多数情况下,寄存器内存储的是存放数据的地址,使用[]包裹住寄存器,表示将寄存器的存储值作为地址访问数据。下面的指令分别是取出地址0x10086存储的数据存放在x1寄存器上,然后存放到地址0x100086的内存中: "mov x0, #0x10086\n" "mov x1, [x0]\n" "mov x2, #0x100086\n" "str x1, [x2]\n" #1 / #0x1 使用#起头表示立即数(常数),建议使用16进制书写 调用规范 ARM64调用约定采用AAPCS64,参数从左到右存放到x0~x7寄存器中,参数超出8个时,多余的从右往左入栈,根据返回值大小不同存放在x0/x8返回。寄存器规则如下: 寄存器 特殊名称 规则 r31 SP 存放栈顶地址 r30 LR 存放函数返回地址 r29 FP 存放函数使用栈帧地址 r19~r28 被调用方需要保护的寄存器 r18 平台寄存器,不建议当做临时寄存器使用 r17 IP1 进程内使用寄存器,不建议当做临时寄存器使用 r16 IP0 同r17,同时作为软中断svc中的系统调用参数 r9~r15 临时寄存器(汇编指令中嵌入函数地址参数时,会用于保存函数地址) r8 返回值寄存器(其他时候同r9~r15) r0~r7 传递存储调用参数,r0可作为返回值寄存器 NZCV 状态寄存器 实战 调试检测 在iOS应用安全加固中,通过sysctl + kinfo_proc的方案可以检测应用是否被调试: __attribute__((__always_inline)) bool checkTracing() { size_t size = sizeof(struct kinfo_proc); struct kinfo_proc proc; memset(&proc, 0, size); int name[4]; name[0] = CTL_KERN; name[1] = KERN_PROC; name[2] = KERN_PROC_PID; name[3] = getpid(); sysctl(name, 4, &proc, &size, NULL, 0); return proc.kp_proc.p_flag & P_TRACED; } 但由于fishhook这种直接修改懒符号地址的方案存在,直接使用sysctl是不安全的,因此多数开发者会将这一调用替换成内嵌汇编的方案执行: size_t size = sizeof(struct kinfo_proc); struct kinfo_proc proc; memset(&proc, 0, size); int name[4]; name[0] = CTL_KERN; name[1] = KERN_PROC; name[2] = KERN_PROC_PID; name[3] = getpid(); __asm__( "mov x0, %[name_ptr]\n" "mov x1, #4\n" "mov x2, %[proc_ptr]\n" "mov x3, %[size_ptr]\n" "mov x4, #0x0\n" "mov x5, #0x0\n" "mov w16, #202\n" "svc #0x80\n" : :[name_ptr]"r"(&name), [proc_ptr]"r"(&proc), [size_ptr]"r"(&size) ); return proc.kp_proc.p_flag & P_TRACED; 踩坑 使用C代码内嵌汇编开发的时候,有个致命的问题是函数入口会将临时变量入栈,并且将这些变量存放到寄存器中。上面的混编代码实际运行时,会出现下面的情况: // 函数入口生成的临时变量代码 add x0, sp, #0x24 // x0存放name add x1, sp, #0x34 // x1存放proc add x2, sp, #020 // x2存放size ...... // 内嵌汇编 mov x0, x0 // name正常赋值 mov x1, #4 // proc数据被破坏 mov x2, x1 // size数据被破坏 mov x3, x2 mov x4, #0x0 mov x5, #0x0 mov x12, #0xca svc #0x80 编译后的代码由于临时变量顺序问题,导致了svc中断调用sysctl无法传入正确参数,最终卡死应用 修复 插入临时变量 通过编译后的指令得到一张对应表: 变量 寄存器 入参寄存器 name x0 x0 proc x1 x2 size x2 X3 如果能够让存储临时变量的寄存器和svc中断时的入参寄存器保持一致,就不会遭到破坏 ARM64调用约定,参数从右往左入栈 因为检测函数无入参,所以临时参数入参后依次存放到了x0~x2寄存器中,顺序为name、proc、size,因此需要只需要在name和proc中插入一个无用的临时变量,就能让参数对应起来: size_t size = sizeof(struct kinfo_proc); struct kinfo_proc proc; memset(&proc, 0, size); int placeholder; int name[4]; name[0] = CTL_KERN; name[1] = KERN_PROC; name[2] = KERN_PROC_PID; name[3] = getpid(); 编译后指令变为: // 函数入口生成的临时变量代码 add x0, sp, #0x24 // x0存放name add x1, sp, #0x34 // x1存放placeholder add x2, sp, 0x38 // x2存放proc add x3, sp, #020 // x3存放size ...... // 内嵌汇编 mov x0, x0 mov x1, #4 mov x2, x2 mov x3, x3 mov x4, #0x0 mov x5, #0x0 mov x12, #0xca svc #0x80 修改指令顺序 设置入参的指令会破坏寄存器上已有的值,那么保证设置入参之前,寄存器没被破坏就可以了: __asm__( "mov x0, %[name_ptr]\n" "mov x3, %[size_ptr]\n" "mov x2, %[proc_ptr]\n" "mov x1, #4\n" "mov x4, #0x0\n" "mov x5, #0x0\n" "mov w16, #202\n" "svc #0x80\n" : :[name_ptr]"r"(&name), [proc_ptr]"r"(&proc), [size_ptr]"r"(&size) ); 编译后指令如下: // 内嵌汇编 mov x0, x0 // x0保存name mov x3, x2 // x3保存size mov x2, x1 // x2保存proc mov x1, #4 mov x4, #0x0 mov x5, #0x0 mov x12, #0xca svc #0x80 全汇编实现 在和C代码混编的情况下,无法保证哪些寄存器会被破坏,那么直接使用汇编实现整个逻辑是一个不错的选择,需要注意2个问题: 保证函数调用前后不会生成出入口指令,使用__attribute__((naked))来处理 所有变量存储在栈上,需要把控制好栈的使用 使用安全的寄存器(r19~r28) 首先先判断需要多长的栈空间,根据函数sysctl(name, 4, &proc, &size, NULL, 0)判断 参数name总共占用 4 * int空间,记为0x10 参数proc在arm64下,sizof()计算长度为0x288 参数&size指针长度为0x8 共计0x2a0 函数入口时,需要对FP/LR寄存器进行入栈,保证函数能正确退出。另外r19~r28共计10个寄存器需要进行入栈保护,最终得出函数运行时的栈空间图: ---------- | FP | ---------- sp + 0x2f8 | LR | ---------- sp + 0x2f0 | r20 | ---------- sp + 0x2e8 | r19 | ---------- sp + 0x2e0 | r22 | ---------- sp + 0x2d8 | r21 | ---------- sp + 0x2d0 | r24 | ---------- sp + 0x2c8 | r23 | ---------- sp + 0x2c0 | r26 | ---------- sp + 0x2b8 | r25 | ---------- sp + 0x2b0 | r28 | ---------- sp + 0x2a8 | r27 | ---------- sp + 0x2a0 | p_size | ---------- sp + 0x298 | proc | ---------- sp + 0x10 | name | ---------- sp 在保存r19~r28寄存器入栈后,使用其中五个寄存器来保存一些参数: ------------------ | 参数 | 寄存器 | ------------------ | name | r19 | ------------------ | proc | r20 | ------------------ | p_size | r21 | ------------------ | size | r22 | ------------------ | sp | r23 | ------------------ | temp | r24 | ------------------ 确认好栈上空间的使用后,可以开始分步骤实现: 函数出入口 在函数的出入口负责两件事情:FP/LR的出入栈、r19~r28的出入栈 __asm__ volatile( "stp x29, x30, [sp, #-0x10]!\n" "stp x19, x20, [sp, #-0x10]!\n" "stp x21, x22, [sp, #-0x10]!\n" "stp x23, x24, [sp, #-0x10]!\n" "stp x25, x26, [sp, #-0x10]!\n" "stp x27, x28, [sp, #-0x10]!\n" ...... "ldp x19, x20, [sp], #0x10\n" "ldp x21, x22, [sp], #0x10\n" "ldp x23, x24, [sp], #0x10\n" "ldp x25, x26, [sp], #0x10\n" "ldp x27, x28, [sp], #0x10\n" "ldp x29, x30, [sp], #0x10\n" ); 栈开辟空间 临时变量总共用到0x2a0的空间,并且需要使用5个寄存器保存变量 __asm__ volatile( ...... "sub sp, sp, #0x2a0\n" // 开辟栈空间,寄存器保存变量 "mov x19, sp\n" // x19 = name "add, x20, sp, #0x10\n" // x20 = proc "add, x21, sp, #0x298\n" // x21 = p_size "mov x22, #0x288\n" // x22 = size "mov x23, sp\n" // x23 = sp "str x22, [x21]\n" // p_size = &size "add sp, sp, #0x2a0\n" ...... ); kinfo_proc 确定proc的内存之后,需要将: size_t size = sizeof(struct kinfo_proc); struct kinfo_proc proc; memset(&proc, 0, size); 转换成对应的汇编,其中proc存储在x20,x22存储了size,memset一共需要三个参数,分别入参: __asm__ volatile( ...... "mov x24, %[memset_ptr]\n" "mov x0, x20\n" "mov x1, #0x0\n" "mov x2, x12\n" "blr x24\n" ...... : :[memset_ptr]"r"(memset) ); name 由于name是int数组,在明确其存储位置的情况下,需要分别将4个4字节的参数存储到对应的内存位置,其位置分布如下: ------------- | name[3] | ------------- sp + 0xc | name[2] | ------------- sp + 0x8 | name[1] | ------------- sp + 0x4 | name[0] | ------------- sp 另外name需要使用到getpid()来配置参数,通过svc的中断可以获取这一参数(svc系统调用参数可以参考扩展阅读中的Kernel Syscalls) #define CTL_KERN 1 #define KERN_PROC 14 #define KERN_PROC_PID 1 __asm__ volatile( ...... // getpid "mov x0, #0\n" "mov w16, #20\n" "mov x3, x0\n" // name[3]=getpid() // 设置参数并存储 "mov x0, #0x1\n" "mov x1, #0xe\n" "mov x2, #0x1\n" "str w0, [x23, 0x0]\n" "str w1, [x23, 0x4]\n" "str w2, [x23, 0x8]\n" "str w3, [x23, 0xc]\n" ...... ); sysctl 最后是调用sysctl,根据参数和寄存器对应关系入参调用即可: __asm__ volatile( ...... "mov x0, x19\n" "mov x1, #0x4\n" "mov x2, x20\n" "mov x3, x21\n" "mov x4, #0x0\n" "mov x5, #0x0\n" "mov w16, #202\n" "svc #0x80\n" ...... ); flag检测 最终需要返回p_flag和P_TRACED的与比较检测,这里需要通过获取p_flag在结构体中的偏移来访问数据,struct extern_proc的结构如下: struct extern_proc { union { struct { struct proc *__p_forw; /* Doubly-linked run/sleep queue. */ struct proc *__p_back; } p_st1; struct timeval __p_starttime; /* process start time */ } p_un; #define p_forw p_un.p_st1.__p_forw #define p_back p_un.p_st1.__p_back #define p_starttime p_un.__p_starttime struct vmspace *p_vmspace; /* Address space. */ struct sigacts *p_sigacts; /* Signal actions, state (PROC ONLY). */ int p_flag; /* P_* flags. */ char p_stat; /* S* process status. */ pid_t p_pid; /* Process identifier. */ pid_t p_oppid; /* Save parent pid during ptrace. XXX */ int p_dupfd; /* Sideways return value from fdopen. XXX */ /* Mach related */ caddr_t user_stack; /* where user stack was allocated */ void *exit_thread; /* XXX Which thread is exiting? */ int p_debugger; /* allow to debug */ boolean_t sigwait; /* indication to suspend */ /* scheduling */ u_int p_estcpu; /* Time averaged value of p_cpticks. */ int p_cpticks; /* Ticks of cpu time. */ fixpt_t p_pctcpu; /* %cpu for this process during p_swtime */ void *p_wchan; /* Sleep address. */ char *p_wmesg; /* Reason for sleep. */ u_int p_swtime; /* Time swapped in or out. */ u_int p_slptime; /* Time since last blocked. */ struct itimerval p_realtimer; /* Alarm timer. */ struct timeval p_rtime; /* Real time. */ u_quad_t p_uticks; /* Statclock hits in user mode. */ u_quad_t p_sticks; /* Statclock hits in system mode. */ u_quad_t p_iticks; /* Statclock hits processing intr. */ int p_traceflag; /* Kernel trace points. */ struct vnode *p_tracep; /* Trace to vnode. */ int p_siglist; /* DEPRECATED. */ struct vnode *p_textvp; /* Vnode of executable. */ int p_holdcnt; /* If non-zero, don't swap. */ sigset_t p_sigmask; /* DEPRECATED. */ sigset_t p_sigignore; /* Signals being ignored. */ sigset_t p_sigcatch; /* Signals being caught by user. */ u_char p_priority; /* Process priority. */ u_char p_usrpri; /* User-priority based on p_cpu and p_nice. */ char p_nice; /* Process "nice" value. */ char p_comm[MAXCOMLEN + 1]; struct pgrp *p_pgrp; /* Pointer to process group. */ struct user *p_addr; /* Kernel virtual addr of u-area (PROC ONLY). */ u_short p_xstat; /* Exit status for wait; also stop signal. */ u_short p_acflag; /* Accounting flags. */ struct rusage *p_ru; /* Exit information. XXX */ }; 其中union p_un的size为0x10,以及p_flag前面的两个指针分别占用0x8,可以确认结构体的内存占用图: ------------------- | p_flag | ------------------- kinfo_proc + 0x20 | p_sigacts | ------------------- kinfo_proc + 0x18 | p_vmspace | ------------------- kinfo_proc + 0x10 | union p_un | ------------------- kinfo_proc 比对标记并且将检测结果存放到x0中返回: #define P_TRACED 0x00000800 __asm__ volatile( ...... "ldr, x24, [x20, #0x20]\n" // x24 = proc.kp_proc.p_flag "mov x25, #0x800\n" // x25 = P_TRACED "blc x0, x24, x25\n" // x0 = x24 & x25 ...... ); 扩展阅读 Kernel_Syscalls ARM64 架构之入栈/出栈操作 深入iOS系统底层之CPU寄存器 Procedure Call Standard for the ARM 64-bit ArchitectureCombine和SwiftUI2019-07-24T08:00:00+08:002019-07-24T08:00:00+08:00www.sindrilin.com/2019/07/24/combine_and_swiftUI.md<p>尽管今年的<code class="language-plaintext highlighter-rouge">WWDC</code>已经落幕,但在过去的一个多月时间,苹果给<code class="language-plaintext highlighter-rouge">iOS</code>开发者带来了许多惊喜,其中堪称最重量级的当属<code class="language-plaintext highlighter-rouge">SwiftUI</code>和<code class="language-plaintext highlighter-rouge">Combine</code>两大新框架</p>
<p>在更早之前,由于缺少系统层的声明式<code class="language-plaintext highlighter-rouge">UI</code>语言,在<code class="language-plaintext highlighter-rouge">iOS</code>系统上的<code class="language-plaintext highlighter-rouge">UI</code>开发对于开发者而言,并不友善,而从<code class="language-plaintext highlighter-rouge">iOS13</code>开始,开发者们终于可以摆脱落后的布局系统,拥抱更简洁高效的开发新时代。与<code class="language-plaintext highlighter-rouge">SwiftUI</code>配套发布的响应式编程框架<code class="language-plaintext highlighter-rouge">Combine</code>提供了更优美的开发方式,这也意味着<code class="language-plaintext highlighter-rouge">Swift</code>真正成为了<code class="language-plaintext highlighter-rouge">iOS</code>开发者们必须学习的语言。本文基于<code class="language-plaintext highlighter-rouge">Swift5.1</code>版本,介绍<code class="language-plaintext highlighter-rouge">SwiftUI</code>是如何通过结合<code class="language-plaintext highlighter-rouge">Combine</code>完成数据绑定</p>
<h2 id="swiftui">SwiftUI</h2>
<p><img src="https://user-gold-cdn.xitu.io/2019/7/24/16c240ba31a2fe31?w=702&h=1400&f=jpeg&s=22385" alt="" /></p>
<p>首先来个例子,假如我们要实现上图的登陆界面,按照以往使用<code class="language-plaintext highlighter-rouge">UIKit</code>进行开发,那么我们需要:</p>
<ul>
<li>创建一个<code class="language-plaintext highlighter-rouge">UITextField</code>,用于输入账户</li>
<li>创建一个<code class="language-plaintext highlighter-rouge">UITextField</code>,用于输入密码</li>
<li>创建一个<code class="language-plaintext highlighter-rouge">UIButton</code>,设置点击事件将前两个<code class="language-plaintext highlighter-rouge">UITextField</code>的文本作为数据请求</li>
</ul>
<p>而在使用<code class="language-plaintext highlighter-rouge">SwiftUI</code>进行开发的情况下,代码如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>public struct LoginView : View {
@State var username: String = ""
@State var password: String = ""
public var body: some View {
VStack {
TextField($username, placeholder: Text("Enter username"))
.textFieldStyle(.roundedBorder)
.padding([.leading, .trailing], 25)
.padding([.bottom], 15)
SecureField($password, placeholder: Text("Enter password"))
.textFieldStyle(.roundedBorder)
.padding([.leading, .trailing], 25)
.padding([.bottom], 30)
Button(action: {}) {
Text("Sign In")
.foregroundColor(.white)
}.frame(width: 120, height: 40)
.background(Color.blue)
}
}
}
</code></pre></div></div>
<p>在<code class="language-plaintext highlighter-rouge">SwiftUI</code>中,使用<code class="language-plaintext highlighter-rouge">@State</code>修饰的属性会在发生改变的时候通知绑定的<code class="language-plaintext highlighter-rouge">UI</code>控件强制刷新渲染,这种新命名归功于新的<code class="language-plaintext highlighter-rouge">PropertyWrapper</code>机制。可以看到<code class="language-plaintext highlighter-rouge">SwiftUI</code>的控件命名上和<code class="language-plaintext highlighter-rouge">UIKit</code>几乎保持一致的,下表是两个标准库上的<code class="language-plaintext highlighter-rouge">UI</code>对应表:</p>
<table>
<thead>
<tr>
<th>SwiftUI</th>
<th>UIKit</th>
</tr>
</thead>
<tbody>
<tr>
<td>Text</td>
<td>UILabel / NSAttributedString</td>
</tr>
<tr>
<td>TextField</td>
<td>UITextField</td>
</tr>
<tr>
<td>SecureField</td>
<td>UITextField with isSecureTextEntry</td>
</tr>
<tr>
<td>Button</td>
<td>UIButton</td>
</tr>
<tr>
<td>Image</td>
<td>UIImageView</td>
</tr>
<tr>
<td>List</td>
<td>UITableView</td>
</tr>
<tr>
<td>Alert</td>
<td>UIAlertView / UIAlertController</td>
</tr>
<tr>
<td>ActionSheet</td>
<td>UIActionSheet / UIAlertController</td>
</tr>
<tr>
<td>NavigationView</td>
<td>UINavigationController</td>
</tr>
<tr>
<td>HStack</td>
<td>UIStackView with horizatonal</td>
</tr>
<tr>
<td>VStack</td>
<td>UIStackView with vertical</td>
</tr>
<tr>
<td>Toggle</td>
<td>UISwitch</td>
</tr>
<tr>
<td>Slider</td>
<td>UISlider</td>
</tr>
<tr>
<td>SegmentedControl</td>
<td>UISegmentedControl</td>
</tr>
<tr>
<td>Stepper</td>
<td>UIStepper</td>
</tr>
<tr>
<td>DatePicker</td>
<td>UIDatePicker</td>
</tr>
</tbody>
</table>
<h3 id="view">View</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View : _View {
/// The type of view representing the body of this view.
///
/// When you create a custom view, Swift infers this type from your
/// implementation of the required `body` property.
associatedtype Body : View
/// Declares the content and behavior of this view.
var body: Self.Body { get }
}
</code></pre></div></div>
<p>虽然在<code class="language-plaintext highlighter-rouge">SwiftUI</code>中使用<code class="language-plaintext highlighter-rouge">View</code>来表示可视控件,但实际上大相径庭,<code class="language-plaintext highlighter-rouge">View</code>是一套容器协议,不展示任何内容,只定义了一套视图的交互、布局等接口。<code class="language-plaintext highlighter-rouge">UI</code>控件需要实现协议中的<code class="language-plaintext highlighter-rouge">body</code>返回需要展示的内容。另外<code class="language-plaintext highlighter-rouge">View</code>还扩展了<code class="language-plaintext highlighter-rouge">Combine</code>响应式编程的订阅接口:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
/// Adds an action to perform when the given publisher emits an event.
///
/// - Parameters:
/// - publisher: The publisher to subscribe to.
/// - action: The action to perform when an event is emitted by
/// `publisher`. The event emitted by publisher is passed as a
/// parameter to `action`.
/// - Returns: A view that triggers `action` when `publisher` emits an
/// event.
public func onReceive<P>(_ publisher: P, perform action: @escaping (P.Output) -> Void) -> SubscriptionView<P, Self> where P : Publisher, P.Failure == Never
/// Adds an action to perform when the given publisher emits an event.
///
/// - Parameters:
/// - publisher: The publisher to subscribe to.
/// - action: The action to perform when an event is emitted by
/// `publisher`.
/// - Returns: A view that triggers `action` when `publisher` emits an
/// event.
public func onReceive<P>(_ publisher: P, perform action: @escaping () -> Void) -> SubscriptionView<P, Self> where P : Publisher, P.Failure == Never
}
</code></pre></div></div>
<h3 id="state">@State</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@propertyDelegate public struct State<Value> : DynamicViewProperty, BindingConvertible {
/// Initialize with the provided initial value.
public init(initialValue value: Value)
/// The current state value.
public var value: Value { get nonmutating set }
/// Returns a binding referencing the state value.
public var binding: Binding<Value> { get }
/// Produces the binding referencing this state value
public var delegateValue: Binding<Value> { get }
/// Produces the binding referencing this state value
/// TODO: old name for storageValue, to be removed
public var storageValue: Binding<Value> { get }
}
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">Swift5.1</code>的新特性之一,开发者可以将变量的<code class="language-plaintext highlighter-rouge">IO</code>实现封装成通用逻辑,用关键字<code class="language-plaintext highlighter-rouge">@propertyWrapper</code>(更新于<code class="language-plaintext highlighter-rouge">beta4</code>版本)修饰读写逻辑,并以<code class="language-plaintext highlighter-rouge">@wrapperName var variable</code>的方式封装变量。以<a href="https://developer.apple.com/videos/play/wwdc2019/415/">WWDC Session 415</a>视频中的例子实现对变量<code class="language-plaintext highlighter-rouge">copy-on-write</code>的封装:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@propertyWrapper
public struct DefensiveCopying<Value: NSCopying> {
private var storage: Value
public init(initialValue value: Value) {
storage = value.copy() as! Value
}
public var wrappedValue: Value {
get { storage }
set {
storage = newValue.copy() as! Value
}
}
/// beta4版本更新,必须声明projectedValue后才能使用$variable的方式访问Wrapper<Value>
/// beta3版本使用wrapperValue命名
public var projectedValue: DefensiveCopying<Value> {
get { self }
}
}
public struct Person {
@DefensiveCopying(initialValue: "")
public var name: NSString
}
</code></pre></div></div>
<p>另外以<code class="language-plaintext highlighter-rouge">PropertyWrapper</code>封装的变量,会<del>默认生成一个命名为<code class="language-plaintext highlighter-rouge">$name</code>的<code class="language-plaintext highlighter-rouge">DefensiveCopying<String></code>类型的变量</del>,更新后会默认生成<code class="language-plaintext highlighter-rouge">_name</code>命名的<code class="language-plaintext highlighter-rouge">Wrapper<Value></code>类型参数,或声明关键变量<code class="language-plaintext highlighter-rouge">wrapperValue/projectedValue</code>后生成可访问的<code class="language-plaintext highlighter-rouge">$name</code>变量,下面两种值访问操作是相同的:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>extension Person {
func visitName() {
printf("name: \(name)")
printf("name: \($name.value)")
}
}
</code></pre></div></div>
<h2 id="combine">Combine</h2>
<blockquote>
<p>Customize handling of asynchronous events by combining event-processing operators.</p>
</blockquote>
<p>引用官方文档的描述,<code class="language-plaintext highlighter-rouge">Combine</code>是一套通过组合变换事件操作来处理异步事件的标准库。事件执行过程的关系包括:被观察者<code class="language-plaintext highlighter-rouge">Observable</code>和观察者<code class="language-plaintext highlighter-rouge">Observer</code>,在<code class="language-plaintext highlighter-rouge">Combine</code>中对应着<code class="language-plaintext highlighter-rouge">Publisher</code>和<code class="language-plaintext highlighter-rouge">Subscriber</code></p>
<h3 id="异步编程">异步编程</h3>
<p>很多开发者认为异步编程会开辟线程执行任务,多数时候程序在异步执行时确实也会创建线程,但是这种理解是不正确的,同步编程和异步编程的区别只在于程序是否会堵塞等待任务执行完毕,下面是一段无需额外线程的异步编程实现代码:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>class TaskExecutor {
static let instance = TaskExecutor()
private var executing: Bool = false
private var tasks: [() -> ()] = Array()
private var queue: DispatchQueue = DispatchQueue.init(label: "SerialQueue")
func pushTask(task: @escaping () -> ()) {
tasks.append(task)
if !executing {
execute()
}
}
func execute() {
executing = true
let executedTasks = tasks
tasks.removeAll()
executedTasks.forEach {
$0()
}
if tasks.count > 0 {
execute()
} else {
executing = false
}
}
}
TaskExecutor.instance.execute()
TaskExecutor.instance.pushTask {
print("abc")
TaskExecutor.instance.pushTask {
print("def")
}
print("ghi")
}
</code></pre></div></div>
<h3 id="单向流动">单向流动</h3>
<p>如果<code class="language-plaintext highlighter-rouge">A</code>事件会触发<code class="language-plaintext highlighter-rouge">B</code>事件,反之不成立,可以认为两个事件是单向的,好比说<code class="language-plaintext highlighter-rouge">我饿了,所以我去吃东西</code>,但不会是<code class="language-plaintext highlighter-rouge">我去吃东西,所以我饿了</code>。在编程中,如果数据流动能够保证单向,会让程序变得更加简单。举个例子,下面是一段非单向流动的常见<code class="language-plaintext highlighter-rouge">UI</code>代码:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>func tapped(signIn: UIButton) {
LoginManager.manager.signIn(username, password: password) { (err) in
guard err == nil else {
ERR_LOG("sign in failed: \(err)")
return
}
UserManager.manager.switch(to: username)
MainPageViewController.enter()
}
}
</code></pre></div></div>
<p>在这段代码中,<code class="language-plaintext highlighter-rouge">Action</code>实际上会等待<code class="language-plaintext highlighter-rouge">State/Data</code>完成后,去更新<code class="language-plaintext highlighter-rouge">View</code>,<code class="language-plaintext highlighter-rouge">View</code>会再去访问数据更新状态,这种逻辑会让数据在不同事件模块中随意流动,易读性和可维护性都会变得更差:</p>
<p><img src="https://user-gold-cdn.xitu.io/2019/7/24/16c240ba50d5046d?w=1026&h=566&f=png&s=79198" alt="" /></p>
<p>而一旦事件之间的流动采用了异步编程的方式来处理,发出事件的人不关心等待事件的处理,无疑能让数据的流动变得更加单一,<code class="language-plaintext highlighter-rouge">Combine</code>的意义就在于此。<code class="language-plaintext highlighter-rouge">SwiftUI</code>与其结合来控制业务数据的单向流动,让开发复杂度大大降低:</p>
<p><img src="https://user-gold-cdn.xitu.io/2019/7/25/16c26fd29b2bc75f?w=587&h=314&f=png&s=25437" alt="来自淘宝技术" /></p>
<h3 id="publisher">Publisher</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Publisher {
/// The kind of values published by this publisher.
associatedtype Output
/// The kind of errors this publisher might publish.
///
/// Use `Never` if this `Publisher` does not publish errors.
associatedtype Failure : Error
/// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)`
///
/// - SeeAlso: `subscribe(_:)`
/// - Parameters:
/// - subscriber: The subscriber to attach to this `Publisher`.
/// once attached it can begin to receive values.
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">Publisher</code>定义了发布相关的两个信息:<code class="language-plaintext highlighter-rouge">Output</code>和<code class="language-plaintext highlighter-rouge">Failure</code>,对应事件输出值和失败处理两种情况,以及提供了<code class="language-plaintext highlighter-rouge">receive(subscriber:)</code>接口注册事件订阅者。在<code class="language-plaintext highlighter-rouge">iOS13</code>之后,苹果基于<code class="language-plaintext highlighter-rouge">Foundation</code>标准库实现了很多<code class="language-plaintext highlighter-rouge">Combine</code>的响应式接口,包括:</p>
<ul>
<li>
<p><code class="language-plaintext highlighter-rouge">URLSessionTask</code>可以在请求完成或者请求出错时发出消息</p>
</li>
<li>
<p><code class="language-plaintext highlighter-rouge">NotificationCenter</code>新增响应式编程接口</p>
</li>
</ul>
<p>以官方的<code class="language-plaintext highlighter-rouge">NotificationCenter</code>扩展为例创建一个登录操作的<code class="language-plaintext highlighter-rouge">Publisher</code>:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>extension NotificationCenter {
struct Publisher: Combine.Publisher {
typealias Output = Notification
typealias Failure = Never
init(center: NotificationCenter, name: Notification.Name, Object: Any? = nil)
}
}
let signInNotification = Notification.Name.init("user_sign_in")
struct SignInInfo {
let username: String
let password: String
}
let signInPublisher = NotificationCenter.Publisher(center: .default, name: signInNotification, object: nil)
</code></pre></div></div>
<p>另外还需要注意的是:<code class="language-plaintext highlighter-rouge">Self.Output == S.Input</code>限制了<code class="language-plaintext highlighter-rouge">Publisher</code>和<code class="language-plaintext highlighter-rouge">Subscriber</code>之间的数据流动必须保持类型一致,大多数时候总是很难维持一致性的,所以<code class="language-plaintext highlighter-rouge">Publisher</code>同样提供了<code class="language-plaintext highlighter-rouge">map/compactMap</code>的高阶函数对输出值进行转换:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/// Subscriber只接收用户名信息
let signInPublisher = NotificationCenter.Publisher(center: .default, name: signInNotification, object: nil)
.map {
return ($0 as? SignInfo)?.username ?? "unknown"
}
</code></pre></div></div>
<h3 id="subscriber">Subscriber</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Subscriber : CustomCombineIdentifierConvertible {
/// The kind of values this subscriber receives.
associatedtype Input
/// The kind of errors this subscriber might receive.
///
/// Use `Never` if this `Subscriber` cannot receive errors.
associatedtype Failure : Error
/// Tells the subscriber that it has successfully subscribed to the publisher and may request items.
///
/// Use the received `Subscription` to request items from the publisher.
/// - Parameter subscription: A subscription that represents the connection between publisher and subscriber.
func receive(subscription: Subscription)
/// Tells the subscriber that the publisher has produced an element.
///
/// - Parameter input: The published element.
/// - Returns: A `Demand` instance indicating how many more elements the subcriber expects to receive.
func receive(_ input: Self.Input) -> Subscribers.Demand
/// Tells the subscriber that the publisher has completed publishing, either normally or with an error.
///
/// - Parameter completion: A `Completion` case indicating whether publishing completed normally or with an error.
func receive(completion: Subscribers.Completion<Self.Failure>)
}
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">Subscriber</code>定义了一套<code class="language-plaintext highlighter-rouge">receive</code>接口用来接收<code class="language-plaintext highlighter-rouge">Publisher</code>发送的消息,一个完整的订阅流程如下图:</p>
<p><img src="https://user-gold-cdn.xitu.io/2019/7/24/16c240ba3a90d058?w=962&h=900&f=jpeg&s=32896" alt="" /></p>
<p>在订阅成功之后,<code class="language-plaintext highlighter-rouge">receive(subscription:)</code>会被调用一次,其类型如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Subscription : Cancellable, CustomCombineIdentifierConvertible {
/// Tells a publisher that it may send more values to the subscriber.
func request(_ demand: Subscribers.Demand)
}
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">Subscription</code>可以认为是单次订阅的会话,其实现了<code class="language-plaintext highlighter-rouge">Cancellable</code>接口允许<code class="language-plaintext highlighter-rouge">Subscriber</code>中途取消订阅,释放资源。基于上方的<code class="language-plaintext highlighter-rouge">NotificationCenter</code>代码,完成<code class="language-plaintext highlighter-rouge">Subscriber</code>的接收部分:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>func registerSignInHandle() {
let signInSubscriber = Subscribers.Assign.init(object: self.userNameLabel, keyPath: \.text)
signInPublisher.subscribe(signInSubscriber)
}
func tapped(signIn: UIButton) {
LoginManager.manager.signIn(username, password: password) { (err) in
guard err == nil else {
ERR_LOG("sign in failed: \(err)")
return
}
let info = SignInfo(username: username, password: password)
NotificationCenter.default.post(name: signInNotification, object: info)
}
}
</code></pre></div></div>
<h2 id="combine与uikit">Combine与UIKit</h2>
<p>得力于<code class="language-plaintext highlighter-rouge">Swift5.1</code>的新特性,基于<code class="language-plaintext highlighter-rouge">PropertyWrapper</code>和<code class="language-plaintext highlighter-rouge">Combine</code>标准库,可以让<code class="language-plaintext highlighter-rouge">UIKit</code>同样具备绑定数据流动的能力,预设代码如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>class ViewController: UIViewController {
@Publishable(initialValue: "")
var text: String
let textLabel = UILabel.init(frame: CGRect.init(x: 100, y: 120, width: 120, height: 40))
override func viewDidLoad() {
super.viewDidLoad()
textLabel.bind(text: $text)
let button = UIButton.init(frame: CGRect.init(x: 100, y: 180, width: 120, height: 40))
button.addTarget(self, action: #selector(tapped(button:)), for: .touchUpInside)
button.setTitle("random text", for: .normal)
button.backgroundColor = .blue
view.addSubview(textLabel)
view.addSubview(button)
}
@objc func tapped(button: UIButton) {
text = String(arc4random() % 101)
}
}
</code></pre></div></div>
<p>每次点击按钮的时候生成随机数字符串,然后<code class="language-plaintext highlighter-rouge">textLabel</code>自动更新文本</p>
<h3 id="publishable">Publishable</h3>
<p>字符串在发生改变的时候需要更新绑定的<code class="language-plaintext highlighter-rouge">label</code>,在这里使用<code class="language-plaintext highlighter-rouge">PassthroughSubject</code>类对输出值类型做强限制,其结构如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
final public class PassthroughSubject<Output, Failure> : Subject where Failure : Error {
public init()
/// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)`
///
/// - SeeAlso: `subscribe(_:)`
/// - Parameters:
/// - subscriber: The subscriber to attach to this `Publisher`.
/// once attached it can begin to receive values.
final public func receive<S>(subscriber: S) where Output == S.Input, Failure == S.Failure, S : Subscriber
/// Sends a value to the subscriber.
///
/// - Parameter value: The value to send.
final public func send(_ input: Output)
/// Sends a completion signal to the subscriber.
///
/// - Parameter completion: A `Completion` instance which indicates whether publishing has finished normally or failed with an error.
final public func send(completion: Subscribers.Completion<Failure>)
}
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">Publishable</code>的实现代码如下(7.25更新):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@propertyWrapper
public struct Publishable<Value: Equatable> {
private var storage: Value
var publisher: PassthroughSubject<Value?, Never>
public init(initialValue value: Value) {
storage = value
publisher = PassthroughSubject<Value?, Never>()
Publishers.AllSatisfy
}
public var wrappedValue: Value {
get { storage }
set {
if storage != newValue {
storage = newValue
publisher.send(storage)
}
}
}
public var projectedValue: Publishable<Value> {
get { self }
}
}
</code></pre></div></div>
<h3 id="ui-extensions">UI extensions</h3>
<p>通过<code class="language-plaintext highlighter-rouge">extension</code>对控件进行扩展支持属性绑定:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>extension UILabel {
func bind(text: Publishable<String>) {
let subscriber = Subscribers.Assign.init(object: self, keyPath: \.text)
text.publisher.subscribe(subscriber)
self.text = text.value
}
}
</code></pre></div></div>
<p>这里需要注意的是,创建的<code class="language-plaintext highlighter-rouge">Subscriber</code>会被系统的<code class="language-plaintext highlighter-rouge">libswiftCore</code>持有,在控制器生命周期结束时,如果不能及时的<code class="language-plaintext highlighter-rouge">cancel</code>掉所有的<code class="language-plaintext highlighter-rouge">subscriber</code>,会导致内存泄漏:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>func freeBinding() {
subscribers?.forEach {
$0.cancel()
}
subscribers?.removeAll()
}
</code></pre></div></div>
<p>最后放上运行效果:</p>
<p><img src="https://user-gold-cdn.xitu.io/2019/7/24/16c240ba50c2226f?w=255&h=180&f=gif&s=11044" alt="" /></p>
<h2 id="其他">其他</h2>
<p>从今年<code class="language-plaintext highlighter-rouge">wwdc</code>发布的新内容,不难看出苹果的野心,由于<code class="language-plaintext highlighter-rouge">Swift</code>本身就是一门特别适合编写<code class="language-plaintext highlighter-rouge">DSL</code>的语言,而在<code class="language-plaintext highlighter-rouge">iOS13</code>上新增的两个标准库让项目的开发成本和维护成本变得更低的特点。由于其极高的可读性,开发者很容易就习惯新的标准库。目前<code class="language-plaintext highlighter-rouge">SwiftUI</code>实时用到了<code class="language-plaintext highlighter-rouge">UIKit</code>、<code class="language-plaintext highlighter-rouge">CoreGraphics</code>等库,可以看做是基于这些库的抽象封装层,随着后续<code class="language-plaintext highlighter-rouge">Swift</code>的普及度,苹果底层可以换掉<code class="language-plaintext highlighter-rouge">UIKit</code>独立存在,甚至实现跨平台的大前端一统。当然目前苹果上的大前端尚早,不过未来可期</p>
<h2 id="参考阅读">参考阅读</h2>
<p><a href="https://developer.apple.com/videos/wwdc2019/?q=SwiftUI">SwiftUI Session</a></p>
<p><a href="https://www.avanderlee.com/swift/property-wrappers/">Property Wrappers</a></p>
<p><a href="https://icodesign.me/posts/swift-combine/">Combine入门导读</a></p>
<p><a href="https://mp.weixin.qq.com/s/x_jFcKeXSbtdK0CnfayFsw">新晋网红SwiftUI</a></p>sindrilinsindrilin@foxmail.com尽管今年的WWDC已经落幕,但在过去的一个多月时间,苹果给iOS开发者带来了许多惊喜,其中堪称最重量级的当属SwiftUI和Combine两大新框架 在更早之前,由于缺少系统层的声明式UI语言,在iOS系统上的UI开发对于开发者而言,并不友善,而从iOS13开始,开发者们终于可以摆脱落后的布局系统,拥抱更简洁高效的开发新时代。与SwiftUI配套发布的响应式编程框架Combine提供了更优美的开发方式,这也意味着Swift真正成为了iOS开发者们必须学习的语言。本文基于Swift5.1版本,介绍SwiftUI是如何通过结合Combine完成数据绑定 SwiftUI 首先来个例子,假如我们要实现上图的登陆界面,按照以往使用UIKit进行开发,那么我们需要: 创建一个UITextField,用于输入账户 创建一个UITextField,用于输入密码 创建一个UIButton,设置点击事件将前两个UITextField的文本作为数据请求 而在使用SwiftUI进行开发的情况下,代码如下: public struct LoginView : View { @State var username: String = "" @State var password: String = "" public var body: some View { VStack { TextField($username, placeholder: Text("Enter username")) .textFieldStyle(.roundedBorder) .padding([.leading, .trailing], 25) .padding([.bottom], 15) SecureField($password, placeholder: Text("Enter password")) .textFieldStyle(.roundedBorder) .padding([.leading, .trailing], 25) .padding([.bottom], 30) Button(action: {}) { Text("Sign In") .foregroundColor(.white) }.frame(width: 120, height: 40) .background(Color.blue) } } } 在SwiftUI中,使用@State修饰的属性会在发生改变的时候通知绑定的UI控件强制刷新渲染,这种新命名归功于新的PropertyWrapper机制。可以看到SwiftUI的控件命名上和UIKit几乎保持一致的,下表是两个标准库上的UI对应表: SwiftUI UIKit Text UILabel / NSAttributedString TextField UITextField SecureField UITextField with isSecureTextEntry Button UIButton Image UIImageView List UITableView Alert UIAlertView / UIAlertController ActionSheet UIActionSheet / UIAlertController NavigationView UINavigationController HStack UIStackView with horizatonal VStack UIStackView with vertical Toggle UISwitch Slider UISlider SegmentedControl UISegmentedControl Stepper UIStepper DatePicker UIDatePicker View @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) public protocol View : _View { /// The type of view representing the body of this view. /// /// When you create a custom view, Swift infers this type from your /// implementation of the required `body` property. associatedtype Body : View /// Declares the content and behavior of this view. var body: Self.Body { get } } 虽然在SwiftUI中使用View来表示可视控件,但实际上大相径庭,View是一套容器协议,不展示任何内容,只定义了一套视图的交互、布局等接口。UI控件需要实现协议中的body返回需要展示的内容。另外View还扩展了Combine响应式编程的订阅接口: @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) extension View { /// Adds an action to perform when the given publisher emits an event. /// /// - Parameters: /// - publisher: The publisher to subscribe to. /// - action: The action to perform when an event is emitted by /// `publisher`. The event emitted by publisher is passed as a /// parameter to `action`. /// - Returns: A view that triggers `action` when `publisher` emits an /// event. public func onReceive<P>(_ publisher: P, perform action: @escaping (P.Output) -> Void) -> SubscriptionView<P, Self> where P : Publisher, P.Failure == Never /// Adds an action to perform when the given publisher emits an event. /// /// - Parameters: /// - publisher: The publisher to subscribe to. /// - action: The action to perform when an event is emitted by /// `publisher`. /// - Returns: A view that triggers `action` when `publisher` emits an /// event. public func onReceive<P>(_ publisher: P, perform action: @escaping () -> Void) -> SubscriptionView<P, Self> where P : Publisher, P.Failure == Never } @State @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) @propertyDelegate public struct State<Value> : DynamicViewProperty, BindingConvertible { /// Initialize with the provided initial value. public init(initialValue value: Value) /// The current state value. public var value: Value { get nonmutating set } /// Returns a binding referencing the state value. public var binding: Binding<Value> { get } /// Produces the binding referencing this state value public var delegateValue: Binding<Value> { get } /// Produces the binding referencing this state value /// TODO: old name for storageValue, to be removed public var storageValue: Binding<Value> { get } } Swift5.1的新特性之一,开发者可以将变量的IO实现封装成通用逻辑,用关键字@propertyWrapper(更新于beta4版本)修饰读写逻辑,并以@wrapperName var variable的方式封装变量。以WWDC Session 415视频中的例子实现对变量copy-on-write的封装: @propertyWrapper public struct DefensiveCopying<Value: NSCopying> { private var storage: Value public init(initialValue value: Value) { storage = value.copy() as! Value } public var wrappedValue: Value { get { storage } set { storage = newValue.copy() as! Value } } /// beta4版本更新,必须声明projectedValue后才能使用$variable的方式访问Wrapper<Value> /// beta3版本使用wrapperValue命名 public var projectedValue: DefensiveCopying<Value> { get { self } } } public struct Person { @DefensiveCopying(initialValue: "") public var name: NSString } 另外以PropertyWrapper封装的变量,会默认生成一个命名为$name的DefensiveCopying<String>类型的变量,更新后会默认生成_name命名的Wrapper<Value>类型参数,或声明关键变量wrapperValue/projectedValue后生成可访问的$name变量,下面两种值访问操作是相同的: extension Person { func visitName() { printf("name: \(name)") printf("name: \($name.value)") } } Combine Customize handling of asynchronous events by combining event-processing operators. 引用官方文档的描述,Combine是一套通过组合变换事件操作来处理异步事件的标准库。事件执行过程的关系包括:被观察者Observable和观察者Observer,在Combine中对应着Publisher和Subscriber 异步编程 很多开发者认为异步编程会开辟线程执行任务,多数时候程序在异步执行时确实也会创建线程,但是这种理解是不正确的,同步编程和异步编程的区别只在于程序是否会堵塞等待任务执行完毕,下面是一段无需额外线程的异步编程实现代码: class TaskExecutor { static let instance = TaskExecutor() private var executing: Bool = false private var tasks: [() -> ()] = Array() private var queue: DispatchQueue = DispatchQueue.init(label: "SerialQueue") func pushTask(task: @escaping () -> ()) { tasks.append(task) if !executing { execute() } } func execute() { executing = true let executedTasks = tasks tasks.removeAll() executedTasks.forEach { $0() } if tasks.count > 0 { execute() } else { executing = false } } } TaskExecutor.instance.execute() TaskExecutor.instance.pushTask { print("abc") TaskExecutor.instance.pushTask { print("def") } print("ghi") } 单向流动 如果A事件会触发B事件,反之不成立,可以认为两个事件是单向的,好比说我饿了,所以我去吃东西,但不会是我去吃东西,所以我饿了。在编程中,如果数据流动能够保证单向,会让程序变得更加简单。举个例子,下面是一段非单向流动的常见UI代码: func tapped(signIn: UIButton) { LoginManager.manager.signIn(username, password: password) { (err) in guard err == nil else { ERR_LOG("sign in failed: \(err)") return } UserManager.manager.switch(to: username) MainPageViewController.enter() } } 在这段代码中,Action实际上会等待State/Data完成后,去更新View,View会再去访问数据更新状态,这种逻辑会让数据在不同事件模块中随意流动,易读性和可维护性都会变得更差: 而一旦事件之间的流动采用了异步编程的方式来处理,发出事件的人不关心等待事件的处理,无疑能让数据的流动变得更加单一,Combine的意义就在于此。SwiftUI与其结合来控制业务数据的单向流动,让开发复杂度大大降低: Publisher @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public protocol Publisher { /// The kind of values published by this publisher. associatedtype Output /// The kind of errors this publisher might publish. /// /// Use `Never` if this `Publisher` does not publish errors. associatedtype Failure : Error /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)` /// /// - SeeAlso: `subscribe(_:)` /// - Parameters: /// - subscriber: The subscriber to attach to this `Publisher`. /// once attached it can begin to receive values. func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input } Publisher定义了发布相关的两个信息:Output和Failure,对应事件输出值和失败处理两种情况,以及提供了receive(subscriber:)接口注册事件订阅者。在iOS13之后,苹果基于Foundation标准库实现了很多Combine的响应式接口,包括: URLSessionTask可以在请求完成或者请求出错时发出消息 NotificationCenter新增响应式编程接口 以官方的NotificationCenter扩展为例创建一个登录操作的Publisher: extension NotificationCenter { struct Publisher: Combine.Publisher { typealias Output = Notification typealias Failure = Never init(center: NotificationCenter, name: Notification.Name, Object: Any? = nil) } } let signInNotification = Notification.Name.init("user_sign_in") struct SignInInfo { let username: String let password: String } let signInPublisher = NotificationCenter.Publisher(center: .default, name: signInNotification, object: nil) 另外还需要注意的是:Self.Output == S.Input限制了Publisher和Subscriber之间的数据流动必须保持类型一致,大多数时候总是很难维持一致性的,所以Publisher同样提供了map/compactMap的高阶函数对输出值进行转换: /// Subscriber只接收用户名信息 let signInPublisher = NotificationCenter.Publisher(center: .default, name: signInNotification, object: nil) .map { return ($0 as? SignInfo)?.username ?? "unknown" } Subscriber @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public protocol Subscriber : CustomCombineIdentifierConvertible { /// The kind of values this subscriber receives. associatedtype Input /// The kind of errors this subscriber might receive. /// /// Use `Never` if this `Subscriber` cannot receive errors. associatedtype Failure : Error /// Tells the subscriber that it has successfully subscribed to the publisher and may request items. /// /// Use the received `Subscription` to request items from the publisher. /// - Parameter subscription: A subscription that represents the connection between publisher and subscriber. func receive(subscription: Subscription) /// Tells the subscriber that the publisher has produced an element. /// /// - Parameter input: The published element. /// - Returns: A `Demand` instance indicating how many more elements the subcriber expects to receive. func receive(_ input: Self.Input) -> Subscribers.Demand /// Tells the subscriber that the publisher has completed publishing, either normally or with an error. /// /// - Parameter completion: A `Completion` case indicating whether publishing completed normally or with an error. func receive(completion: Subscribers.Completion<Self.Failure>) } Subscriber定义了一套receive接口用来接收Publisher发送的消息,一个完整的订阅流程如下图: 在订阅成功之后,receive(subscription:)会被调用一次,其类型如下: @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public protocol Subscription : Cancellable, CustomCombineIdentifierConvertible { /// Tells a publisher that it may send more values to the subscriber. func request(_ demand: Subscribers.Demand) } Subscription可以认为是单次订阅的会话,其实现了Cancellable接口允许Subscriber中途取消订阅,释放资源。基于上方的NotificationCenter代码,完成Subscriber的接收部分: func registerSignInHandle() { let signInSubscriber = Subscribers.Assign.init(object: self.userNameLabel, keyPath: \.text) signInPublisher.subscribe(signInSubscriber) } func tapped(signIn: UIButton) { LoginManager.manager.signIn(username, password: password) { (err) in guard err == nil else { ERR_LOG("sign in failed: \(err)") return } let info = SignInfo(username: username, password: password) NotificationCenter.default.post(name: signInNotification, object: info) } } Combine与UIKit 得力于Swift5.1的新特性,基于PropertyWrapper和Combine标准库,可以让UIKit同样具备绑定数据流动的能力,预设代码如下: class ViewController: UIViewController { @Publishable(initialValue: "") var text: String let textLabel = UILabel.init(frame: CGRect.init(x: 100, y: 120, width: 120, height: 40)) override func viewDidLoad() { super.viewDidLoad() textLabel.bind(text: $text) let button = UIButton.init(frame: CGRect.init(x: 100, y: 180, width: 120, height: 40)) button.addTarget(self, action: #selector(tapped(button:)), for: .touchUpInside) button.setTitle("random text", for: .normal) button.backgroundColor = .blue view.addSubview(textLabel) view.addSubview(button) } @objc func tapped(button: UIButton) { text = String(arc4random() % 101) } } 每次点击按钮的时候生成随机数字符串,然后textLabel自动更新文本 Publishable 字符串在发生改变的时候需要更新绑定的label,在这里使用PassthroughSubject类对输出值类型做强限制,其结构如下: @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) final public class PassthroughSubject<Output, Failure> : Subject where Failure : Error { public init() /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)` /// /// - SeeAlso: `subscribe(_:)` /// - Parameters: /// - subscriber: The subscriber to attach to this `Publisher`. /// once attached it can begin to receive values. final public func receive<S>(subscriber: S) where Output == S.Input, Failure == S.Failure, S : Subscriber /// Sends a value to the subscriber. /// /// - Parameter value: The value to send. final public func send(_ input: Output) /// Sends a completion signal to the subscriber. /// /// - Parameter completion: A `Completion` instance which indicates whether publishing has finished normally or failed with an error. final public func send(completion: Subscribers.Completion<Failure>) } Publishable的实现代码如下(7.25更新): @propertyWrapper public struct Publishable<Value: Equatable> { private var storage: Value var publisher: PassthroughSubject<Value?, Never> public init(initialValue value: Value) { storage = value publisher = PassthroughSubject<Value?, Never>() Publishers.AllSatisfy } public var wrappedValue: Value { get { storage } set { if storage != newValue { storage = newValue publisher.send(storage) } } } public var projectedValue: Publishable<Value> { get { self } } } UI extensions 通过extension对控件进行扩展支持属性绑定: extension UILabel { func bind(text: Publishable<String>) { let subscriber = Subscribers.Assign.init(object: self, keyPath: \.text) text.publisher.subscribe(subscriber) self.text = text.value } } 这里需要注意的是,创建的Subscriber会被系统的libswiftCore持有,在控制器生命周期结束时,如果不能及时的cancel掉所有的subscriber,会导致内存泄漏: func freeBinding() { subscribers?.forEach { $0.cancel() } subscribers?.removeAll() } 最后放上运行效果: 其他 从今年wwdc发布的新内容,不难看出苹果的野心,由于Swift本身就是一门特别适合编写DSL的语言,而在iOS13上新增的两个标准库让项目的开发成本和维护成本变得更低的特点。由于其极高的可读性,开发者很容易就习惯新的标准库。目前SwiftUI实时用到了UIKit、CoreGraphics等库,可以看做是基于这些库的抽象封装层,随着后续Swift的普及度,苹果底层可以换掉UIKit独立存在,甚至实现跨平台的大前端一统。当然目前苹果上的大前端尚早,不过未来可期 参考阅读 SwiftUI Session Property Wrappers Combine入门导读 新晋网红SwiftUIOOM与内存2019-05-23T08:00:00+08:002019-05-23T08:00:00+08:00www.sindrilin.com/2019/05/23/oom_and_memory<p>微视的<code class="language-plaintext highlighter-rouge">crash log</code>会夹带应用剩余内存信息上报给服务器,希望借此协助诊断崩溃是否由<code class="language-plaintext highlighter-rouge">OOM</code>引发。多数情况下,<code class="language-plaintext highlighter-rouge">OOM</code>不直接导致<code class="language-plaintext highlighter-rouge">crash</code>,而是以<code class="language-plaintext highlighter-rouge">SIGSEGV</code>的信号错误,其操作逻辑更像是内存无法分配,却依然访问这个无效地址:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>int *ptr = malloc(sizeof(int *));
*ptr = 0x100; // crash by access invalid memory
</code></pre></div></div>
<p>下面是一个只保留了主线程调用栈的<code class="language-plaintext highlighter-rouge">crash log</code>(屏蔽部分敏感信息后):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Handler: Signal Handler
Hardware Model: iPhone8,2
Process: microvision [2581]
Path: /var/containers/Bundle/Application/C25EDAC2-4686-4033-A481-88FB80D8CA2F/microvision.app
Identifier: com.tencent.microvision
Version: 5.4.3(645)
Code Type: ARM-64 (Native)
Parent Process: [1]
Date/Time: 2019-05-18 09:13:42.534 +0800
OS Version: iPhone OS 12.1.4 (16D57)
Report Version: 104
SDK start time: 2019-05-18 08:28:51
SDK server time delta: 0 s
last record time delta : 2691534 ms
RDM SDK Version: XXXX
RDM Registed Uin : XXXX
RDM DeviceId: XXXX
RDM CI UUID: XXXX
RDM APP KEY: XXXX
Exception Type: SIGSEGV
Exception Codes: SEGV_ACCERR at 0x0000000000000008
Crashed Thread: 0
Thread 0 Crashed:
0 QuartzCore 0x000000018d09e224 CA::AttrList::set(unsigned int, _CAValueType, void const*) + 176
1 QuartzCore 0x000000018d042674 CA::Layer::setter(unsigned int, _CAValueType, void const*) + 372
2 QuartzCore 0x000000018d03edfc -[CALayer setOpacity:] + 64
3 microvision 0x000000010288ce94 -[XXXX createBlurViewWithFrame:] (XXXX.m:175)
4 microvision 0x000000010288d114 -[XXXX createButtonWithImageName:interactionType:index:] (XXXX.m:199)
5 microvision 0x000000010288bda0 -[XXXX init] (XXXX.m:78)
6 microvision 0x000000010270ceb4 -[XXXX setupView] (XXXX.m:162)
7 microvision 0x000000010270cae4 -[XXXX initWithFrame:] (XXXX.m:140)
8 microvision 0x0000000102677524 -[XXXX commonInit] (XXXX.m:202)
9 microvision 0x0000000102676bf4 -[XXXX initWithFrame:] (XXXX.m:159)
10 microvision 0x0000000102bc5534 -[XXXX initDetailCell] (XXXX.m:21)
11 microvision 0x0000000102bc5630 -[XXXX initWithFrame:] (XXXX.m:33)
12 UIKitCore 0x00000001b54bfe6c -[UICollectionView _dequeueReusableViewOfKind:withIdentifier:forIndexPath:viewCategory:] + 2172
13 UIKitCore 0x00000001b54c01b0 -[UICollectionView dequeueReusableCellWithReuseIdentifier:forIndexPath:] + 180
14 microvision 0x0000000102cb18f8 _$SSo16UICollectionViewC11microvisionE22lk_dequeueReusableCell9indexPathx10Foundation05IndexI0V_tlFSo012WSChannelSetG0C_Tg5Tm + 340
+ 340
15 microvision 0x000000010315cf58 collectionView (WSVideoListBaseController.swift:0)
16 microvision 0x0000000102dc0710 collectionView (WSFeedDetailListViewController.swift:462)
17 microvision 0x0000000102dc5ffc collectionView (<compiler-generated>:0)
18 UIKitCore 0x00000001b54ac39c -[UICollectionView _createPreparedCellForItemAtIndexPath:withLayoutAttributes:applyAttributes:isFocused:notify:] + 356
19 UIKitCore 0x00000001b54b06dc -[UICollectionView _updateVisibleCellsNow:] + 4036
20 UIKitCore 0x00000001b54b577c -[UICollectionView layoutSubviews] + 324
21 UIKitCore 0x00000001b608977c -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1380
22 QuartzCore 0x000000018d03db7c -[CALayer layoutSublayers] + 184
23 QuartzCore 0x000000018d042b34 CA::Layer::layout_if_needed(CA::Transaction*) + 324
24 QuartzCore 0x000000018cfa1598 CA::Context::commit_transaction(CA::Transaction*) + 340
25 QuartzCore 0x000000018cfcfec8 CA::Transaction::commit() + 608
26 QuartzCore 0x000000018cfd0d30 CA::Transaction::observer_callback(__CFRunLoopObserver*, unsigned long, void*) + 92
27 CoreFoundation 0x00000001889d16bc ___CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 32
+ 32
28 CoreFoundation 0x00000001889cc350 ___CFRunLoopDoObservers + 412
29 CoreFoundation 0x00000001889cc8f0 ___CFRunLoopRun + 1264
30 CoreFoundation 0x00000001889cc0e0 CFRunLoopRunSpecific + 436
31 GraphicsServices 0x000000018ac45584 GSEventRunModal + 96
32 UIKitCore 0x00000001b5be0c00 UIApplicationMain + 212
33 microvision 0x0000000102986ea4 main (main.m:40)
34 libdyld.dylib 0x000000018848abb4 _start + 4
</code></pre></div></div>
<p>除了调用栈外,通过<code class="language-plaintext highlighter-rouge">task_vm_info_data_t</code>计算获取的设备可用内存为<code class="language-plaintext highlighter-rouge">1560.81MB</code>,基本可以排除内存不足的可能性。另外,<code class="language-plaintext highlighter-rouge">log</code>中最后的非系统库调用代码如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect: [UIBlurEffect effectWithStyle: UIBlurEffectStyleDark]];
blurView.layer.cornerRadius = kButtonSize / 2;
blurView.layer.masksToBounds = YES;
blurView.frame = frame;
blurView.alpha = 0.5; /// crash发生的位置
[self addSubview: blurView];
</code></pre></div></div>
<p>日志中的<code class="language-plaintext highlighter-rouge">Exception Codes</code>显示的是<code class="language-plaintext highlighter-rouge">SEGV_ACCERR at 0x0000000000000008</code>,由于<code class="language-plaintext highlighter-rouge">ASLR</code>保护技术的存在,运行在<code class="language-plaintext highlighter-rouge">64</code>位的应用会随机分配一个起始偏移地址,起始地址大于<code class="language-plaintext highlighter-rouge">0x100000000</code>,因此被访问的这个地址是无效的危险地址,从客户端代码已经无法定位问题,需要崩溃处的系统<code class="language-plaintext highlighter-rouge">api</code>做追查。</p>
<h3 id="调用还原">调用还原</h3>
<p>利用<code class="language-plaintext highlighter-rouge">Xcode</code>的符号断点很快就获得对应的指令代码(只展示到<code class="language-plaintext highlighter-rouge">crash</code>位置指令为止):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>QuartzCore`CA::AttrList::set:
-> 0x21e47d6cc <+0>: stp x26, x25, [sp, #-0x50]!
0x21e47d6d0 <+4>: stp x24, x23, [sp, #0x10]
0x21e47d6d4 <+8>: stp x22, x21, [sp, #0x20]
0x21e47d6d8 <+12>: stp x20, x19, [sp, #0x30]
0x21e47d6dc <+16>: stp x29, x30, [sp, #0x40]
0x21e47d6e0 <+20>: add x29, sp, #0x40 ; =0x40
0x21e47d6e4 <+24>: mov x20, x3
0x21e47d6e8 <+28>: mov x22, x2
0x21e47d6ec <+32>: mov x23, x1
0x21e47d6f0 <+36>: mov x19, x0
0x21e47d6f4 <+40>: b 0x21e47d71c ; <+80>
0x21e47d6f8 <+44>: mov x21, x19
0x21e47d6fc <+48>: mov x0, x21
0x21e47d700 <+52>: bl 0x21e47d498 ; CA::AttrList::copy_()
0x21e47d704 <+56>: mov x19, x0
0x21e47d708 <+60>: sub w8, w25, #0x1 ; =0x1
0x21e47d70c <+64>: ldr x9, [x21, #0x8]
0x21e47d710 <+68>: and x9, x9, #0xfffffffffffffff8
0x21e47d714 <+72>: orr x8, x9, x8
0x21e47d718 <+76>: str x8, [x21, #0x8]
0x21e47d71c <+80>: mov x24, x19
0x21e47d720 <+84>: ldr w8, [x24, #0x8]!
0x21e47d724 <+88>: ands w25, w8, #0x7
0x21e47d728 <+92>: b.ne 0x21e47d6f8 ; <+44>
0x21e47d72c <+96>: ldr x21, [x19]
0x21e47d730 <+100>: cbz x21, 0x21e47d764 ; <+152>
0x21e47d734 <+104>: mov w8, #0x0
0x21e47d738 <+108>: mov x25, x19
0x21e47d73c <+112>: ldr w9, [x21, #0x8]
0x21e47d740 <+116>: and w10, w9, #0xffffff
0x21e47d744 <+120>: cmp w10, w23
0x21e47d748 <+124>: b.eq 0x21e47d7dc ; <+272>
0x21e47d74c <+128>: cmp w9, #0x0 ; =0x0
0x21e47d750 <+132>: cset w9, lt
0x21e47d754 <+136>: orr w8, w8, w9
0x21e47d758 <+140>: mov x25, x21
0x21e47d75c <+144>: ldr x21, [x21]
0x21e47d760 <+148>: cbnz x21, 0x21e47d73c ; <+112>
0x21e47d764 <+152>: orr w0, wzr, #0x18
0x21e47d768 <+156>: bl 0x21e37ea90 ; get_malloc_zone(unsigned long)
0x21e47d76c <+160>: orr w1, wzr, #0x18
0x21e47d770 <+164>: bl 0x219a17900 ; malloc_zone_malloc
<+168>: mov x21, x0
<+172>: and w8, w23, #0xffffff
<+176>: str w8, [x21, #0x8] // crash
</code></pre></div></div>
<p>从下往上看几个关键指令:</p>
<ul>
<li>
<p><code class="language-plaintext highlighter-rouge"><+176>: str w8, [x21, #0x8]</code></p>
<p>崩溃行指令将<code class="language-plaintext highlighter-rouge">x8</code>寄存器的数据存储到<code class="language-plaintext highlighter-rouge">x21</code>地址偏移<code class="language-plaintext highlighter-rouge">0x8</code>的地址空间中导致了崩溃,结合日志中的<code class="language-plaintext highlighter-rouge">SEGV_ACCERR at 0x0000000000000008</code>,说明<code class="language-plaintext highlighter-rouge">x21</code>的地址值为<code class="language-plaintext highlighter-rouge">0</code></p>
</li>
<li>
<p><code class="language-plaintext highlighter-rouge"><+152>: orr w0, wzr, #0x18</code><br /><code class="language-plaintext highlighter-rouge"><+156>: bl 0x21e37ea90</code><br /><code class="language-plaintext highlighter-rouge"><+160>: orr w1, wzr, #0x18</code><br /><code class="language-plaintext highlighter-rouge"><+164>: bl</code><br /><code class="language-plaintext highlighter-rouge">0x21e47d774 <+168>: mov x21, x0</code></p>
<p>按照<code class="language-plaintext highlighter-rouge">arm64</code>指令的调用约定,函数调用的返回值会存储在<code class="language-plaintext highlighter-rouge">(x/w)0</code>寄存器中,函数参数从左到右依次存放到<code class="language-plaintext highlighter-rouge">(x/w)0 ~ (x/w)7</code>这些寄存器中。<code class="language-plaintext highlighter-rouge">x21</code>寄存器的数据自于函数<code class="language-plaintext highlighter-rouge">malloc_zone_malloc</code>的返回值,根据函数名称可以断定这是内存分配失败后的无效地址访问错误</p>
</li>
</ul>
<p>根据关键指令进行崩溃位置的函数还原伪代码:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CA::AttrList::set(unsigned int arg1, _CAValueType arg2, void const* arg3) {
......
unsigned int size = 24;
_CAValueType *ptr = (_CAValueType *)malloc_zone_malloc(get_malloc_zone(size), size);
_CAValueType val = arg2 & 0xffffff;
ptr++;
*ptr = val; // crash
}
</code></pre></div></div>
<p>通过对函数的汇编还原,基本可以认为这是一起<code class="language-plaintext highlighter-rouge">OOM</code>,但为什么通过<code class="language-plaintext highlighter-rouge">task_vm_info_data_t</code>获取到设备可用内存依然有这么多?</p>
<h3 id="malloc_zone">malloc_zone</h3>
<p>在<code class="language-plaintext highlighter-rouge">macOS/iOS</code>上,苹果使用了名为<code class="language-plaintext highlighter-rouge">malloc_zone</code>的内存分配方式,将内存块的分配按照尺寸拆成不同的<code class="language-plaintext highlighter-rouge">zone</code>来完成,一个<code class="language-plaintext highlighter-rouge">zone</code>可以看做是一系列内存分配相关<code class="language-plaintext highlighter-rouge">api</code>的组合结构,其结构表现如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>typedef struct _malloc_zone_t {
void *reserved1; /* RESERVED FOR CFAllocator DO NOT USE */
void *reserved2; /* RESERVED FOR CFAllocator DO NOT USE */
size_t (* MALLOC_ZONE_FN_PTR(size))(struct _malloc_zone_t *zone, const void *ptr); /* returns the size of a block or 0 if not in this zone; must be fast, especially for negative answers */
void *(* MALLOC_ZONE_FN_PTR(malloc))(struct _malloc_zone_t *zone, size_t size);
void *(* MALLOC_ZONE_FN_PTR(calloc))(struct _malloc_zone_t *zone, size_t num_items, size_t size); /* same as malloc, but block returned is set to zero */
void *(* MALLOC_ZONE_FN_PTR(valloc))(struct _malloc_zone_t *zone, size_t size); /* same as malloc, but block returned is set to zero and is guaranteed to be page aligned */
void (* MALLOC_ZONE_FN_PTR(free))(struct _malloc_zone_t *zone, void *ptr);
void *(* MALLOC_ZONE_FN_PTR(realloc))(struct _malloc_zone_t *zone, void *ptr, size_t size);
void (* MALLOC_ZONE_FN_PTR(destroy))(struct _malloc_zone_t *zone); /* zone is destroyed and all memory reclaimed */
const char *zone_name;
/* Optional batch callbacks; these may be NULL */
unsigned (* MALLOC_ZONE_FN_PTR(batch_malloc))(struct _malloc_zone_t *zone, size_t size, void **results, unsigned num_requested); /* given a size, returns pointers capable of holding that size; returns the number of pointers allocated (maybe 0 or less than num_requested) */
void (* MALLOC_ZONE_FN_PTR(batch_free))(struct _malloc_zone_t *zone, void **to_be_freed, unsigned num_to_be_freed); /* frees all the pointers in to_be_freed; note that to_be_freed may be overwritten during the process */
struct malloc_introspection_t * MALLOC_INTROSPECT_TBL_PTR(introspect);
unsigned version;
/* aligned memory allocation. The callback may be NULL. Present in version >= 5. */
void *(* MALLOC_ZONE_FN_PTR(memalign))(struct _malloc_zone_t *zone, size_t alignment, size_t size);
/* free a pointer known to be in zone and known to have the given size. The callback may be NULL. Present in version >= 6.*/
void (* MALLOC_ZONE_FN_PTR(free_definite_size))(struct _malloc_zone_t *zone, void *ptr, size_t size);
/* Empty out caches in the face of memory pressure. The callback may be NULL. Present in version >= 8. */
size_t (* MALLOC_ZONE_FN_PTR(pressure_relief))(struct _malloc_zone_t *zone, size_t goal);
boolean_t (* MALLOC_ZONE_FN_PTR(claimed_address))(struct _malloc_zone_t *zone, void *ptr);
} malloc_zone_t;
</code></pre></div></div>
<p>部分结构在<code class="language-plaintext highlighter-rouge">malloc/malloc.h</code>头文件中可以找到,关键的分配函数开源在<a href="https://opensource.apple.com/source/libmalloc/libmalloc-116.30.3/src/">libmalloc</a>,不同大小的内存块分配类型如下:</p>
<table>
<thead>
<tr>
<th>类型</th>
<th>尺寸范围</th>
<th>最小分割单位</th>
</tr>
</thead>
<tbody>
<tr>
<td>nano</td>
<td>16~256B</td>
<td>16B</td>
</tr>
<tr>
<td>tiny</td>
<td>16~1008B</td>
<td>16B</td>
</tr>
<tr>
<td>small</td>
<td>1~15KB(设备内存小于1GB) / 1~127KB(大于等于1GB)</td>
<td>512B</td>
</tr>
<tr>
<td>large</td>
<td>15+KB(<1GB) / 127+KB(>=1GB)</td>
<td>内核页表尺寸</td>
</tr>
</tbody>
</table>
<p>在<code class="language-plaintext highlighter-rouge">64</code>位系统上,默认情况下采用<code class="language-plaintext highlighter-rouge">nano</code>类型(<=<code class="language-plaintext highlighter-rouge">256B</code>)的内存分配方式,考虑到这点,下文只描述这种类型的内存分配</p>
<h4 id="结构">结构</h4>
<p>和<code class="language-plaintext highlighter-rouge">nano</code>相关的主要结构体存放在<code class="language-plaintext highlighter-rouge">nano_zone.h</code>当中,标明了重要参数的意义:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/// 可分配内存信息结构
typedef struct nano_meta_s {
OSQueueHead slot_LIFO MALLOC_NANO_CACHE_ALIGN;
unsigned int slot_madvised_log_page_count;
volatile uintptr_t slot_current_base_addr; // 内存分配基址
volatile uintptr_t slot_limit_addr; // 内存分配地址上限
volatile size_t slot_objects_mapped;
volatile size_t slot_objects_skipped;
bitarray_t slot_madvised_pages; // 当前分配地址
volatile uintptr_t slot_bump_addr MALLOC_NANO_CACHE_ALIGN;
volatile boolean_t slot_exhausted;
unsigned int slot_bytes; // 分配的内存size
unsigned int slot_objects; // 可存储的内存块个数
} *nano_meta_admin_t;
/// nano类型分配
typedef struct nanozone_s {
malloc_zone_t basic_zone; // 内存分配zone
uint8_t pad[PAGE_MAX_SIZE - sizeof(malloc_zone_t)];
struct nano_meta_s meta_data[NANO_MAG_SIZE][NANO_SLOT_SIZE]; // 可用内存信息二维列表
_malloc_lock_s band_resupply_lock[NANO_MAG_SIZE]; // 锁
uintptr_t band_max_mapped_baseaddr[NANO_MAG_SIZE];
size_t core_mapped_size[NANO_MAG_SIZE];
unsigned debug_flags;
unsigned our_signature;
unsigned phys_ncpus; // 物理cpu数量
unsigned logical_ncpus; // 逻辑cpu数量
unsigned hyper_shift; // 用于进行逻辑cpu->物理cpu的计算
/* security cookie */
uintptr_t cookie;
malloc_zone_t *helper_zone; // 非nano内存类型的分配zone
} nanozone_t;
</code></pre></div></div>
<p>借用<a href="https://yq.aliyun.com/articles/3065">阿里云栖社区</a>的图表示结构:</p>
<p><img src="http://img1.tbcdn.cn/L1/461/1/4960742425dc6b93cf39f56546db2203a9a0a231" alt="" /></p>
<p><code class="language-plaintext highlighter-rouge">nano_zone</code>使用类似位图的结构体数组来存放可分配的内存地址信息,图中纵向方向表示物理<code class="language-plaintext highlighter-rouge">cpu</code>核心所能分配的内存信息数组。考虑到现代<code class="language-plaintext highlighter-rouge">cpu</code>的超线程设计,一个物理<code class="language-plaintext highlighter-rouge">cpu</code>核心上存在大于<code class="language-plaintext highlighter-rouge">1</code>个的逻辑<code class="language-plaintext highlighter-rouge">cpu</code>执行流,因此使用<code class="language-plaintext highlighter-rouge">hyper_shift</code>来协助获取真实<code class="language-plaintext highlighter-rouge">cpu</code>的信息:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>malloc_zone_t* create_nano_zone(size_t initial_size, malloc_zone_t *helper_zone, unsigned debug_flags) {
......
nanozone->phys_ncpus = *(uint8_t *)(uintptr_t)_COMM_PAGE_PHYSICAL_CPUS;
nanozone->logical_ncpus = *(uint8_t *)(uintptr_t)_COMM_PAGE_LOGICAL_CPUS;
switch (nanozone->logical_ncpus/nanozone->phys_ncpus) {
case 1:
nanozone->hyper_shift = 0;
break;
case 2:
nanozone->hyper_shift = 1;
break;
case 4:
nanozone->hyper_shift = 2;
break;
default:
malloc_printf("*** FATAL ERROR - logical_ncpus / phys_ncpus not 1, 2, or 4.\n");
exit(-1);
}
}
#define NANO_MAG_BITS 5
#define NANO_MAG_SIZE (1 << NANO_MAG_BITS)
#define NANO_MAG_INDEX(nz) (_os_cpu_number() >> nz->hyper_shift)
</code></pre></div></div>
<p>由于<code class="language-plaintext highlighter-rouge">nano</code>可分配的尺寸大小为<code class="language-plaintext highlighter-rouge">16~256B</code>,因此按照<code class="language-plaintext highlighter-rouge">16B</code>最小单位进行分割成<code class="language-plaintext highlighter-rouge">16</code>份</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#define NANO_MAX_SIZE 256 /* Buckets sized {16, 32, 48, 64, 80, 96, 112, ...} */
#define NANO_SLOT_BITS 4
#define NANO_SLOT_SIZE (1 << NANO_SLOT_BITS)
</code></pre></div></div>
<h4 id="分配">分配</h4>
<p><a href="https://opensource.apple.com/source/libmalloc/libmalloc-116.30.3/src/malloc.c.auto.html">malloc.c</a>中可以看到<code class="language-plaintext highlighter-rouge">malloc_zone_malloc</code>的源码实现:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>void *
malloc_zone_malloc(malloc_zone_t *zone, size_t size)
{
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);
void *ptr;
if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
internal_check();
}
if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
return NULL;
}
ptr = zone->malloc(zone, size); // 核心实现
if (malloc_logger) {
malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
}
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
return ptr;
}
</code></pre></div></div>
<p>整个内存分配的实现分为三步:</p>
<ol>
<li>安全条件检测</li>
<li>通过<code class="language-plaintext highlighter-rouge">zone</code>申请内存</li>
<li>日志记录输出</li>
</ol>
<p><code class="language-plaintext highlighter-rouge">nano_zone</code>与内存分配相关的初始化同样放在<code class="language-plaintext highlighter-rouge">create_nano_zone</code>中:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>void *
malloc_zone_malloc(malloc_zone_t *zone, size_t size)
{
nanozone->basic_zone.version = 8;
nanozone->basic_zone.size = (void *)nano_size;
nanozone->basic_zone.malloc = (debug_flags & SCALABLE_MALLOC_DO_SCRIBBLE) ? (void *)nano_malloc_scribble : (void *)nano_malloc;
nanozone->basic_zone.calloc = (void *)nano_calloc;
nanozone->basic_zone.valloc = (void *)nano_valloc;
nanozone->basic_zone.free = (debug_flags & SCALABLE_MALLOC_DO_SCRIBBLE) ? (void *)nano_free_scribble : (void *)nano_free;
nanozone->basic_zone.realloc = (void *)nano_realloc;
nanozone->basic_zone.destroy = (void *)nano_destroy;
nanozone->basic_zone.batch_malloc = (void *)nano_batch_malloc;
nanozone->basic_zone.batch_free = (void *)nano_batch_free;
nanozone->basic_zone.introspect = (struct malloc_introspection_t *)&nano_introspect;
nanozone->basic_zone.memalign = (void *)nano_memalign;
nanozone->basic_zone.free_definite_size = (debug_flags & SCALABLE_MALLOC_DO_SCRIBBLE) ?
(void *)nano_free_definite_size_scribble : (void *)nano_free_definite_size;
}
</code></pre></div></div>
<p>默认情况下<code class="language-plaintext highlighter-rouge">SCALABLE_MALLOC_DO_SCRIBBLE</code>总是不启用的,因此申请内存最终走到了<code class="language-plaintext highlighter-rouge">nano_malloc</code>函数中(移除<code class="language-plaintext highlighter-rouge">DEBUG</code>代码):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>static void *
nano_malloc(nanozone_t *nanozone, size_t size)
{
if (size <= NANO_MAX_SIZE) {
void *p = _nano_malloc_check_clear(nanozone, size, 0);
if (p) {
return p;
} else {
/* FALLTHROUGH to helper zone */
}
}
malloc_zone_t *zone = (malloc_zone_t *)(nanozone->helper_zone);
return zone->malloc(zone, size);
}
static MALLOC_INLINE size_t
segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
size_t k, slot_bytes;
if (0 == size) {
size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
}
k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
slot_bytes = k << SHIFT_NANO_QUANTUM; // multiply by power of two quanta size
*pKey = k - 1; // Zero-based!
return slot_bytes;
}
static void *
_nano_malloc_check_clear(nanozone_t *nanozone, size_t size, boolean_t cleared_requested)
{
// 记录输出
MALLOC_TRACE(TRACE_nano_malloc, (uintptr_t)nanozone, size, cleared_requested, 0);
/// 获取cpu对应的index以及内存size对应的slot
void *ptr;
size_t slot_key;
size_t slot_bytes = segregated_size_to_fit(nanozone, size, &slot_key); // Note slot_key is set here
unsigned int mag_index = NANO_MAG_INDEX(nanozone);
nano_meta_admin_t pMeta = &(nanozone->meta_data[mag_index][slot_key]);
/// 检测最近释放的内存块是否存在可用的
ptr = OSAtomicDequeue(&(pMeta->slot_LIFO), offsetof(struct chained_block_s, next));
if (ptr) {
#if NANO_FREE_DEQUEUE_DILIGENCE
size_t gotSize;
nano_blk_addr_t p; // the compiler holds this in a register
/// 检测nano标记位
p.addr = (uint64_t)ptr;
if (nanozone->our_signature != p.fields.nano_signature) {
nanozone_error(nanozone, 1, "Invalid signature for pointer dequeued from free list", ptr, NULL);
}
/// 检测cpu核心是否相同
if (mag_index != p.fields.nano_mag_index) {
nanozone_error(nanozone, 1, "Mismatched magazine for pointer dequeued from free list", ptr, NULL);
}
/// 校验slot信息
gotSize = _nano_vet_and_size_of_free(nanozone, ptr);
if (0 == gotSize) {
nanozone_error(nanozone, 1, "Invalid pointer dequeued from free list", ptr, NULL);
}
if (gotSize != slot_bytes) {
nanozone_error(nanozone, 1, "Mismatched size for pointer dequeued from free list", ptr, NULL);
}
if ((((chained_block_t)ptr)->double_free_guard ^ nanozone->cookie) != 0xBADDC0DEDEADBEADULL) {
nanozone_error(nanozone, 1, "Heap corruption detected, free list canary is damaged", ptr, NULL);
}
#endif /* NANO_FREE_DEQUEUE_DILIGENCE */
((chained_block_t)ptr)->double_free_guard = 0;
((chained_block_t)ptr)->next = NULL; // clear out next pointer to protect free list
} else {
/// 获取新的内存
ptr = segregated_next_block(nanozone, pMeta, slot_bytes, mag_index);
}
if (cleared_requested && ptr) {
memset(ptr, 0, slot_bytes); // TODO: Needs a memory barrier after memset to ensure zeroes land first?
}
return ptr;
}
</code></pre></div></div>
<p>由于涉及到的函数过多且篇幅问题,想进一步了解<code class="language-plaintext highlighter-rouge">slot</code>所属的内存块<code class="language-plaintext highlighter-rouge">band</code>的分配规则,建议阅读源码。最后同样放上一张同样来自云栖社区的非<code class="language-plaintext highlighter-rouge">nano</code>类型分配的结构图,配合源码阅读效果更佳:</p>
<p><img src="http://img1.tbcdn.cn/L1/461/1/c50505c7577e0d883c3a33c21c1a0763ae408350" alt="" /></p>
<h3 id="结论">结论</h3>
<p>苹果系统将内存分配分类并使用不同的<code class="language-plaintext highlighter-rouge">zone</code>完成内存申请,因此在应用启动之后,每一种类型能够使用的地址范围已经被限定。这导致了即便只有单一类型的内存地址被分配完毕,设备依然有可用内存的情况下,同样会引发<code class="language-plaintext highlighter-rouge">OOM</code>问题</p>sindrilinsindrilin@foxmail.com微视的crash log会夹带应用剩余内存信息上报给服务器,希望借此协助诊断崩溃是否由OOM引发。多数情况下,OOM不直接导致crash,而是以SIGSEGV的信号错误,其操作逻辑更像是内存无法分配,却依然访问这个无效地址: int *ptr = malloc(sizeof(int *)); *ptr = 0x100; // crash by access invalid memory 下面是一个只保留了主线程调用栈的crash log(屏蔽部分敏感信息后): Handler: Signal Handler Hardware Model: iPhone8,2 Process: microvision [2581] Path: /var/containers/Bundle/Application/C25EDAC2-4686-4033-A481-88FB80D8CA2F/microvision.app Identifier: com.tencent.microvision Version: 5.4.3(645) Code Type: ARM-64 (Native) Parent Process: [1] Date/Time: 2019-05-18 09:13:42.534 +0800 OS Version: iPhone OS 12.1.4 (16D57) Report Version: 104 SDK start time: 2019-05-18 08:28:51 SDK server time delta: 0 s last record time delta : 2691534 ms RDM SDK Version: XXXX RDM Registed Uin : XXXX RDM DeviceId: XXXX RDM CI UUID: XXXX RDM APP KEY: XXXX Exception Type: SIGSEGV Exception Codes: SEGV_ACCERR at 0x0000000000000008 Crashed Thread: 0 Thread 0 Crashed: 0 QuartzCore 0x000000018d09e224 CA::AttrList::set(unsigned int, _CAValueType, void const*) + 176 1 QuartzCore 0x000000018d042674 CA::Layer::setter(unsigned int, _CAValueType, void const*) + 372 2 QuartzCore 0x000000018d03edfc -[CALayer setOpacity:] + 64 3 microvision 0x000000010288ce94 -[XXXX createBlurViewWithFrame:] (XXXX.m:175) 4 microvision 0x000000010288d114 -[XXXX createButtonWithImageName:interactionType:index:] (XXXX.m:199) 5 microvision 0x000000010288bda0 -[XXXX init] (XXXX.m:78) 6 microvision 0x000000010270ceb4 -[XXXX setupView] (XXXX.m:162) 7 microvision 0x000000010270cae4 -[XXXX initWithFrame:] (XXXX.m:140) 8 microvision 0x0000000102677524 -[XXXX commonInit] (XXXX.m:202) 9 microvision 0x0000000102676bf4 -[XXXX initWithFrame:] (XXXX.m:159) 10 microvision 0x0000000102bc5534 -[XXXX initDetailCell] (XXXX.m:21) 11 microvision 0x0000000102bc5630 -[XXXX initWithFrame:] (XXXX.m:33) 12 UIKitCore 0x00000001b54bfe6c -[UICollectionView _dequeueReusableViewOfKind:withIdentifier:forIndexPath:viewCategory:] + 2172 13 UIKitCore 0x00000001b54c01b0 -[UICollectionView dequeueReusableCellWithReuseIdentifier:forIndexPath:] + 180 14 microvision 0x0000000102cb18f8 _$SSo16UICollectionViewC11microvisionE22lk_dequeueReusableCell9indexPathx10Foundation05IndexI0V_tlFSo012WSChannelSetG0C_Tg5Tm + 340 + 340 15 microvision 0x000000010315cf58 collectionView (WSVideoListBaseController.swift:0) 16 microvision 0x0000000102dc0710 collectionView (WSFeedDetailListViewController.swift:462) 17 microvision 0x0000000102dc5ffc collectionView (<compiler-generated>:0) 18 UIKitCore 0x00000001b54ac39c -[UICollectionView _createPreparedCellForItemAtIndexPath:withLayoutAttributes:applyAttributes:isFocused:notify:] + 356 19 UIKitCore 0x00000001b54b06dc -[UICollectionView _updateVisibleCellsNow:] + 4036 20 UIKitCore 0x00000001b54b577c -[UICollectionView layoutSubviews] + 324 21 UIKitCore 0x00000001b608977c -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1380 22 QuartzCore 0x000000018d03db7c -[CALayer layoutSublayers] + 184 23 QuartzCore 0x000000018d042b34 CA::Layer::layout_if_needed(CA::Transaction*) + 324 24 QuartzCore 0x000000018cfa1598 CA::Context::commit_transaction(CA::Transaction*) + 340 25 QuartzCore 0x000000018cfcfec8 CA::Transaction::commit() + 608 26 QuartzCore 0x000000018cfd0d30 CA::Transaction::observer_callback(__CFRunLoopObserver*, unsigned long, void*) + 92 27 CoreFoundation 0x00000001889d16bc ___CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 32 + 32 28 CoreFoundation 0x00000001889cc350 ___CFRunLoopDoObservers + 412 29 CoreFoundation 0x00000001889cc8f0 ___CFRunLoopRun + 1264 30 CoreFoundation 0x00000001889cc0e0 CFRunLoopRunSpecific + 436 31 GraphicsServices 0x000000018ac45584 GSEventRunModal + 96 32 UIKitCore 0x00000001b5be0c00 UIApplicationMain + 212 33 microvision 0x0000000102986ea4 main (main.m:40) 34 libdyld.dylib 0x000000018848abb4 _start + 4 除了调用栈外,通过task_vm_info_data_t计算获取的设备可用内存为1560.81MB,基本可以排除内存不足的可能性。另外,log中最后的非系统库调用代码如下: UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect: [UIBlurEffect effectWithStyle: UIBlurEffectStyleDark]]; blurView.layer.cornerRadius = kButtonSize / 2; blurView.layer.masksToBounds = YES; blurView.frame = frame; blurView.alpha = 0.5; /// crash发生的位置 [self addSubview: blurView]; 日志中的Exception Codes显示的是SEGV_ACCERR at 0x0000000000000008,由于ASLR保护技术的存在,运行在64位的应用会随机分配一个起始偏移地址,起始地址大于0x100000000,因此被访问的这个地址是无效的危险地址,从客户端代码已经无法定位问题,需要崩溃处的系统api做追查。 调用还原 利用Xcode的符号断点很快就获得对应的指令代码(只展示到crash位置指令为止): QuartzCore`CA::AttrList::set: -> 0x21e47d6cc <+0>: stp x26, x25, [sp, #-0x50]! 0x21e47d6d0 <+4>: stp x24, x23, [sp, #0x10] 0x21e47d6d4 <+8>: stp x22, x21, [sp, #0x20] 0x21e47d6d8 <+12>: stp x20, x19, [sp, #0x30] 0x21e47d6dc <+16>: stp x29, x30, [sp, #0x40] 0x21e47d6e0 <+20>: add x29, sp, #0x40 ; =0x40 0x21e47d6e4 <+24>: mov x20, x3 0x21e47d6e8 <+28>: mov x22, x2 0x21e47d6ec <+32>: mov x23, x1 0x21e47d6f0 <+36>: mov x19, x0 0x21e47d6f4 <+40>: b 0x21e47d71c ; <+80> 0x21e47d6f8 <+44>: mov x21, x19 0x21e47d6fc <+48>: mov x0, x21 0x21e47d700 <+52>: bl 0x21e47d498 ; CA::AttrList::copy_() 0x21e47d704 <+56>: mov x19, x0 0x21e47d708 <+60>: sub w8, w25, #0x1 ; =0x1 0x21e47d70c <+64>: ldr x9, [x21, #0x8] 0x21e47d710 <+68>: and x9, x9, #0xfffffffffffffff8 0x21e47d714 <+72>: orr x8, x9, x8 0x21e47d718 <+76>: str x8, [x21, #0x8] 0x21e47d71c <+80>: mov x24, x19 0x21e47d720 <+84>: ldr w8, [x24, #0x8]! 0x21e47d724 <+88>: ands w25, w8, #0x7 0x21e47d728 <+92>: b.ne 0x21e47d6f8 ; <+44> 0x21e47d72c <+96>: ldr x21, [x19] 0x21e47d730 <+100>: cbz x21, 0x21e47d764 ; <+152> 0x21e47d734 <+104>: mov w8, #0x0 0x21e47d738 <+108>: mov x25, x19 0x21e47d73c <+112>: ldr w9, [x21, #0x8] 0x21e47d740 <+116>: and w10, w9, #0xffffff 0x21e47d744 <+120>: cmp w10, w23 0x21e47d748 <+124>: b.eq 0x21e47d7dc ; <+272> 0x21e47d74c <+128>: cmp w9, #0x0 ; =0x0 0x21e47d750 <+132>: cset w9, lt 0x21e47d754 <+136>: orr w8, w8, w9 0x21e47d758 <+140>: mov x25, x21 0x21e47d75c <+144>: ldr x21, [x21] 0x21e47d760 <+148>: cbnz x21, 0x21e47d73c ; <+112> 0x21e47d764 <+152>: orr w0, wzr, #0x18 0x21e47d768 <+156>: bl 0x21e37ea90 ; get_malloc_zone(unsigned long) 0x21e47d76c <+160>: orr w1, wzr, #0x18 0x21e47d770 <+164>: bl 0x219a17900 ; malloc_zone_malloc <+168>: mov x21, x0 <+172>: and w8, w23, #0xffffff <+176>: str w8, [x21, #0x8] // crash 从下往上看几个关键指令: <+176>: str w8, [x21, #0x8] 崩溃行指令将x8寄存器的数据存储到x21地址偏移0x8的地址空间中导致了崩溃,结合日志中的SEGV_ACCERR at 0x0000000000000008,说明x21的地址值为0 <+152>: orr w0, wzr, #0x18<+156>: bl 0x21e37ea90<+160>: orr w1, wzr, #0x18<+164>: bl0x21e47d774 <+168>: mov x21, x0 按照arm64指令的调用约定,函数调用的返回值会存储在(x/w)0寄存器中,函数参数从左到右依次存放到(x/w)0 ~ (x/w)7这些寄存器中。x21寄存器的数据自于函数malloc_zone_malloc的返回值,根据函数名称可以断定这是内存分配失败后的无效地址访问错误 根据关键指令进行崩溃位置的函数还原伪代码: CA::AttrList::set(unsigned int arg1, _CAValueType arg2, void const* arg3) { ...... unsigned int size = 24; _CAValueType *ptr = (_CAValueType *)malloc_zone_malloc(get_malloc_zone(size), size); _CAValueType val = arg2 & 0xffffff; ptr++; *ptr = val; // crash } 通过对函数的汇编还原,基本可以认为这是一起OOM,但为什么通过task_vm_info_data_t获取到设备可用内存依然有这么多? malloc_zone 在macOS/iOS上,苹果使用了名为malloc_zone的内存分配方式,将内存块的分配按照尺寸拆成不同的zone来完成,一个zone可以看做是一系列内存分配相关api的组合结构,其结构表现如下: typedef struct _malloc_zone_t { void *reserved1; /* RESERVED FOR CFAllocator DO NOT USE */ void *reserved2; /* RESERVED FOR CFAllocator DO NOT USE */ size_t (* MALLOC_ZONE_FN_PTR(size))(struct _malloc_zone_t *zone, const void *ptr); /* returns the size of a block or 0 if not in this zone; must be fast, especially for negative answers */ void *(* MALLOC_ZONE_FN_PTR(malloc))(struct _malloc_zone_t *zone, size_t size); void *(* MALLOC_ZONE_FN_PTR(calloc))(struct _malloc_zone_t *zone, size_t num_items, size_t size); /* same as malloc, but block returned is set to zero */ void *(* MALLOC_ZONE_FN_PTR(valloc))(struct _malloc_zone_t *zone, size_t size); /* same as malloc, but block returned is set to zero and is guaranteed to be page aligned */ void (* MALLOC_ZONE_FN_PTR(free))(struct _malloc_zone_t *zone, void *ptr); void *(* MALLOC_ZONE_FN_PTR(realloc))(struct _malloc_zone_t *zone, void *ptr, size_t size); void (* MALLOC_ZONE_FN_PTR(destroy))(struct _malloc_zone_t *zone); /* zone is destroyed and all memory reclaimed */ const char *zone_name; /* Optional batch callbacks; these may be NULL */ unsigned (* MALLOC_ZONE_FN_PTR(batch_malloc))(struct _malloc_zone_t *zone, size_t size, void **results, unsigned num_requested); /* given a size, returns pointers capable of holding that size; returns the number of pointers allocated (maybe 0 or less than num_requested) */ void (* MALLOC_ZONE_FN_PTR(batch_free))(struct _malloc_zone_t *zone, void **to_be_freed, unsigned num_to_be_freed); /* frees all the pointers in to_be_freed; note that to_be_freed may be overwritten during the process */ struct malloc_introspection_t * MALLOC_INTROSPECT_TBL_PTR(introspect); unsigned version; /* aligned memory allocation. The callback may be NULL. Present in version >= 5. */ void *(* MALLOC_ZONE_FN_PTR(memalign))(struct _malloc_zone_t *zone, size_t alignment, size_t size); /* free a pointer known to be in zone and known to have the given size. The callback may be NULL. Present in version >= 6.*/ void (* MALLOC_ZONE_FN_PTR(free_definite_size))(struct _malloc_zone_t *zone, void *ptr, size_t size); /* Empty out caches in the face of memory pressure. The callback may be NULL. Present in version >= 8. */ size_t (* MALLOC_ZONE_FN_PTR(pressure_relief))(struct _malloc_zone_t *zone, size_t goal); boolean_t (* MALLOC_ZONE_FN_PTR(claimed_address))(struct _malloc_zone_t *zone, void *ptr); } malloc_zone_t; 部分结构在malloc/malloc.h头文件中可以找到,关键的分配函数开源在libmalloc,不同大小的内存块分配类型如下: 类型 尺寸范围 最小分割单位 nano 16~256B 16B tiny 16~1008B 16B small 1~15KB(设备内存小于1GB) / 1~127KB(大于等于1GB) 512B large 15+KB(<1GB) / 127+KB(>=1GB) 内核页表尺寸 在64位系统上,默认情况下采用nano类型(<=256B)的内存分配方式,考虑到这点,下文只描述这种类型的内存分配 结构 和nano相关的主要结构体存放在nano_zone.h当中,标明了重要参数的意义: /// 可分配内存信息结构 typedef struct nano_meta_s { OSQueueHead slot_LIFO MALLOC_NANO_CACHE_ALIGN; unsigned int slot_madvised_log_page_count; volatile uintptr_t slot_current_base_addr; // 内存分配基址 volatile uintptr_t slot_limit_addr; // 内存分配地址上限 volatile size_t slot_objects_mapped; volatile size_t slot_objects_skipped; bitarray_t slot_madvised_pages; // 当前分配地址 volatile uintptr_t slot_bump_addr MALLOC_NANO_CACHE_ALIGN; volatile boolean_t slot_exhausted; unsigned int slot_bytes; // 分配的内存size unsigned int slot_objects; // 可存储的内存块个数 } *nano_meta_admin_t; /// nano类型分配 typedef struct nanozone_s { malloc_zone_t basic_zone; // 内存分配zone uint8_t pad[PAGE_MAX_SIZE - sizeof(malloc_zone_t)]; struct nano_meta_s meta_data[NANO_MAG_SIZE][NANO_SLOT_SIZE]; // 可用内存信息二维列表 _malloc_lock_s band_resupply_lock[NANO_MAG_SIZE]; // 锁 uintptr_t band_max_mapped_baseaddr[NANO_MAG_SIZE]; size_t core_mapped_size[NANO_MAG_SIZE]; unsigned debug_flags; unsigned our_signature; unsigned phys_ncpus; // 物理cpu数量 unsigned logical_ncpus; // 逻辑cpu数量 unsigned hyper_shift; // 用于进行逻辑cpu->物理cpu的计算 /* security cookie */ uintptr_t cookie; malloc_zone_t *helper_zone; // 非nano内存类型的分配zone } nanozone_t; 借用阿里云栖社区的图表示结构: nano_zone使用类似位图的结构体数组来存放可分配的内存地址信息,图中纵向方向表示物理cpu核心所能分配的内存信息数组。考虑到现代cpu的超线程设计,一个物理cpu核心上存在大于1个的逻辑cpu执行流,因此使用hyper_shift来协助获取真实cpu的信息: malloc_zone_t* create_nano_zone(size_t initial_size, malloc_zone_t *helper_zone, unsigned debug_flags) { ...... nanozone->phys_ncpus = *(uint8_t *)(uintptr_t)_COMM_PAGE_PHYSICAL_CPUS; nanozone->logical_ncpus = *(uint8_t *)(uintptr_t)_COMM_PAGE_LOGICAL_CPUS; switch (nanozone->logical_ncpus/nanozone->phys_ncpus) { case 1: nanozone->hyper_shift = 0; break; case 2: nanozone->hyper_shift = 1; break; case 4: nanozone->hyper_shift = 2; break; default: malloc_printf("*** FATAL ERROR - logical_ncpus / phys_ncpus not 1, 2, or 4.\n"); exit(-1); } } #define NANO_MAG_BITS 5 #define NANO_MAG_SIZE (1 << NANO_MAG_BITS) #define NANO_MAG_INDEX(nz) (_os_cpu_number() >> nz->hyper_shift) 由于nano可分配的尺寸大小为16~256B,因此按照16B最小单位进行分割成16份 #define NANO_MAX_SIZE 256 /* Buckets sized {16, 32, 48, 64, 80, 96, 112, ...} */ #define NANO_SLOT_BITS 4 #define NANO_SLOT_SIZE (1 << NANO_SLOT_BITS) 分配 malloc.c中可以看到malloc_zone_malloc的源码实现: void * malloc_zone_malloc(malloc_zone_t *zone, size_t size) { MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0); void *ptr; if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) { internal_check(); } if (size > MALLOC_ABSOLUTE_MAX_SIZE) { return NULL; } ptr = zone->malloc(zone, size); // 核心实现 if (malloc_logger) { malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0); } MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0); return ptr; } 整个内存分配的实现分为三步: 安全条件检测 通过zone申请内存 日志记录输出 nano_zone与内存分配相关的初始化同样放在create_nano_zone中: void * malloc_zone_malloc(malloc_zone_t *zone, size_t size) { nanozone->basic_zone.version = 8; nanozone->basic_zone.size = (void *)nano_size; nanozone->basic_zone.malloc = (debug_flags & SCALABLE_MALLOC_DO_SCRIBBLE) ? (void *)nano_malloc_scribble : (void *)nano_malloc; nanozone->basic_zone.calloc = (void *)nano_calloc; nanozone->basic_zone.valloc = (void *)nano_valloc; nanozone->basic_zone.free = (debug_flags & SCALABLE_MALLOC_DO_SCRIBBLE) ? (void *)nano_free_scribble : (void *)nano_free; nanozone->basic_zone.realloc = (void *)nano_realloc; nanozone->basic_zone.destroy = (void *)nano_destroy; nanozone->basic_zone.batch_malloc = (void *)nano_batch_malloc; nanozone->basic_zone.batch_free = (void *)nano_batch_free; nanozone->basic_zone.introspect = (struct malloc_introspection_t *)&nano_introspect; nanozone->basic_zone.memalign = (void *)nano_memalign; nanozone->basic_zone.free_definite_size = (debug_flags & SCALABLE_MALLOC_DO_SCRIBBLE) ? (void *)nano_free_definite_size_scribble : (void *)nano_free_definite_size; } 默认情况下SCALABLE_MALLOC_DO_SCRIBBLE总是不启用的,因此申请内存最终走到了nano_malloc函数中(移除DEBUG代码): static void * nano_malloc(nanozone_t *nanozone, size_t size) { if (size <= NANO_MAX_SIZE) { void *p = _nano_malloc_check_clear(nanozone, size, 0); if (p) { return p; } else { /* FALLTHROUGH to helper zone */ } } malloc_zone_t *zone = (malloc_zone_t *)(nanozone->helper_zone); return zone->malloc(zone, size); } static MALLOC_INLINE size_t segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey) { size_t k, slot_bytes; if (0 == size) { size = NANO_REGIME_QUANTA_SIZE; // Historical behavior } k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta slot_bytes = k << SHIFT_NANO_QUANTUM; // multiply by power of two quanta size *pKey = k - 1; // Zero-based! return slot_bytes; } static void * _nano_malloc_check_clear(nanozone_t *nanozone, size_t size, boolean_t cleared_requested) { // 记录输出 MALLOC_TRACE(TRACE_nano_malloc, (uintptr_t)nanozone, size, cleared_requested, 0); /// 获取cpu对应的index以及内存size对应的slot void *ptr; size_t slot_key; size_t slot_bytes = segregated_size_to_fit(nanozone, size, &slot_key); // Note slot_key is set here unsigned int mag_index = NANO_MAG_INDEX(nanozone); nano_meta_admin_t pMeta = &(nanozone->meta_data[mag_index][slot_key]); /// 检测最近释放的内存块是否存在可用的 ptr = OSAtomicDequeue(&(pMeta->slot_LIFO), offsetof(struct chained_block_s, next)); if (ptr) { #if NANO_FREE_DEQUEUE_DILIGENCE size_t gotSize; nano_blk_addr_t p; // the compiler holds this in a register /// 检测nano标记位 p.addr = (uint64_t)ptr; if (nanozone->our_signature != p.fields.nano_signature) { nanozone_error(nanozone, 1, "Invalid signature for pointer dequeued from free list", ptr, NULL); } /// 检测cpu核心是否相同 if (mag_index != p.fields.nano_mag_index) { nanozone_error(nanozone, 1, "Mismatched magazine for pointer dequeued from free list", ptr, NULL); } /// 校验slot信息 gotSize = _nano_vet_and_size_of_free(nanozone, ptr); if (0 == gotSize) { nanozone_error(nanozone, 1, "Invalid pointer dequeued from free list", ptr, NULL); } if (gotSize != slot_bytes) { nanozone_error(nanozone, 1, "Mismatched size for pointer dequeued from free list", ptr, NULL); } if ((((chained_block_t)ptr)->double_free_guard ^ nanozone->cookie) != 0xBADDC0DEDEADBEADULL) { nanozone_error(nanozone, 1, "Heap corruption detected, free list canary is damaged", ptr, NULL); } #endif /* NANO_FREE_DEQUEUE_DILIGENCE */ ((chained_block_t)ptr)->double_free_guard = 0; ((chained_block_t)ptr)->next = NULL; // clear out next pointer to protect free list } else { /// 获取新的内存 ptr = segregated_next_block(nanozone, pMeta, slot_bytes, mag_index); } if (cleared_requested && ptr) { memset(ptr, 0, slot_bytes); // TODO: Needs a memory barrier after memset to ensure zeroes land first? } return ptr; } 由于涉及到的函数过多且篇幅问题,想进一步了解slot所属的内存块band的分配规则,建议阅读源码。最后同样放上一张同样来自云栖社区的非nano类型分配的结构图,配合源码阅读效果更佳: 结论 苹果系统将内存分配分类并使用不同的zone完成内存申请,因此在应用启动之后,每一种类型能够使用的地址范围已经被限定。这导致了即便只有单一类型的内存地址被分配完毕,设备依然有可用内存的情况下,同样会引发OOM问题记一次重构2019-01-05T08:00:00+08:002019-01-05T08:00:00+08:00www.sindrilin.com/2019/01/05/one_reconstruct<h2 id="技术重构">技术重构</h2>
<p>重构是软件开发过程中不断对软件代码进行打散重组,提高代码稳定性和可读性的处理手段之一。对【技术重构】进行关键信息提炼可以得到思维导图:</p>
<p><img src="https://upload-images.jianshu.io/upload_images/783864-cd8ff8a95884d63c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" alt="" /></p>
<p>本文以微视最近一次音乐播放功能的重构为例回顾重构过程</p>
<h2 id="重构步骤">重构步骤</h2>
<h3 id="业务梳理">业务梳理</h3>
<p>微视<code class="language-plaintext highlighter-rouge">4.8</code>版本增加了音乐榜单功能,在更早之前的版本拥有音乐播放的界面只有音乐聚合页,相较于音乐聚合页同时只有一首歌曲需要控制播放,音乐榜单页存在切歌、榜单切换的场景,逻辑处置起来要棘手的多。另外由于音乐播放应该是一个通用能力,在重构前控制器需要维护<code class="language-plaintext highlighter-rouge">AVPlayer</code>的各种状态,代码格式如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- (void)observeValueForKeyPath: (NSString *)keyPath
ofObject: (id)object
change: (NSDictionary<NSKeyValueChangeKey,id> *)change
context: (void *)context {
if ([UIApplication sharedApplication].applicationState != UIApplicationStateActive) {
return;
}
if ([keyPath isEqualToString: @"rate"]) {
NSNumber *val = change[NSKeyValueChangeNewKey];
[self updateStateWhenRateChanged: val.doubleValue];
}
else if ([keyPath isEqualToString: @"status"]) {
if (self.audioPlayer.currentItem.status != AVPlayerItemStatusReadyToPlay) {
return;
}
[self updateStateWhenReadyToPlay];
}
else if ([keyPath isEqualToString: @"loadedTimeRanges"]) {
NSArray *ranges = change[NSKeyValueChangeNewKey];
[self updateStateWhenLoadedTimeChanged: ranges];
}
}
</code></pre></div></div>
<p>由于<code class="language-plaintext highlighter-rouge">KVO</code>存在强引用,为了避免存在的内存泄漏,还需要在控制器<code class="language-plaintext highlighter-rouge">disappear</code>的时候去移除监听:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- (void)viewDidDisappear: (BOOL)animated {
[super viewDidDisappear: animated];
[self removeAudioPlayerObservers];
}
- (void)addAudioPlayerObservers {
if (!self.audioPlayer) {
return;
}
[self.audioPlayer addObserver: self forKeyPath: @"rate" options: NSKeyValueObservingOptionNew context: nil];
[self.audioPlayer.currentItem addObserver: self forKeyPath: @"status" options: NSKeyValueObservingOptionNew context: nil];
[self.audioPlayer.currentItem addObserver: self forKeyPath: @"loadedTimeRanges" options: NSKeyValueObservingOptionNew context: nil];
}
- (void)removeAudioPlayerObservers {
if (!self.audioPlayer) {
return;
}
self.audioPlayer removeObserver: self forKeyPath: @"rate" context: nil];
self.audioPlayer.currentItem removeObserver: self forKeyPath: @"status" context: nil];
self.audioPlayer.currentItem removeObserver: self forKeyPath: @"loadedTimeRanges" context: nil];
}
</code></pre></div></div>
<p>对于音乐榜单页来说,由于音乐列表的存在,需要维护<code class="language-plaintext highlighter-rouge">loading</code>、<code class="language-plaintext highlighter-rouge">pause</code>、<code class="language-plaintext highlighter-rouge">playing</code>和<code class="language-plaintext highlighter-rouge">idle</code>四种状态值,以便能正确显示视图的状态。这种情况下将音乐播放分离出来有三个原因:</p>
<ol>
<li>多种改变播放状态的逻辑无法统一处理</li>
<li>控制器管理了不属于自身的逻辑</li>
<li>监听方式增加代码的维护成本</li>
</ol>
<h3 id="明确目标">明确目标</h3>
<p>由于<code class="language-plaintext highlighter-rouge">AVFoundation</code>提供的音乐播放器需要进行额外的配置以及维护状态,这部分的代码属于通用逻辑,不易放在<code class="language-plaintext highlighter-rouge">controller</code>这种业务上层中,因此通过添加一个间接层实现播放器的控制逻辑。计划重构后的结构如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> -------------------
控制器 | ViewController |
-------------------
↓
↓
-------------------
请求层 | WSMusicPlayer |
-------------------
↓
↓
-------------------
核心层 | AVFoundation |
-------------------
</code></pre></div></div>
<p>分离后的播放器对外提供少量的控制接口,以及通过<code class="language-plaintext highlighter-rouge">delegate</code>统一状态变化的回调:</p>
<p><img src="https://upload-images.jianshu.io/upload_images/783864-f620cf023ed8e8b4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" alt="" /></p>
<h3 id="动手实践">动手实践</h3>
<p>分离之后的<code class="language-plaintext highlighter-rouge">musicPlayer</code>主要有三处设计点:</p>
<blockquote>
<p><code class="language-plaintext highlighter-rouge">KVO</code>的引用分离</p>
</blockquote>
<p><code class="language-plaintext highlighter-rouge">KVO</code>对象无法在<code class="language-plaintext highlighter-rouge">dealloc</code>中释放监听,因此在监听双方插入一个弱引用转发者,破坏循环引用链:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> -------------
-------------- | Player | ------------
| ------------- |
↓ ↓
------------ -----------
| Proxy | ←----------------------- | Item |
------------ -----------
</code></pre></div></div>
<blockquote>
<p>接口和实现的分离</p>
</blockquote>
<p>控制接口包装了对实际操作的调用,提供过滤作用。通过<code class="language-plaintext highlighter-rouge">interface2</code>的方式命名实际接口,其存在的调用关系如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- playWithURL:
--> stop2
--> play2
--> switchOnOff
- switchOnOff
--> play2
--> pause2
- pause:
--> pause2
- stop:
--> stop2
</code></pre></div></div>
<blockquote>
<p>前后台切换的暂停续播</p>
</blockquote>
<p>对外暴露<code class="language-plaintext highlighter-rouge">autoSwitchWhenApplicationStateChanged</code>配置音乐是否跟随前后台变化切换,默认跟随</p>
<h2 id="踩的一些坑">踩的一些坑</h2>
<ol>
<li>由于榜单页的多音乐播放场景会频繁的切换音乐,控制器虽然不再维护播放器的<code class="language-plaintext highlighter-rouge">state</code>,但依旧要维护当前播放的音乐。最开始<code class="language-plaintext highlighter-rouge">player</code>对外暴露<code class="language-plaintext highlighter-rouge">url</code>属性方便业务调用方判断,但发现这样给<code class="language-plaintext highlighter-rouge">player</code>开了一个口子,承担了不必要的向上层依赖风险,综合考虑之下由<code class="language-plaintext highlighter-rouge">play</code>接收参数进行转发</li>
<li><code class="language-plaintext highlighter-rouge">notification</code>的<code class="language-plaintext highlighter-rouge">block</code>发生了引用。这是个低级错误,通知用多了,会下意识忘记了通知会存在引用</li>
</ol>sindrilinsindrilin@foxmail.com技术重构 重构是软件开发过程中不断对软件代码进行打散重组,提高代码稳定性和可读性的处理手段之一。对【技术重构】进行关键信息提炼可以得到思维导图: 本文以微视最近一次音乐播放功能的重构为例回顾重构过程 重构步骤 业务梳理 微视4.8版本增加了音乐榜单功能,在更早之前的版本拥有音乐播放的界面只有音乐聚合页,相较于音乐聚合页同时只有一首歌曲需要控制播放,音乐榜单页存在切歌、榜单切换的场景,逻辑处置起来要棘手的多。另外由于音乐播放应该是一个通用能力,在重构前控制器需要维护AVPlayer的各种状态,代码格式如下: - (void)observeValueForKeyPath: (NSString *)keyPath ofObject: (id)object change: (NSDictionary<NSKeyValueChangeKey,id> *)change context: (void *)context { if ([UIApplication sharedApplication].applicationState != UIApplicationStateActive) { return; } if ([keyPath isEqualToString: @"rate"]) { NSNumber *val = change[NSKeyValueChangeNewKey]; [self updateStateWhenRateChanged: val.doubleValue]; } else if ([keyPath isEqualToString: @"status"]) { if (self.audioPlayer.currentItem.status != AVPlayerItemStatusReadyToPlay) { return; } [self updateStateWhenReadyToPlay]; } else if ([keyPath isEqualToString: @"loadedTimeRanges"]) { NSArray *ranges = change[NSKeyValueChangeNewKey]; [self updateStateWhenLoadedTimeChanged: ranges]; } } 由于KVO存在强引用,为了避免存在的内存泄漏,还需要在控制器disappear的时候去移除监听: - (void)viewDidDisappear: (BOOL)animated { [super viewDidDisappear: animated]; [self removeAudioPlayerObservers]; } - (void)addAudioPlayerObservers { if (!self.audioPlayer) { return; } [self.audioPlayer addObserver: self forKeyPath: @"rate" options: NSKeyValueObservingOptionNew context: nil]; [self.audioPlayer.currentItem addObserver: self forKeyPath: @"status" options: NSKeyValueObservingOptionNew context: nil]; [self.audioPlayer.currentItem addObserver: self forKeyPath: @"loadedTimeRanges" options: NSKeyValueObservingOptionNew context: nil]; } - (void)removeAudioPlayerObservers { if (!self.audioPlayer) { return; } self.audioPlayer removeObserver: self forKeyPath: @"rate" context: nil]; self.audioPlayer.currentItem removeObserver: self forKeyPath: @"status" context: nil]; self.audioPlayer.currentItem removeObserver: self forKeyPath: @"loadedTimeRanges" context: nil]; } 对于音乐榜单页来说,由于音乐列表的存在,需要维护loading、pause、playing和idle四种状态值,以便能正确显示视图的状态。这种情况下将音乐播放分离出来有三个原因: 多种改变播放状态的逻辑无法统一处理 控制器管理了不属于自身的逻辑 监听方式增加代码的维护成本 明确目标 由于AVFoundation提供的音乐播放器需要进行额外的配置以及维护状态,这部分的代码属于通用逻辑,不易放在controller这种业务上层中,因此通过添加一个间接层实现播放器的控制逻辑。计划重构后的结构如下: ------------------- 控制器 | ViewController | ------------------- ↓ ↓ ------------------- 请求层 | WSMusicPlayer | ------------------- ↓ ↓ ------------------- 核心层 | AVFoundation | ------------------- 分离后的播放器对外提供少量的控制接口,以及通过delegate统一状态变化的回调: 动手实践 分离之后的musicPlayer主要有三处设计点: KVO的引用分离 KVO对象无法在dealloc中释放监听,因此在监听双方插入一个弱引用转发者,破坏循环引用链: ------------- -------------- | Player | ------------ | ------------- | ↓ ↓ ------------ ----------- | Proxy | ←----------------------- | Item | ------------ ----------- 接口和实现的分离 控制接口包装了对实际操作的调用,提供过滤作用。通过interface2的方式命名实际接口,其存在的调用关系如下: - playWithURL: --> stop2 --> play2 --> switchOnOff - switchOnOff --> play2 --> pause2 - pause: --> pause2 - stop: --> stop2 前后台切换的暂停续播 对外暴露autoSwitchWhenApplicationStateChanged配置音乐是否跟随前后台变化切换,默认跟随 踩的一些坑 由于榜单页的多音乐播放场景会频繁的切换音乐,控制器虽然不再维护播放器的state,但依旧要维护当前播放的音乐。最开始player对外暴露url属性方便业务调用方判断,但发现这样给player开了一个口子,承担了不必要的向上层依赖风险,综合考虑之下由play接收参数进行转发 notification的block发生了引用。这是个低级错误,通知用多了,会下意识忘记了通知会存在引用质量监控-图片减包2018-12-11T08:00:00+08:002018-12-11T08:00:00+08:00www.sindrilin.com/2018/12/11/image_subtraction<p>经过多个版本迭代,项目在<code class="language-plaintext highlighter-rouge">release</code>配置下的打包体积依旧轻松破百,应用体积过大导致的问题包括:</p>
<ul>
<li>更长的构建时间,换个词就是<code class="language-plaintext highlighter-rouge">加班</code></li>
<li><code class="language-plaintext highlighter-rouge">TEXT</code>段体积过大会导致审核失败</li>
<li>用户不愿意下载应用</li>
</ul>
<p>通常来说,资源文件能在应用体积包中占据<code class="language-plaintext highlighter-rouge">1/3</code>或者更多的体积,相比起代码<code class="language-plaintext highlighter-rouge">(5kb/千行)</code>的平均占用来说,对图片进行减包是最直接高效的手段,对图片资源的处理方式包括四种:</p>
<ol>
<li>通过请求下载大图</li>
<li>使用工具压缩图片</li>
<li>查找删除重复图片</li>
<li>查找复用相似图片</li>
</ol>
<p>考虑到由于项目开发分工的问题,<code class="language-plaintext highlighter-rouge">方式1</code>需要推动落地,所以本文不讨论这种处理方式。其他三种都能通过编写脚本实现自动化处理</p>
<h2 id="图片压缩">图片压缩</h2>
<p>图片压缩分为<code class="language-plaintext highlighter-rouge">有损压缩</code>和<code class="language-plaintext highlighter-rouge">无损压缩</code>两类,<code class="language-plaintext highlighter-rouge">有损压缩</code>放弃了一部分图片的质量换取更高的压缩比。网上主流的压缩工具有<code class="language-plaintext highlighter-rouge">tinypng</code>、<code class="language-plaintext highlighter-rouge">pngquant</code>、<code class="language-plaintext highlighter-rouge">ImageAlpha</code>和<code class="language-plaintext highlighter-rouge">ImageOptim</code>等,分别采用了一种或者多种压缩技术完成图片压缩</p>
<h3 id="为什么png能够无损压缩">为什么png能够无损压缩</h3>
<p>由于<code class="language-plaintext highlighter-rouge">png</code>格式的灵活性,同一张图片可以使用多种方式进行表示,不同方式占用的大小不一样。一般的软件会采用效率更高的方式来表示图片,所以这种情况下<code class="language-plaintext highlighter-rouge">png</code>图片存在巨大的优化空间。通常来说,从<code class="language-plaintext highlighter-rouge">png</code>文件中能去除的数据包括:</p>
<ul>
<li><code class="language-plaintext highlighter-rouge">iTXt</code>、<code class="language-plaintext highlighter-rouge">tEXt</code>和<code class="language-plaintext highlighter-rouge">zTXt</code>这些可以存储任意文本的数据区段</li>
<li><code class="language-plaintext highlighter-rouge">iCCP</code>数据区段存储的<code class="language-plaintext highlighter-rouge">profile</code>等等</li>
<li><code class="language-plaintext highlighter-rouge">photoshop</code>导出的<code class="language-plaintext highlighter-rouge">png</code>图片存在大量的额外信息</li>
</ul>
<p><code class="language-plaintext highlighter-rouge">png</code>图片有两种类型的数据块,一种是必不可缺的数据块称为<code class="language-plaintext highlighter-rouge">关键数据块</code>。另一种叫做<code class="language-plaintext highlighter-rouge">辅助数据块</code>,<code class="language-plaintext highlighter-rouge">png</code>文件格式规范指定的辅助数据块包括:</p>
<ul>
<li>背景颜色数据块<code class="language-plaintext highlighter-rouge">bKGD</code></li>
<li>基色和白色数据块<code class="language-plaintext highlighter-rouge">cHRM</code></li>
<li>图像<code class="language-plaintext highlighter-rouge">γ</code>数据块<code class="language-plaintext highlighter-rouge">gAMA</code></li>
<li>图像直方图数据块<code class="language-plaintext highlighter-rouge">hIST</code></li>
<li>物理像素尺寸数据块<code class="language-plaintext highlighter-rouge">pHYs</code></li>
<li>样本有效位数据块<code class="language-plaintext highlighter-rouge">sBIT</code></li>
<li>文本信息数据块<code class="language-plaintext highlighter-rouge">tEXt</code></li>
<li>图像最后修改时间数据块<code class="language-plaintext highlighter-rouge">tIME</code></li>
<li>图像透明数据块<code class="language-plaintext highlighter-rouge">tRNS</code></li>
<li>压缩文本数据块<code class="language-plaintext highlighter-rouge">zTXt</code></li>
</ul>
<p>其中<code class="language-plaintext highlighter-rouge">tEXt</code>和<code class="language-plaintext highlighter-rouge">zTXt</code>数据段中存在的数据包括:</p>
<table>
<thead>
<tr>
<th>关键字</th>
<th> </th>
</tr>
</thead>
<tbody>
<tr>
<td>Title</td>
<td>图像名称</td>
</tr>
<tr>
<td>Author</td>
<td>图像作者</td>
</tr>
<tr>
<td>Description</td>
<td>图像说明</td>
</tr>
<tr>
<td>Copyright</td>
<td>版权声明</td>
</tr>
<tr>
<td>CreationTime</td>
<td>原图创作时间</td>
</tr>
<tr>
<td>Software</td>
<td>创作图像使用的软件</td>
</tr>
<tr>
<td>Disclaimer</td>
<td>弃权</td>
</tr>
<tr>
<td>Warning</td>
<td>图像内容警告</td>
</tr>
<tr>
<td>Source</td>
<td>创作图像使用的设备</td>
</tr>
<tr>
<td>Comment</td>
<td>注释信息</td>
</tr>
</tbody>
</table>
<p>由上可见,辅助数据块在<code class="language-plaintext highlighter-rouge">png</code>文件中可能占据了极大的篇幅,正是这些数据块构成了<code class="language-plaintext highlighter-rouge">png</code>的无损压缩条件</p>
<h3 id="tinypng">tinypng</h3>
<p><code class="language-plaintext highlighter-rouge">tinypng</code>采用了一种称作<code class="language-plaintext highlighter-rouge">Quantization</code>的压缩技术,通过合并图片中相似的颜色,将<code class="language-plaintext highlighter-rouge">24bit</code>的图片文件压缩成<code class="language-plaintext highlighter-rouge">8bit</code>图片,同时去除图片中不必要的元数据,图片最高能达到<code class="language-plaintext highlighter-rouge">70%</code>以上的压缩率。截止文章完成之前,<code class="language-plaintext highlighter-rouge">tinypng</code>仅提供了线上压缩功能,暂未提供工具下载</p>
<h3 id="pngquant">pngquant</h3>
<p>根据官方介绍,<code class="language-plaintext highlighter-rouge">pngquant</code>将<code class="language-plaintext highlighter-rouge">24bit</code>以上的图片转换成<code class="language-plaintext highlighter-rouge">8bit</code>的保留透明度通道的压缩图片,压缩算法的压缩比非常显著,通常都能减少<code class="language-plaintext highlighter-rouge">70%</code>的大小。<code class="language-plaintext highlighter-rouge">pngquant</code>提供了命令行工具来完成解压任务:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pngquant --quality=0-100 imagepath
</code></pre></div></div>
<p>命令行更多调试参数可以在<a href="https://pngquant.org">官网</a>参阅</p>
<h3 id="imagealpha">ImageAlpha</h3>
<p><code class="language-plaintext highlighter-rouge">ImageAlpha</code>是一个<code class="language-plaintext highlighter-rouge">macOS</code>系统下的有损图片压缩工具,内置了<code class="language-plaintext highlighter-rouge">pngquant</code>、<code class="language-plaintext highlighter-rouge">pngnq-s9</code>等多个压缩工具,多数情况下通过将图片降至<code class="language-plaintext highlighter-rouge">8bit</code>来获取高压缩比。由于<code class="language-plaintext highlighter-rouge">ImageAlpha</code>的可视化界面无法批量处理图片,直接使用提供的命令工具可以实现批量压缩图片:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>for file in $(ls $1); do
imagepath=$1"/"$file
if [ -d imagepath ]
then
/// 路径为文件夹
else
if [[ $file == *.png ]]
then
beforeSize=`ls -l $imagepath | awk '{print $5}'`
/Applications/ImageAlpha.app/Contents/MacOS/pngquant $imagepath
afterSize=`ls -l ${imagepath/.png/-fs8.png} | awk '{print $5}'`
if [[ $afterSize -lt $beforeSize]]
then
mv ${imagepath/.png/-fs8.png} $imagepath
fi
fi
fi
done
</code></pre></div></div>
<p>使用<code class="language-plaintext highlighter-rouge">ImageAlpha</code>需要注意两点:</p>
<ol>
<li>压缩后的图片命名会自动添加<code class="language-plaintext highlighter-rouge">-fs8</code>后缀,需要使用<code class="language-plaintext highlighter-rouge">mv</code>命令实现替换</li>
<li>有损压缩会修改<code class="language-plaintext highlighter-rouge">关键数据块</code>,可能导致压缩图片尺寸增大,需要过滤</li>
</ol>
<p>在使用<code class="language-plaintext highlighter-rouge">有损压缩</code>时需要注意单张<code class="language-plaintext highlighter-rouge">png</code>图片是可以被多次压缩的,但这会导致图片的清晰度和色彩都受到影响,不建议对图片超过一次以上的<code class="language-plaintext highlighter-rouge">有损压缩</code></p>
<h3 id="imageoptim">ImageOptim</h3>
<p><code class="language-plaintext highlighter-rouge">ImageOptim</code>是介绍的四种工具中唯一的<code class="language-plaintext highlighter-rouge">无损压缩</code>,它采用了包括<code class="language-plaintext highlighter-rouge">去除exif信息</code>、<code class="language-plaintext highlighter-rouge">重新排列像素存储方式</code>等手段实现图片的压缩。<code class="language-plaintext highlighter-rouge">无损</code>代表着一张图片被<code class="language-plaintext highlighter-rouge">ImageOptim</code>压缩后,后续无法再次进行压缩,同时它的压缩比往往比不上其他的<code class="language-plaintext highlighter-rouge">有损压缩</code>方案,但最大程度上保证了图片的原始清晰度和色彩</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>for file in $(ls $1); do
imagepath=$1"/"$file
if [ -d imagepath ]
then
/// 路径为文件夹
else
if [[ $file == *.png ]]
then
/Applications/ImageOptim.app/Contents/MacOS/ImageOptim $imagepath
fi
fi
done
</code></pre></div></div>
<p><code class="language-plaintext highlighter-rouge">ImageOptim</code>同样存在可视化的工具并且支持批量压缩图片</p>
<h3 id="多方案对比">多方案对比</h3>
<p>考虑到<code class="language-plaintext highlighter-rouge">ImageAlpha</code>几乎都是使用<code class="language-plaintext highlighter-rouge">pngquant</code>作为压缩工具,因此只列出三种压缩工具的对比:</p>
<table>
<thead>
<tr>
<th>原始尺寸</th>
<th>压缩工具</th>
<th>压缩后尺寸</th>
<th>压缩比</th>
</tr>
</thead>
<tbody>
<tr>
<td>319.5KB</td>
<td>tinypng</td>
<td>120.5KB</td>
<td>62%</td>
</tr>
<tr>
<td>319.5KB</td>
<td>ImageAlpha-pngquant</td>
<td>395KB</td>
<td>-24%</td>
</tr>
<tr>
<td>319.5KB</td>
<td>ImageOptim</td>
<td>252KB</td>
<td>21%</td>
</tr>
</tbody>
</table>
<p>测试图片采用<code class="language-plaintext highlighter-rouge">qq</code>聊天截图生成的<code class="language-plaintext highlighter-rouge">png</code>,<code class="language-plaintext highlighter-rouge">tinypng</code>压缩率非常高,而<code class="language-plaintext highlighter-rouge">pngquant</code>的表现不尽人意</p>
<h2 id="删除重复图片">删除重复图片</h2>
<p>通常来说,出现重复图片的原因包括<code class="language-plaintext highlighter-rouge">模块间需求开发没有打通</code>或是<code class="language-plaintext highlighter-rouge">缺少统一的图片命名规范</code>。通过图片<code class="language-plaintext highlighter-rouge">MD5</code>摘要是识别重复图片的最快方法,以<code class="language-plaintext highlighter-rouge">python</code>为例,匹配重复图片的代码如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>md5list = {}
for file in files:
if os.path.isdir(file.path):
continue
md5obj = hashlib.md5()
fd = open(file.path, 'rb')
while True:
buff = fd.read(2048)
if not buff:
break
md5obj.update(buff)
fd.close()
filemd5 = str(md5obj.hexdigest()).lower()
if filemd5 in md5list:
md5list[filemd5].add(file.path)
else:
md5list[filemd5] = set([file.path])
for key in md5list:
list = md5list[key]
if len(list) > 1:
print (list)
</code></pre></div></div>
<p>在遍历中以文件<code class="language-plaintext highlighter-rouge">MD5</code>字符串作为<code class="language-plaintext highlighter-rouge">key</code>,维护具备相同<code class="language-plaintext highlighter-rouge">MD5</code>的图片路径,最后遍历这个<code class="language-plaintext highlighter-rouge">map</code>查找存在一个以上路径的数组并且输出</p>
<h2 id="寻找相似图片">寻找相似图片</h2>
<p>相似图片在图片内容、色彩上都十分的接近,多数时间可以考虑复用这些图片,但相似图片的问题在于无法通过<code class="language-plaintext highlighter-rouge">MD5</code>直接匹配。为了确认两个图片是否相似,要使用简单的一个数学公式来帮忙查找:</p>
<blockquote>
<p>方差。在概率论和统计学中,一个随机变量的方差描述的是它的离散程度,也就是该变量离其期望值的距离</p>
</blockquote>
<p>举个例子,甲同学五次成绩分别是<code class="language-plaintext highlighter-rouge">65, 69, 81, 89, 96</code>,乙同学五次成绩是<code class="language-plaintext highlighter-rouge">82, 80, 77, 81, 80</code>,两个人平均成绩都是<code class="language-plaintext highlighter-rouge">80</code>,但是引入方差公式计算:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>甲: ((65-80)^2 + (69-80)^2 + (81-80)^2 + (89-80)^2 + (96-80)^2) / 5 = 136.8
乙: ((82-80)^2 + (80-80)^2 + (77-80)^2 + (81-80)^2 + (80-80)^2) / 5 = 2.8
</code></pre></div></div>
<p>平均值相同的情况下,方差越大,说明数据偏离期望值的情况越严重。方差越接近的两个随机变量,他们的变化就越加趋同,获取方差代码如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>def getVariance(nums):
variance = 0
average = sum(nums) / len(nums)
for num in nums:
variance += (num - average) * (num - average) / len(nums)
return variance
</code></pre></div></div>
<p>因此将图片划分成连串的一维数据,以此计算出图片的方差,通过方差匹配可以实现一个简单的图片相似度判断工具,实现前还要注意两点:</p>
<ol>
<li>图片<code class="language-plaintext highlighter-rouge">RGB</code>色彩值会导致方差的计算变得复杂,所以转成灰度图可以降低难度</li>
<li>不同尺寸需要缩放到相同尺寸进行计算</li>
</ol>
<p>最终将图片转换成一维数据列表的代码如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>def getAverageList(img):
commonlength = 30
img = cv2.resize(img, (commonlength, commonlength), interpolation=cv2.INTER_CUBIC)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
res = []
for idx in range(commonlength):
average = sum(gray[idx]) / len(gray[idx])
res.append(average)
</code></pre></div></div>
<p>将图片转成灰度图后,仍然可能存在<code class="language-plaintext highlighter-rouge">RGB</code>色值不同但灰度值相同的情况导致判断失准,可以考虑两种方案提高算法的检测准确率:</p>
<ol>
<li>在不修改以灰度值计算方差的方案下,构建以<code class="language-plaintext highlighter-rouge">列平均像素值</code>为单位的一维列表计算另一个方差,两个方差值一并做判断</li>
<li>摒弃灰度值方差方案,每一行分别生成<code class="language-plaintext highlighter-rouge">R</code>、<code class="language-plaintext highlighter-rouge">G</code>、<code class="language-plaintext highlighter-rouge">B</code>三种色彩平均值的一维列表,计算出三个方差进行匹配检测</li>
</ol>
<h2 id="效果">效果</h2>
<p>经过两轮图片减包处理后,整个项目资源产生的减包量约有<code class="language-plaintext highlighter-rouge">20M</code>,其中通过文中的三种手段产生的减包量在<code class="language-plaintext highlighter-rouge">6.5M</code>左右,整体上来看产出还是比较可观的</p>
<p><img src="https://user-gold-cdn.xitu.io/2018/12/11/1679d90e7b60f288?w=430&h=430&f=jpeg&s=23750" alt="关注我的公众号获取更新信息" /></p>sindrilinsindrilin@foxmail.com经过多个版本迭代,项目在release配置下的打包体积依旧轻松破百,应用体积过大导致的问题包括: 更长的构建时间,换个词就是加班 TEXT段体积过大会导致审核失败 用户不愿意下载应用 通常来说,资源文件能在应用体积包中占据1/3或者更多的体积,相比起代码(5kb/千行)的平均占用来说,对图片进行减包是最直接高效的手段,对图片资源的处理方式包括四种: 通过请求下载大图 使用工具压缩图片 查找删除重复图片 查找复用相似图片 考虑到由于项目开发分工的问题,方式1需要推动落地,所以本文不讨论这种处理方式。其他三种都能通过编写脚本实现自动化处理 图片压缩 图片压缩分为有损压缩和无损压缩两类,有损压缩放弃了一部分图片的质量换取更高的压缩比。网上主流的压缩工具有tinypng、pngquant、ImageAlpha和ImageOptim等,分别采用了一种或者多种压缩技术完成图片压缩 为什么png能够无损压缩 由于png格式的灵活性,同一张图片可以使用多种方式进行表示,不同方式占用的大小不一样。一般的软件会采用效率更高的方式来表示图片,所以这种情况下png图片存在巨大的优化空间。通常来说,从png文件中能去除的数据包括: iTXt、tEXt和zTXt这些可以存储任意文本的数据区段 iCCP数据区段存储的profile等等 photoshop导出的png图片存在大量的额外信息 png图片有两种类型的数据块,一种是必不可缺的数据块称为关键数据块。另一种叫做辅助数据块,png文件格式规范指定的辅助数据块包括: 背景颜色数据块bKGD 基色和白色数据块cHRM 图像γ数据块gAMA 图像直方图数据块hIST 物理像素尺寸数据块pHYs 样本有效位数据块sBIT 文本信息数据块tEXt 图像最后修改时间数据块tIME 图像透明数据块tRNS 压缩文本数据块zTXt 其中tEXt和zTXt数据段中存在的数据包括: 关键字 Title 图像名称 Author 图像作者 Description 图像说明 Copyright 版权声明 CreationTime 原图创作时间 Software 创作图像使用的软件 Disclaimer 弃权 Warning 图像内容警告 Source 创作图像使用的设备 Comment 注释信息 由上可见,辅助数据块在png文件中可能占据了极大的篇幅,正是这些数据块构成了png的无损压缩条件 tinypng tinypng采用了一种称作Quantization的压缩技术,通过合并图片中相似的颜色,将24bit的图片文件压缩成8bit图片,同时去除图片中不必要的元数据,图片最高能达到70%以上的压缩率。截止文章完成之前,tinypng仅提供了线上压缩功能,暂未提供工具下载 pngquant 根据官方介绍,pngquant将24bit以上的图片转换成8bit的保留透明度通道的压缩图片,压缩算法的压缩比非常显著,通常都能减少70%的大小。pngquant提供了命令行工具来完成解压任务: pngquant --quality=0-100 imagepath 命令行更多调试参数可以在官网参阅 ImageAlpha ImageAlpha是一个macOS系统下的有损图片压缩工具,内置了pngquant、pngnq-s9等多个压缩工具,多数情况下通过将图片降至8bit来获取高压缩比。由于ImageAlpha的可视化界面无法批量处理图片,直接使用提供的命令工具可以实现批量压缩图片: for file in $(ls $1); do imagepath=$1"/"$file if [ -d imagepath ] then /// 路径为文件夹 else if [[ $file == *.png ]] then beforeSize=`ls -l $imagepath | awk '{print $5}'` /Applications/ImageAlpha.app/Contents/MacOS/pngquant $imagepath afterSize=`ls -l ${imagepath/.png/-fs8.png} | awk '{print $5}'` if [[ $afterSize -lt $beforeSize]] then mv ${imagepath/.png/-fs8.png} $imagepath fi fi fi done 使用ImageAlpha需要注意两点: 压缩后的图片命名会自动添加-fs8后缀,需要使用mv命令实现替换 有损压缩会修改关键数据块,可能导致压缩图片尺寸增大,需要过滤 在使用有损压缩时需要注意单张png图片是可以被多次压缩的,但这会导致图片的清晰度和色彩都受到影响,不建议对图片超过一次以上的有损压缩 ImageOptim ImageOptim是介绍的四种工具中唯一的无损压缩,它采用了包括去除exif信息、重新排列像素存储方式等手段实现图片的压缩。无损代表着一张图片被ImageOptim压缩后,后续无法再次进行压缩,同时它的压缩比往往比不上其他的有损压缩方案,但最大程度上保证了图片的原始清晰度和色彩 for file in $(ls $1); do imagepath=$1"/"$file if [ -d imagepath ] then /// 路径为文件夹 else if [[ $file == *.png ]] then /Applications/ImageOptim.app/Contents/MacOS/ImageOptim $imagepath fi fi done ImageOptim同样存在可视化的工具并且支持批量压缩图片 多方案对比 考虑到ImageAlpha几乎都是使用pngquant作为压缩工具,因此只列出三种压缩工具的对比: 原始尺寸 压缩工具 压缩后尺寸 压缩比 319.5KB tinypng 120.5KB 62% 319.5KB ImageAlpha-pngquant 395KB -24% 319.5KB ImageOptim 252KB 21% 测试图片采用qq聊天截图生成的png,tinypng压缩率非常高,而pngquant的表现不尽人意 删除重复图片 通常来说,出现重复图片的原因包括模块间需求开发没有打通或是缺少统一的图片命名规范。通过图片MD5摘要是识别重复图片的最快方法,以python为例,匹配重复图片的代码如下: md5list = {} for file in files: if os.path.isdir(file.path): continue md5obj = hashlib.md5() fd = open(file.path, 'rb') while True: buff = fd.read(2048) if not buff: break md5obj.update(buff) fd.close() filemd5 = str(md5obj.hexdigest()).lower() if filemd5 in md5list: md5list[filemd5].add(file.path) else: md5list[filemd5] = set([file.path]) for key in md5list: list = md5list[key] if len(list) > 1: print (list) 在遍历中以文件MD5字符串作为key,维护具备相同MD5的图片路径,最后遍历这个map查找存在一个以上路径的数组并且输出 寻找相似图片 相似图片在图片内容、色彩上都十分的接近,多数时间可以考虑复用这些图片,但相似图片的问题在于无法通过MD5直接匹配。为了确认两个图片是否相似,要使用简单的一个数学公式来帮忙查找: 方差。在概率论和统计学中,一个随机变量的方差描述的是它的离散程度,也就是该变量离其期望值的距离 举个例子,甲同学五次成绩分别是65, 69, 81, 89, 96,乙同学五次成绩是82, 80, 77, 81, 80,两个人平均成绩都是80,但是引入方差公式计算: 甲: ((65-80)^2 + (69-80)^2 + (81-80)^2 + (89-80)^2 + (96-80)^2) / 5 = 136.8 乙: ((82-80)^2 + (80-80)^2 + (77-80)^2 + (81-80)^2 + (80-80)^2) / 5 = 2.8 平均值相同的情况下,方差越大,说明数据偏离期望值的情况越严重。方差越接近的两个随机变量,他们的变化就越加趋同,获取方差代码如下: def getVariance(nums): variance = 0 average = sum(nums) / len(nums) for num in nums: variance += (num - average) * (num - average) / len(nums) return variance 因此将图片划分成连串的一维数据,以此计算出图片的方差,通过方差匹配可以实现一个简单的图片相似度判断工具,实现前还要注意两点: 图片RGB色彩值会导致方差的计算变得复杂,所以转成灰度图可以降低难度 不同尺寸需要缩放到相同尺寸进行计算 最终将图片转换成一维数据列表的代码如下: def getAverageList(img): commonlength = 30 img = cv2.resize(img, (commonlength, commonlength), interpolation=cv2.INTER_CUBIC) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) res = [] for idx in range(commonlength): average = sum(gray[idx]) / len(gray[idx]) res.append(average) 将图片转成灰度图后,仍然可能存在RGB色值不同但灰度值相同的情况导致判断失准,可以考虑两种方案提高算法的检测准确率: 在不修改以灰度值计算方差的方案下,构建以列平均像素值为单位的一维列表计算另一个方差,两个方差值一并做判断 摒弃灰度值方差方案,每一行分别生成R、G、B三种色彩平均值的一维列表,计算出三个方差进行匹配检测 效果 经过两轮图片减包处理后,整个项目资源产生的减包量约有20M,其中通过文中的三种手段产生的减包量在6.5M左右,整体上来看产出还是比较可观的分析实现-离散请求2018-11-15T08:00:00+08:002018-11-15T08:00:00+08:00www.sindrilin.com/2018/11/15/discrete_request<p>网络层作为<code class="language-plaintext highlighter-rouge">App</code>架构中至关重要的中间件之一,承担着业务封装和核心层网络请求交互的职责。讨论请求中间件实现方案的意义在于中间件要如何设计以便减少对业务对接的影响;明晰请求流程中的职责以便写出更合理的代码等。因此在讲如何去设计请求中间件时,主要考虑三个问题:</p>
<ul>
<li>业务以什么方式发起请求</li>
<li>请求数据如何交付业务层</li>
<li>如何实现通用的请求接口</li>
</ul>
<h2 id="以什么方式发起请求">以什么方式发起请求</h2>
<p>根据暴露给业务层请求<code class="language-plaintext highlighter-rouge">API</code>的不同,可以分为<code class="language-plaintext highlighter-rouge">集约式请求</code>和<code class="language-plaintext highlighter-rouge">离散型请求</code>两类。<code class="language-plaintext highlighter-rouge">集约式请求</code>对外只提供一个类用于接收包括请求地址、请求参数在内的数据信息,以及回调处理(通常使用<code class="language-plaintext highlighter-rouge">block</code>)。而<code class="language-plaintext highlighter-rouge">离散型请求</code>对外提供通用的扩展接口完成请求</p>
<h3 id="集约式请求">集约式请求</h3>
<p>考虑到<code class="language-plaintext highlighter-rouge">AFNetworking</code>基本成为了<code class="language-plaintext highlighter-rouge">iOS</code>的请求标准,以传统的集约式请求代码为例:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/// 请求地址和参数组装
NSString *domain = [SLNetworkEnvironment currentDomain];
NSString *url = [domain stringByAppendingPathComponent: @"getInterviewers"];
NSDictionary *params = @{
@"page": @1,
@"pageCount": @20,
@"filterRule": @"work-years >= 3"
};
/// 构建新的请求对象发起请求
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
[manager POST: url parameters: params success: ^(NSURLSessionDataTask *task, id responseObject) {
/// 请求成功处理
if ([responseObject isKindOfClass: [NSArray class]]) {
NSArray *result = [responseObject bk_map: ^id(id obj) {
return [[SLResponse alloc] initWithJSON: obj];
}];
[self reloadDataWithResponses: result];
} else {
SLLog(@"Invalid response object: %@", responseObject);
}
} failure: ^(NSURLSessionDataTask *task, NSError *error) {
/// 请求失败处理
SLLog(@"Error: %@ in requesting %@", error, task.currentRequest.URL);
}];
/// 取消存在的请求
[self.currentRequestManager invalidateSessionCancelingTasks: YES];
self.currentRequestManager = manager;
</code></pre></div></div>
<p>这样的请求代码存在这些问题:</p>
<ol>
<li>请求环境配置、参数构建、请求任务控制等业务无关代码</li>
<li>请求逻辑和回调逻辑在同一处违背了单一原则</li>
<li><code class="language-plaintext highlighter-rouge">block</code>回调潜在的引用问题</li>
</ol>
<p>在业务封装的层面上,应该只关心<code class="language-plaintext highlighter-rouge">何时发起请求</code>和<code class="language-plaintext highlighter-rouge">展示请求结果</code>。设计上,请求中间件应当只暴露必要的参数<code class="language-plaintext highlighter-rouge">property</code>,隐藏请求过程和返回数据的处理</p>
<h3 id="离散型请求">离散型请求</h3>
<p>和集约式请求不同,对于每一个请求<code class="language-plaintext highlighter-rouge">API</code>都会有一个<code class="language-plaintext highlighter-rouge">manager</code>来管理。在使用<code class="language-plaintext highlighter-rouge">manager</code>的时候只需要创建实例,执行一个类似<code class="language-plaintext highlighter-rouge">load</code>的方法,<code class="language-plaintext highlighter-rouge">manager</code>会自动控制请求的发起和处理:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- (void)viewDidLoad {
[super viewDidLoad];
self.getInterviewerApiManager = [SLGetInterviewerApiManager new];
[self.getInterviewerApiManager addDelegate: self];
[self.getInterviewerApiManager refreshData];
}
</code></pre></div></div>
<p>集约式请求和离散型请求最终的实现方案并不是互斥的,从底层请求的具体行为来看,最终都有统一执行的步骤:<code class="language-plaintext highlighter-rouge">域名拼凑</code>、<code class="language-plaintext highlighter-rouge">请求发起</code>、<code class="language-plaintext highlighter-rouge">结果处理</code>等。因此从设计上来说,使用基类来统一这些行为,再通过派生生成针对不同请求<code class="language-plaintext highlighter-rouge">API</code>的子类,以便获得具体请求的灵活性:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@protocol SLBaseApiManagerDelegate
- (void)managerWillLoadData: (SLBaseApiManager *)manager;
- (void)managerDidLoadData: (SLBaseApiManager *)manager;
@end
@interface SLBaseApiManager : NSObject
@property (nonatomic, readonly) NSArray<id<SLBaseApiManagerDelegate>) *delegates;
- (void)loadWithParams: (NSDictionary *)params;
- (void)addDelegate: (id<SLBaseApiManagerDelegate>)delegate;
- (void)removeDelegate: (id<SLBaseApiManagerDelegate>)delegate;
@end
@interface SLBaseListApiManager : SLBaseApiManager
@property (nonatomic, readonly, assign) BOOL hasMore;
@property (nonatomic, readonly, copy) NSArray *dataList;
- (void)refreshData;
- (void)loadMoreData;
@end
</code></pre></div></div>
<p>离散型请求的一个特点是,将相同的请求逻辑抽离出来,统一行为接口。除了请求行为之外的行为,包括请求数据解析、重试控制、请求是否互斥等行为,每一个请求<code class="language-plaintext highlighter-rouge">API</code>都有单独的<code class="language-plaintext highlighter-rouge">manager</code>进行定制,灵活性更强。另外通过<code class="language-plaintext highlighter-rouge">delegate</code>统一回调行为,减少<code class="language-plaintext highlighter-rouge">debug</code>难度,避免了<code class="language-plaintext highlighter-rouge">block</code>方式潜在的引用问题等</p>
<h2 id="请求数据如何交付">请求数据如何交付</h2>
<p>在一次完整的<code class="language-plaintext highlighter-rouge">fetch</code>数据过程中,数据可以分为四种形态:</p>
<ul>
<li>服务端直接返回的二进制形态,称为<code class="language-plaintext highlighter-rouge">Data</code></li>
<li>以<code class="language-plaintext highlighter-rouge">AFN</code>等工具拉取的数据,一般是<code class="language-plaintext highlighter-rouge">JSON</code></li>
<li>被持久化或非短暂持有的形态,一般从<code class="language-plaintext highlighter-rouge">JSON</code>转换而来,称作<code class="language-plaintext highlighter-rouge">Entity</code></li>
<li>展示在屏幕上的文本形态,大概率需要再加工,称作<code class="language-plaintext highlighter-rouge">Text</code></li>
</ul>
<p>这四种数据形态的流动结构如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> Server AFN controller view
------------- ------------- ------------- -------------
| | | | | | convert | |
| Data | ---> | JSON | ---> | Entity | ---> | Text |
| | | | | | | |
------------- ------------- ------------- -------------
</code></pre></div></div>
<p>普通情况下,第三方请求库会以<code class="language-plaintext highlighter-rouge">JSON</code>的形态交付数据给业务方。考虑到客户端与服务端的命名规范、以及可能存在的变更,多数情况下客户端会对<code class="language-plaintext highlighter-rouge">JSON</code>数据加工成具体的<code class="language-plaintext highlighter-rouge">Entity</code>数据实体,然后使用容器类保存。从上图的四种数据形态来说,如果中间件必须选择其中一种形态交付给业务层,<code class="language-plaintext highlighter-rouge">Entity</code>应该是最合理的交付数据形态,原因有三:</p>
<ol>
<li>如果交付的是<code class="language-plaintext highlighter-rouge">JSON</code>,业务封装必须完成<code class="language-plaintext highlighter-rouge">JSON -> Entity</code>的转换,多数时候请求发起的业务在<code class="language-plaintext highlighter-rouge">C</code>层中,而这些逻辑总是造成<code class="language-plaintext highlighter-rouge">Fat Controller</code>的原因</li>
<li>在<code class="language-plaintext highlighter-rouge">Entity -> Text</code>涉及到了具体的上层业务,请求中间件不应该向上干涉。在<code class="language-plaintext highlighter-rouge">JSON -> Entity</code>的转换过程中,<code class="language-plaintext highlighter-rouge">Entity</code>已经组装了业务封装最需要的数据内容</li>
</ol>
<p>另一个有趣的问题是<code class="language-plaintext highlighter-rouge">Entity</code>描述的是数据流动的阶段状态,而非具体数据类型。打个比方,<code class="language-plaintext highlighter-rouge">Entity</code>不一定非得是类对象实例,只要<code class="language-plaintext highlighter-rouge">Entity</code>遵守业务封装的读取规范,可以是<code class="language-plaintext highlighter-rouge">instance</code>也可以是<code class="language-plaintext highlighter-rouge">collection</code>,比如一个面试者<code class="language-plaintext highlighter-rouge">Entity</code>只要能提供<code class="language-plaintext highlighter-rouge">姓名</code>和<code class="language-plaintext highlighter-rouge">工作年限</code>这两个关键数据即可:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/// 抽象模型
@interface SLInterviewer : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) CGFloat workYears;
@end
SLInterviewer *interviewer = entity;
NSLog(@"The interviewer name: %@ and work-years: %g", interviewer.name, interviewer.workYears);
/// 键值约定
extern NSString *SLInterviewerNameKey;
extern NSString *SLInterviewerWorkYearsKey;
NSDictionary *interviewer = entity;
NSLog(@"The interviewer name: %@ and work-years: %@", interviewer[SLInterviewerNameKey], interviewer[SLInterviewerWorkYearsKey]);
</code></pre></div></div>
<p>如果让集约式请求的中间件交付<code class="language-plaintext highlighter-rouge">Entity</code>数据,<code class="language-plaintext highlighter-rouge">JSON -> Entity</code>的形态转换可能会导致请求中间件涉及到具体的业务逻辑中,因此在实现上需要提供一个<code class="language-plaintext highlighter-rouge">parser</code>来完成这一过程:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@protocol EntityParser
- (id)parseJSON: (id)JSON;
@end
@interface SLIntensiveRequest : NSObject
@property (nonatomic, strong) id<EntityParser> parser;
- (void)GET: (NSString *)url params: (id)params success: (SLSuccess)success failure: (SLFailure)failure;
@end
</code></pre></div></div>
<p>而相较之下,离散型请求中<code class="language-plaintext highlighter-rouge">BaseManager</code>承担了统一的请求行为,派生的<code class="language-plaintext highlighter-rouge">manager</code>完全可以直接将转换的逻辑直接封装起来,无需额外的<code class="language-plaintext highlighter-rouge">Parser</code>,唯一需要考虑的是<code class="language-plaintext highlighter-rouge">Entity</code>的具体实体对象是否需要抽象模型来表达:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation SLGetInterviewerApiManager
/// 抽象模型
- (id)entityFromJSON: (id)json {
if ([json isKindOfClass: [NSDictionary class]]) {
return [SLInterviewer interviewerWithJSON: json];
} else {
return nil;
}
}
- (void)didLoadData {
self.dataList = self.response.safeMap(^id(id item) {
return [self entityFromJSON: item];
}).safeMap(^id(id interviewer) {
return [SLInterviewerInfo infoWithInterviewer: interviewer];
});
if ([_delegate respondsToSelector: @selector(managerDidLoadData:)]) {
[_delegate managerDidLoadData: self];
}
}
/// 键值约定
- (id)entityFromJSON: (id)json keyMap: (NSDictionary *)keyMap {
if ([json isKindOfClass: [NSDictionary class]]) {
NSDictionary *dict = json;
NSMutableDictionary *entity = @{}.mutableCopy;
for (NSString *key in keyMap) {
NSString *entityKey = keyMap[key];
entity[entityKey] = dict[key];
}
return entity.copy;
} else {
return nil;
}
}
@end
</code></pre></div></div>
<p>甚至再进一步,<code class="language-plaintext highlighter-rouge">manager</code>可以同时交付<code class="language-plaintext highlighter-rouge">Text</code>和<code class="language-plaintext highlighter-rouge">Entity</code>这两种数据形态,使用<code class="language-plaintext highlighter-rouge">parser</code>可以对<code class="language-plaintext highlighter-rouge">C</code>层完成隐藏数据的转换过程:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@protocol TextParser
- (id)parseEntity: (id)entity;
@end
@interface SLInterviewerTextContent : NSObject
@property (nonatomic, readonly) NSString *name;
@property (nonatomic, readonly) NSString *workYear;
@property (nonatomic, readonly) SLInterviewer *interviewer;
- (instancetype)initWithInterviewer: (SLInterviewer *)interviewer;
@end
@implementation SLInterviewerTextParser
- (id)parseEntity: (SLInterviewer *)entity {
return [[SLInterviewerTextContent alloc] initWithInterviewer: entity];
}
@end
</code></pre></div></div>
<h2 id="通用的请求接口">通用的请求接口</h2>
<blockquote>
<p>是否需要统一接口的请求封装层</p>
</blockquote>
<p>在<code class="language-plaintext highlighter-rouge">App</code>中的请求分为三类:<code class="language-plaintext highlighter-rouge">GET</code>、<code class="language-plaintext highlighter-rouge">POST</code>和<code class="language-plaintext highlighter-rouge">UPLOAD</code>,在不考虑进行封装的情况下,核心层的请求接口至少需要三种不同的接口来对应这三种请求类型。此外还要考虑核心层的请求接口一旦发生变动(例如<code class="language-plaintext highlighter-rouge">AFN</code>在更新至<code class="language-plaintext highlighter-rouge">3.0</code>的时候修改了请求接口),因此对业务请求发起方来说,存在一个封装的请求中间层可以有效的抵御请求接口改动的风险,以及有效的减少代码量。上文可以看到对业务层暴露的中间件<code class="language-plaintext highlighter-rouge">manager</code>的作用是对请求的行为进行统一,但并不干预请求的细节,因此<code class="language-plaintext highlighter-rouge">manager</code>也能被当做是一个请求发起方,那么在其下层需要有暴露统一接口的请求封装层:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> -------------
中间件 | Manager |
-------------
↓
↓
-------------
请求层 | Request |
-------------
↓
↓
-------------
核心请求 | CoreNet |
-------------
</code></pre></div></div>
<p>封装请求层的问题在于如何只暴露一个接口来适应多种情况类型,一个方法是将请求内容抽象成一系列的接口协议,<code class="language-plaintext highlighter-rouge">Request</code>层根据接口返回参数调度具体的请求接口:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/// 协议接口层
enum {
SLRequestMethodGet,
SLRequestMethodPost,
SLRequestMethodUpload
};
@protocol RequestEntity
- (int)requestMethod; /// 请求类型
- (NSString *)urlPath; /// 提供域名中的path段,以便组装:xxxxx/urlPath
- (NSDictionary *)parameters; /// 参数
@end
extern NSString *SLRequestParamPageKey;
extern NSString *SLRequestParamPageCountKey;
@interface RequestListEntity : NSObject<RequestEntity>
@property (nonatomic, assign) NSUInteger page;
@property (nonatomic, assign) NSUInteger pageCount;
@end
/// 请求层
typedef void(^SLRequestComplete)(id response, NSError *error);
@interface SLRequestEngine
+ (instancetype)engine;
- (void)sendRequest: (id<RequestEntity>)request complete: (SLRequestComplete)complete;
@end
@implementation SLRequestEngine
- (void)sendRequest: (id<RequestEntity>)request complete: (SLRequestComplete)complete {
if (!request || !complete) {
return;
}
if (request.requestMethod == SLRequestMethodGet) {
[self get: request complete: complete];
} else if (request.requestMethod == SLRequestMethodPost) {
[self post: request complete: complete];
} else if (request.requestMethod == SLRequestMethodUpload) {
[self upload: request complete: complete];
}
}
@end
</code></pre></div></div>
<p>这样一来,当有新的请求<code class="language-plaintext highlighter-rouge">API</code>时,创建对应的<code class="language-plaintext highlighter-rouge">RequestEntity</code>和<code class="language-plaintext highlighter-rouge">Manager</code>类来处理请求。对于业务上层来说,整个请求过程更像是一个异步的<code class="language-plaintext highlighter-rouge">fetch</code>流程,一个单独的<code class="language-plaintext highlighter-rouge">manager</code>负责加载数据并在加载完成时回调。<code class="language-plaintext highlighter-rouge">Manager</code>也不用了解具体是什么请求,只需要简单的配置参数即可,<code class="language-plaintext highlighter-rouge">Manager</code>的设计如下:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@interface WSBaseApiManager : NSObject
@property (nonatomic, readonly, strong) id data;
@property (nonatomic, readonly, strong) NSError *error; /// 请求失败时不为空
@property (nonatomic, weak) id<WSBaseApiManagerDelegate> delegate;
@end
@interface WSBaseListApiManager : NSObject
@property (nonatomic, assign) BOOL hasMore;
@property (nonatomic, readonly, copy) NSArray *dataList;
@end
@interface SLGetInterviewerRequest: RequestListEntity
@end
@interface SLGetInterviewerManager : WSBaseListApiManager
@end
@implementation SLGetInterviewerManager
- (void)loadWithParams: (NSDictionary *)params {
SLGetInterviewerRequest *request = [SLGetInterviewerRequest new];
request.page = [params[SLRequestParamPageKey] unsignedIntegerValue];
request.pageCount = [params[SLRequestParamPageCountKey] unsignedIntegerValue];
[[SLRequestEngine engine] sendRequest: request complete: ^(id response, NSError *error){
/// do something when request complete
}];
}
@end
</code></pre></div></div>
<p>最终请求结构:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> -------------
业务层 | Client |
-------------
↓
↓
-------------
中间件 | Manager |
-------------
↓
↓
-------------
| Request |
-------------
↓
↓
请求层 -----------------------------------
↓ ↓ ↓
↓ ↓ ↓
------------- ------------- -------------
| GET | | POST | | Upload |
------------- ------------- -------------
↓ ↓ ↓
↓ ↓ ↓
---------------------------------------------
核心请求 | CoreNet |
---------------------------------------------
</code></pre></div></div>
<p><img src="https://user-gold-cdn.xitu.io/2018/11/15/16716b1afe57d0d6?w=430&h=430&f=jpeg&s=23750" alt="关注我的公众号获取更新信息" /></p>sindrilinsindrilin@foxmail.com网络层作为App架构中至关重要的中间件之一,承担着业务封装和核心层网络请求交互的职责。讨论请求中间件实现方案的意义在于中间件要如何设计以便减少对业务对接的影响;明晰请求流程中的职责以便写出更合理的代码等。因此在讲如何去设计请求中间件时,主要考虑三个问题: 业务以什么方式发起请求 请求数据如何交付业务层 如何实现通用的请求接口 以什么方式发起请求 根据暴露给业务层请求API的不同,可以分为集约式请求和离散型请求两类。集约式请求对外只提供一个类用于接收包括请求地址、请求参数在内的数据信息,以及回调处理(通常使用block)。而离散型请求对外提供通用的扩展接口完成请求 集约式请求 考虑到AFNetworking基本成为了iOS的请求标准,以传统的集约式请求代码为例: /// 请求地址和参数组装 NSString *domain = [SLNetworkEnvironment currentDomain]; NSString *url = [domain stringByAppendingPathComponent: @"getInterviewers"]; NSDictionary *params = @{ @"page": @1, @"pageCount": @20, @"filterRule": @"work-years >= 3" }; /// 构建新的请求对象发起请求 AFHTTPSessionManager *manager = [AFHTTPSessionManager manager]; [manager POST: url parameters: params success: ^(NSURLSessionDataTask *task, id responseObject) { /// 请求成功处理 if ([responseObject isKindOfClass: [NSArray class]]) { NSArray *result = [responseObject bk_map: ^id(id obj) { return [[SLResponse alloc] initWithJSON: obj]; }]; [self reloadDataWithResponses: result]; } else { SLLog(@"Invalid response object: %@", responseObject); } } failure: ^(NSURLSessionDataTask *task, NSError *error) { /// 请求失败处理 SLLog(@"Error: %@ in requesting %@", error, task.currentRequest.URL); }]; /// 取消存在的请求 [self.currentRequestManager invalidateSessionCancelingTasks: YES]; self.currentRequestManager = manager; 这样的请求代码存在这些问题: 请求环境配置、参数构建、请求任务控制等业务无关代码 请求逻辑和回调逻辑在同一处违背了单一原则 block回调潜在的引用问题 在业务封装的层面上,应该只关心何时发起请求和展示请求结果。设计上,请求中间件应当只暴露必要的参数property,隐藏请求过程和返回数据的处理 离散型请求 和集约式请求不同,对于每一个请求API都会有一个manager来管理。在使用manager的时候只需要创建实例,执行一个类似load的方法,manager会自动控制请求的发起和处理: - (void)viewDidLoad { [super viewDidLoad]; self.getInterviewerApiManager = [SLGetInterviewerApiManager new]; [self.getInterviewerApiManager addDelegate: self]; [self.getInterviewerApiManager refreshData]; } 集约式请求和离散型请求最终的实现方案并不是互斥的,从底层请求的具体行为来看,最终都有统一执行的步骤:域名拼凑、请求发起、结果处理等。因此从设计上来说,使用基类来统一这些行为,再通过派生生成针对不同请求API的子类,以便获得具体请求的灵活性: @protocol SLBaseApiManagerDelegate - (void)managerWillLoadData: (SLBaseApiManager *)manager; - (void)managerDidLoadData: (SLBaseApiManager *)manager; @end @interface SLBaseApiManager : NSObject @property (nonatomic, readonly) NSArray<id<SLBaseApiManagerDelegate>) *delegates; - (void)loadWithParams: (NSDictionary *)params; - (void)addDelegate: (id<SLBaseApiManagerDelegate>)delegate; - (void)removeDelegate: (id<SLBaseApiManagerDelegate>)delegate; @end @interface SLBaseListApiManager : SLBaseApiManager @property (nonatomic, readonly, assign) BOOL hasMore; @property (nonatomic, readonly, copy) NSArray *dataList; - (void)refreshData; - (void)loadMoreData; @end 离散型请求的一个特点是,将相同的请求逻辑抽离出来,统一行为接口。除了请求行为之外的行为,包括请求数据解析、重试控制、请求是否互斥等行为,每一个请求API都有单独的manager进行定制,灵活性更强。另外通过delegate统一回调行为,减少debug难度,避免了block方式潜在的引用问题等 请求数据如何交付 在一次完整的fetch数据过程中,数据可以分为四种形态: 服务端直接返回的二进制形态,称为Data 以AFN等工具拉取的数据,一般是JSON 被持久化或非短暂持有的形态,一般从JSON转换而来,称作Entity 展示在屏幕上的文本形态,大概率需要再加工,称作Text 这四种数据形态的流动结构如下: Server AFN controller view ------------- ------------- ------------- ------------- | | | | | | convert | | | Data | ---> | JSON | ---> | Entity | ---> | Text | | | | | | | | | ------------- ------------- ------------- ------------- 普通情况下,第三方请求库会以JSON的形态交付数据给业务方。考虑到客户端与服务端的命名规范、以及可能存在的变更,多数情况下客户端会对JSON数据加工成具体的Entity数据实体,然后使用容器类保存。从上图的四种数据形态来说,如果中间件必须选择其中一种形态交付给业务层,Entity应该是最合理的交付数据形态,原因有三: 如果交付的是JSON,业务封装必须完成JSON -> Entity的转换,多数时候请求发起的业务在C层中,而这些逻辑总是造成Fat Controller的原因 在Entity -> Text涉及到了具体的上层业务,请求中间件不应该向上干涉。在JSON -> Entity的转换过程中,Entity已经组装了业务封装最需要的数据内容 另一个有趣的问题是Entity描述的是数据流动的阶段状态,而非具体数据类型。打个比方,Entity不一定非得是类对象实例,只要Entity遵守业务封装的读取规范,可以是instance也可以是collection,比如一个面试者Entity只要能提供姓名和工作年限这两个关键数据即可: /// 抽象模型 @interface SLInterviewer : NSObject @property (nonatomic, copy) NSString *name; @property (nonatomic, assign) CGFloat workYears; @end SLInterviewer *interviewer = entity; NSLog(@"The interviewer name: %@ and work-years: %g", interviewer.name, interviewer.workYears); /// 键值约定 extern NSString *SLInterviewerNameKey; extern NSString *SLInterviewerWorkYearsKey; NSDictionary *interviewer = entity; NSLog(@"The interviewer name: %@ and work-years: %@", interviewer[SLInterviewerNameKey], interviewer[SLInterviewerWorkYearsKey]); 如果让集约式请求的中间件交付Entity数据,JSON -> Entity的形态转换可能会导致请求中间件涉及到具体的业务逻辑中,因此在实现上需要提供一个parser来完成这一过程: @protocol EntityParser - (id)parseJSON: (id)JSON; @end @interface SLIntensiveRequest : NSObject @property (nonatomic, strong) id<EntityParser> parser; - (void)GET: (NSString *)url params: (id)params success: (SLSuccess)success failure: (SLFailure)failure; @end 而相较之下,离散型请求中BaseManager承担了统一的请求行为,派生的manager完全可以直接将转换的逻辑直接封装起来,无需额外的Parser,唯一需要考虑的是Entity的具体实体对象是否需要抽象模型来表达: @implementation SLGetInterviewerApiManager /// 抽象模型 - (id)entityFromJSON: (id)json { if ([json isKindOfClass: [NSDictionary class]]) { return [SLInterviewer interviewerWithJSON: json]; } else { return nil; } } - (void)didLoadData { self.dataList = self.response.safeMap(^id(id item) { return [self entityFromJSON: item]; }).safeMap(^id(id interviewer) { return [SLInterviewerInfo infoWithInterviewer: interviewer]; }); if ([_delegate respondsToSelector: @selector(managerDidLoadData:)]) { [_delegate managerDidLoadData: self]; } } /// 键值约定 - (id)entityFromJSON: (id)json keyMap: (NSDictionary *)keyMap { if ([json isKindOfClass: [NSDictionary class]]) { NSDictionary *dict = json; NSMutableDictionary *entity = @{}.mutableCopy; for (NSString *key in keyMap) { NSString *entityKey = keyMap[key]; entity[entityKey] = dict[key]; } return entity.copy; } else { return nil; } } @end 甚至再进一步,manager可以同时交付Text和Entity这两种数据形态,使用parser可以对C层完成隐藏数据的转换过程: @protocol TextParser - (id)parseEntity: (id)entity; @end @interface SLInterviewerTextContent : NSObject @property (nonatomic, readonly) NSString *name; @property (nonatomic, readonly) NSString *workYear; @property (nonatomic, readonly) SLInterviewer *interviewer; - (instancetype)initWithInterviewer: (SLInterviewer *)interviewer; @end @implementation SLInterviewerTextParser - (id)parseEntity: (SLInterviewer *)entity { return [[SLInterviewerTextContent alloc] initWithInterviewer: entity]; } @end 通用的请求接口 是否需要统一接口的请求封装层 在App中的请求分为三类:GET、POST和UPLOAD,在不考虑进行封装的情况下,核心层的请求接口至少需要三种不同的接口来对应这三种请求类型。此外还要考虑核心层的请求接口一旦发生变动(例如AFN在更新至3.0的时候修改了请求接口),因此对业务请求发起方来说,存在一个封装的请求中间层可以有效的抵御请求接口改动的风险,以及有效的减少代码量。上文可以看到对业务层暴露的中间件manager的作用是对请求的行为进行统一,但并不干预请求的细节,因此manager也能被当做是一个请求发起方,那么在其下层需要有暴露统一接口的请求封装层: ------------- 中间件 | Manager | ------------- ↓ ↓ ------------- 请求层 | Request | ------------- ↓ ↓ ------------- 核心请求 | CoreNet | ------------- 封装请求层的问题在于如何只暴露一个接口来适应多种情况类型,一个方法是将请求内容抽象成一系列的接口协议,Request层根据接口返回参数调度具体的请求接口: /// 协议接口层 enum { SLRequestMethodGet, SLRequestMethodPost, SLRequestMethodUpload }; @protocol RequestEntity - (int)requestMethod; /// 请求类型 - (NSString *)urlPath; /// 提供域名中的path段,以便组装:xxxxx/urlPath - (NSDictionary *)parameters; /// 参数 @end extern NSString *SLRequestParamPageKey; extern NSString *SLRequestParamPageCountKey; @interface RequestListEntity : NSObject<RequestEntity> @property (nonatomic, assign) NSUInteger page; @property (nonatomic, assign) NSUInteger pageCount; @end /// 请求层 typedef void(^SLRequestComplete)(id response, NSError *error); @interface SLRequestEngine + (instancetype)engine; - (void)sendRequest: (id<RequestEntity>)request complete: (SLRequestComplete)complete; @end @implementation SLRequestEngine - (void)sendRequest: (id<RequestEntity>)request complete: (SLRequestComplete)complete { if (!request || !complete) { return; } if (request.requestMethod == SLRequestMethodGet) { [self get: request complete: complete]; } else if (request.requestMethod == SLRequestMethodPost) { [self post: request complete: complete]; } else if (request.requestMethod == SLRequestMethodUpload) { [self upload: request complete: complete]; } } @end 这样一来,当有新的请求API时,创建对应的RequestEntity和Manager类来处理请求。对于业务上层来说,整个请求过程更像是一个异步的fetch流程,一个单独的manager负责加载数据并在加载完成时回调。Manager也不用了解具体是什么请求,只需要简单的配置参数即可,Manager的设计如下: @interface WSBaseApiManager : NSObject @property (nonatomic, readonly, strong) id data; @property (nonatomic, readonly, strong) NSError *error; /// 请求失败时不为空 @property (nonatomic, weak) id<WSBaseApiManagerDelegate> delegate; @end @interface WSBaseListApiManager : NSObject @property (nonatomic, assign) BOOL hasMore; @property (nonatomic, readonly, copy) NSArray *dataList; @end @interface SLGetInterviewerRequest: RequestListEntity @end @interface SLGetInterviewerManager : WSBaseListApiManager @end @implementation SLGetInterviewerManager - (void)loadWithParams: (NSDictionary *)params { SLGetInterviewerRequest *request = [SLGetInterviewerRequest new]; request.page = [params[SLRequestParamPageKey] unsignedIntegerValue]; request.pageCount = [params[SLRequestParamPageCountKey] unsignedIntegerValue]; [[SLRequestEngine engine] sendRequest: request complete: ^(id response, NSError *error){ /// do something when request complete }]; } @end 最终请求结构: ------------- 业务层 | Client | ------------- ↓ ↓ ------------- 中间件 | Manager | ------------- ↓ ↓ ------------- | Request | ------------- ↓ ↓ 请求层 ----------------------------------- ↓ ↓ ↓ ↓ ↓ ↓ ------------- ------------- ------------- | GET | | POST | | Upload | ------------- ------------- ------------- ↓ ↓ ↓ ↓ ↓ ↓ --------------------------------------------- 核心请求 | CoreNet | ---------------------------------------------开发笔记-警惕swizzling2018-09-14T20:00:00+08:002018-09-14T20:00:00+08:00www.sindrilin.com/2018/09/14/watch_on_swizzling<p>不知道什么时候开始,只要使用了<code class="language-plaintext highlighter-rouge">swizzling</code>都能被解读成是<code class="language-plaintext highlighter-rouge">AOP</code>开发,开发者张口嘴就是<code class="language-plaintext highlighter-rouge">runtime</code>,将其高高捧起,称之为<code class="language-plaintext highlighter-rouge">黑魔法</code>;以项目中各种<code class="language-plaintext highlighter-rouge">method_swizzling</code>为荣,却不知道这种做法破坏了代码的整体性,使关键逻辑支离破碎。本文基于<a href="https://www.valiantcat.cn/index.php/2017/11/03/53.html#menu_index_7">iOS界的毒瘤</a>一文,从另外的角度谈谈为什么我们应当<code class="language-plaintext highlighter-rouge">警惕</code></p>
<h2 id="调用顺序性">调用顺序性</h2>
<p><code class="language-plaintext highlighter-rouge">调用顺序性</code>是链接文章讲述的的核心问题,它会破坏方法的原有执行顺序,导致意料之外的错误。先从一段简单的代码聊起:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@interface SLTestObject: NSObject
@end
@implementation SLTestObject
- (instancetype)init {
self = [super init];
return self;
}
@end
void testIsSelectorSame() {
Method allocate1 = class_getClassMethod([NSObject class], @selector(alloc));
Method allocate2 = class_getClassMethod([SLTestObject class], @selector(alloc));
Method initialize1 = class_getInstanceMethod([NSObject class], @selector(init));
Method initialize2 = class_getInstanceMethod([SLTestObject class], @selector(init));
assert(allocate1 == allocate2 && initialize1 != initialize2);
}
</code></pre></div></div>
<p>这段代码的目的是证明一个定论:</p>
<blockquote>
<p>如果子类没有重写父类声明的方法,在子类对象调用该方法时,执行的是父类实现的代码</p>
</blockquote>
<p>基于这一定论,假定一个场景:现在通过无埋点方案统计用户进入和离开<code class="language-plaintext highlighter-rouge">Controller</code>次数:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation UIViewController (SLCount)
+ (void)load {
sl_swizzle([self class], @selector(viewWillAppear:), @selector(sl_viewWillAppearI:));
sl_swizzle([self class], @selector(viewDidDisappear:), @selector(sl_viewDidDisappearI:));
}
- (void)sl_viewWillAppearI: (BOOL)animated {
[SLControllerCounter countControllerEnter: [self class]];
[self sl_viewWillAppearI: animated];
}
- (void)sl_viewDidDisappearI: (BOOL)animated {
[SLControllerCounter countControllerLeave: [self class]];
[self sl_viewDidDisappearI: animated];
}
@end
</code></pre></div></div>
<p>由于<code class="language-plaintext highlighter-rouge">UIViewController</code>是所有控制器的父类,所以理论上只要<code class="language-plaintext highlighter-rouge">swizzle</code>这个类就能统计到所有控制器的信息。同时项目中存在一个定制的基础控制器<code class="language-plaintext highlighter-rouge">SLBaseViewController</code>存在这么一段代码:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@implementation SLBaseViewController (SLCount)
+ (void)load {
sl_swizzle([self class], @selector(viewWillAppear:), @selector(sl_viewWillAppearII:));
sl_swizzle([self class], @selector(viewDidDisappear:), @selector(sl_viewDidDisappearII:));
}
- (void)sl_viewWillAppearII: (BOOL)animated {
[self prepareRequest];
[self sl_viewWillAppearII: animated];
}
- (void)sl_viewDidDisappearII: (BOOL)animated {
[self sl_viewDidDisappearII: animated];
[self cancelAllRequests];
}
@end
</code></pre></div></div>
<p>但是这两段代码却在特定的场景下发生<code class="language-plaintext highlighter-rouge">crash</code>,发生异常的原因在于子类在没有重写方法的情况下,子类先于父类进行了<code class="language-plaintext highlighter-rouge">swizzle</code>的操作。<code class="language-plaintext highlighter-rouge">iOS</code>使用中方法名称<code class="language-plaintext highlighter-rouge">SEL</code>和方法实现<code class="language-plaintext highlighter-rouge">IMP</code>是分开存放的,使用结构体<code class="language-plaintext highlighter-rouge">Method</code>将两者关联到一起:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>typedef struct Method {
SEL name;
IMP imp;
} Method;
</code></pre></div></div>
<p>交换方法会将两个<code class="language-plaintext highlighter-rouge">method</code>中的<code class="language-plaintext highlighter-rouge">imp</code>进行交换。而在理想情况下,父类先于子类完成了<code class="language-plaintext highlighter-rouge">swizzle</code>,原有方法保存了<code class="language-plaintext highlighter-rouge">swizzle</code>之后的<code class="language-plaintext highlighter-rouge">imp</code>,这时候子类再进行<code class="language-plaintext highlighter-rouge">swizzle</code>就能正确调用。下图标识了<code class="language-plaintext highlighter-rouge">SEL</code>和<code class="language-plaintext highlighter-rouge">IMP</code>的关联,箭头表示<code class="language-plaintext highlighter-rouge">IMP</code>的调用次序:</p>
<p><img src="https://user-gold-cdn.xitu.io/2018/9/19/165f169b982b1d12?w=1240&h=1018&f=png&s=606738" alt="" /></p>
<p>但是如果子类的<code class="language-plaintext highlighter-rouge">swizzle</code>发生的更早,这时候<code class="language-plaintext highlighter-rouge">viewWillAppear</code>对应的<code class="language-plaintext highlighter-rouge">imp</code>已经被修改,父类再进行<code class="language-plaintext highlighter-rouge">swizzle</code>的时候,调用次序已经出错:</p>
<p><img src="https://user-gold-cdn.xitu.io/2018/9/19/165f169b9a481def?w=1240&h=1010&f=png&s=608322" alt="" /></p>
<p>解决方式也并不复杂,包括:</p>
<ol>
<li>在<code class="language-plaintext highlighter-rouge">swizzle</code>之前先<code class="language-plaintext highlighter-rouge">addMethod</code>,保证子类不沿用父类的默认实现</li>
<li>每次调用通过<code class="language-plaintext highlighter-rouge">sel</code>去获取<code class="language-plaintext highlighter-rouge">imp</code>执行</li>
</ol>
<p>具体的实现代码可以参考<a href="https://www.valiantcat.cn/index.php/2017/11/03/53.html#menu_index_7">iOS界的毒瘤</a>的解决方案</p>
<h2 id="行为冲突">行为冲突</h2>
<p>在<code class="language-plaintext highlighter-rouge">OOP</code>的设计中,将描述对象抽象成类,将对象行为抽象成接口。从工程师的角度来说,职责单一的接口更利于迭代维护。类一旦设计好,应当不改动或者少改动接口。对于设计良好的接口来说,<code class="language-plaintext highlighter-rouge">swizzle</code>很可能直接破坏了整个接口的行为:</p>
<p><img src="https://user-gold-cdn.xitu.io/2018/9/19/165f169b98002788?w=1240&h=449&f=png&s=267489" alt="" /></p>
<p>举个例子,<code class="language-plaintext highlighter-rouge">crash防护</code>是当下被追捧的工具,但其中<code class="language-plaintext highlighter-rouge">KVO</code>的防护或许是一种很烂的手段。从实现来说,为了避免<code class="language-plaintext highlighter-rouge">KVO</code>导致的循环引用,需要在引用关系的中间插入一个<code class="language-plaintext highlighter-rouge">weakProxy</code>来做防护,因此监听代码实际上可以转换成:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 表面代码
[observedObj addObserver: self forKeyPath: keyPath options: NSKeyValueObservingOptionNew context: nil];
// 实际效果
WeakProxy *proxy = [WeakProxy new];
proxy.client = self;
[observedObj addObserver: proxy forKeyPath: keyPath options: NSKeyValueObservingOptionNew context: nil];
</code></pre></div></div>
<p>为什么说这种设计很烂的?一旦客户端出现这样的代码:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- (void)dealloc {
......
[observedObj removeObserver: self forKeyPath: keyPath];
}
</code></pre></div></div>
<p>通常情况下,以现在的多数<code class="language-plaintext highlighter-rouge">防护工具</code>的实现,都会发生崩溃。对于<code class="language-plaintext highlighter-rouge">swizzle</code>代码外的使用者来说,或许根本不清楚<code class="language-plaintext highlighter-rouge">observer</code>早已发生了转移,导致了原有的正确调用出错。解决方案之一是对<code class="language-plaintext highlighter-rouge">remove</code>接口同样进行<code class="language-plaintext highlighter-rouge">swizzle</code>,使得两次调用的监听对象配套:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- (void)sl_removeObserver: (id)observer forKeyPath: (NSString *)keyPath {
[self sl_removeObserver: observer.proxy forKeyPath: keyPath];
}
</code></pre></div></div>
<p>然而这样做之后,首先<code class="language-plaintext highlighter-rouge">KVO</code>的行为已经被修改,接口被破坏可能导致潜在的隐患。其次,如果存在多个防护工具,如果按照<code class="language-plaintext highlighter-rouge">weakProxy</code>的实现,那么一旦有<code class="language-plaintext highlighter-rouge">2</code>个或者更多的防护时,<code class="language-plaintext highlighter-rouge">KVO</code>功能将失效:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>OneWeakProxy *proxy = [OneWeakProxy new];
proxy.client = self;
[observedObj addObserver: proxy forKeyPath: keyPath options: NSKeyValueObservingOptionNew context: nil];
TwoWeakProxy *proxy = [TwoWeakProxy new];
proxy.client = self; /// self is OneWeakProxy
[observedObj addObserver: proxy forKeyPath: keyPath options: NSKeyValueObservingOptionNew context: nil];
</code></pre></div></div>
<p>在第二次生成<code class="language-plaintext highlighter-rouge">WeakProxy</code>后并调用方法后,<code class="language-plaintext highlighter-rouge">OneWeakProxy</code>创建的对象被释放。如果要避免多个防护工具对流程造成干扰,还需要做更多额外的工作。况且一旦有其中一个没有完美实现,整套<code class="language-plaintext highlighter-rouge">防护机制</code>可能就直接崩溃失效了,因此<code class="language-plaintext highlighter-rouge">KVO防护</code>不见得是一种好手段</p>
<p><img src="https://user-gold-cdn.xitu.io/2018/9/19/165f169b9a244023?w=588&h=572&f=png&s=346731" alt="" /></p>
<h2 id="代码整体性">代码整体性</h2>
<p>以上面例子来说,<code class="language-plaintext highlighter-rouge">KVO</code>是<code class="language-plaintext highlighter-rouge">NSObject</code>这个基类提供的能力,由于<code class="language-plaintext highlighter-rouge">子类默认沿用父类的方法实现</code>这一原则,这种方法的<code class="language-plaintext highlighter-rouge">swizzle</code>实际上影响了全部的对象,例如下面的代码实际上效果是完全一样的:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/// swizzle 1
void swizzleTableView() {
Method ori = class_getClassMethod([UITableView class], @selector(addObserver:forKeyPath:options:context:));
Method cus = class_getClassMethod([UITableView class], @selector(sl_addObserver:forKeyPath:options:context:));
method_exchange(ori, cus);
}
/// swizzle 2
void swizzleObj() {
Method ori = class_getClassMethod([NSObject class], @selector(addObserver:forKeyPath:options:context:));
Method cus = class_getClassMethod([NSObject class], @selector(sl_addObserver:forKeyPath:options:context:));
method_exchange(ori, cus);
}
</code></pre></div></div>
<p>而第一个方法由于默认实现是<code class="language-plaintext highlighter-rouge">NSObject</code>的,因此一旦发生了<code class="language-plaintext highlighter-rouge">swizzle</code>所有的对象都会生效,这存在两个问题:</p>
<ol>
<li>非<code class="language-plaintext highlighter-rouge">UITableView</code>对象依旧受到了<code class="language-plaintext highlighter-rouge">KVO</code>的拦截影响</li>
<li>没有<code class="language-plaintext highlighter-rouge">sl_addObserver:forKeyPath:options:context:</code>的对象会发生崩溃</li>
</ol>
<p>另一方面,类的接口设计总是偏向于<code class="language-plaintext highlighter-rouge">装扮模式</code>的思维,不同层级的类对象在自己的方法被调用起时会执行自身特有的工作,这种设计让继承有足够的灵活性,从<code class="language-plaintext highlighter-rouge">viewDidLoad</code>的实现代码可见一斑:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- (void)viewDidLoad {
[super viewDidLoad];
/// setup work
}
</code></pre></div></div>
<p>换句话说,以这种<code class="language-plaintext highlighter-rouge">装扮模式</code>思维来构建的代码,如果中间的一个方法被影响甚至破坏了,在中间的这个类开始往下将呈现塌式破坏,可以想象如果<code class="language-plaintext highlighter-rouge">UIView</code>一旦出错,应用几乎丧失展示控件的能力。但假如确实需要<code class="language-plaintext highlighter-rouge">swizzle</code>的中间环节,必须保证<code class="language-plaintext highlighter-rouge">swizzle</code>不对或者尽量少地对子类对象造成影响</p>
<p><img src="https://user-gold-cdn.xitu.io/2018/9/19/165f169b917d7ba4?w=430&h=430&f=jpeg&s=23750" alt="关注我的公众号获取更新信息" /></p>sindrilinsindrilin@foxmail.com不知道什么时候开始,只要使用了swizzling都能被解读成是AOP开发,开发者张口嘴就是runtime,将其高高捧起,称之为黑魔法;以项目中各种method_swizzling为荣,却不知道这种做法破坏了代码的整体性,使关键逻辑支离破碎。本文基于iOS界的毒瘤一文,从另外的角度谈谈为什么我们应当警惕 调用顺序性 调用顺序性是链接文章讲述的的核心问题,它会破坏方法的原有执行顺序,导致意料之外的错误。先从一段简单的代码聊起: @interface SLTestObject: NSObject @end @implementation SLTestObject - (instancetype)init { self = [super init]; return self; } @end void testIsSelectorSame() { Method allocate1 = class_getClassMethod([NSObject class], @selector(alloc)); Method allocate2 = class_getClassMethod([SLTestObject class], @selector(alloc)); Method initialize1 = class_getInstanceMethod([NSObject class], @selector(init)); Method initialize2 = class_getInstanceMethod([SLTestObject class], @selector(init)); assert(allocate1 == allocate2 && initialize1 != initialize2); } 这段代码的目的是证明一个定论: 如果子类没有重写父类声明的方法,在子类对象调用该方法时,执行的是父类实现的代码 基于这一定论,假定一个场景:现在通过无埋点方案统计用户进入和离开Controller次数: @implementation UIViewController (SLCount) + (void)load { sl_swizzle([self class], @selector(viewWillAppear:), @selector(sl_viewWillAppearI:)); sl_swizzle([self class], @selector(viewDidDisappear:), @selector(sl_viewDidDisappearI:)); } - (void)sl_viewWillAppearI: (BOOL)animated { [SLControllerCounter countControllerEnter: [self class]]; [self sl_viewWillAppearI: animated]; } - (void)sl_viewDidDisappearI: (BOOL)animated { [SLControllerCounter countControllerLeave: [self class]]; [self sl_viewDidDisappearI: animated]; } @end 由于UIViewController是所有控制器的父类,所以理论上只要swizzle这个类就能统计到所有控制器的信息。同时项目中存在一个定制的基础控制器SLBaseViewController存在这么一段代码: @implementation SLBaseViewController (SLCount) + (void)load { sl_swizzle([self class], @selector(viewWillAppear:), @selector(sl_viewWillAppearII:)); sl_swizzle([self class], @selector(viewDidDisappear:), @selector(sl_viewDidDisappearII:)); } - (void)sl_viewWillAppearII: (BOOL)animated { [self prepareRequest]; [self sl_viewWillAppearII: animated]; } - (void)sl_viewDidDisappearII: (BOOL)animated { [self sl_viewDidDisappearII: animated]; [self cancelAllRequests]; } @end 但是这两段代码却在特定的场景下发生crash,发生异常的原因在于子类在没有重写方法的情况下,子类先于父类进行了swizzle的操作。iOS使用中方法名称SEL和方法实现IMP是分开存放的,使用结构体Method将两者关联到一起: typedef struct Method { SEL name; IMP imp; } Method; 交换方法会将两个method中的imp进行交换。而在理想情况下,父类先于子类完成了swizzle,原有方法保存了swizzle之后的imp,这时候子类再进行swizzle就能正确调用。下图标识了SEL和IMP的关联,箭头表示IMP的调用次序: 但是如果子类的swizzle发生的更早,这时候viewWillAppear对应的imp已经被修改,父类再进行swizzle的时候,调用次序已经出错: 解决方式也并不复杂,包括: 在swizzle之前先addMethod,保证子类不沿用父类的默认实现 每次调用通过sel去获取imp执行 具体的实现代码可以参考iOS界的毒瘤的解决方案 行为冲突 在OOP的设计中,将描述对象抽象成类,将对象行为抽象成接口。从工程师的角度来说,职责单一的接口更利于迭代维护。类一旦设计好,应当不改动或者少改动接口。对于设计良好的接口来说,swizzle很可能直接破坏了整个接口的行为: 举个例子,crash防护是当下被追捧的工具,但其中KVO的防护或许是一种很烂的手段。从实现来说,为了避免KVO导致的循环引用,需要在引用关系的中间插入一个weakProxy来做防护,因此监听代码实际上可以转换成: // 表面代码 [observedObj addObserver: self forKeyPath: keyPath options: NSKeyValueObservingOptionNew context: nil]; // 实际效果 WeakProxy *proxy = [WeakProxy new]; proxy.client = self; [observedObj addObserver: proxy forKeyPath: keyPath options: NSKeyValueObservingOptionNew context: nil]; 为什么说这种设计很烂的?一旦客户端出现这样的代码: - (void)dealloc { ...... [observedObj removeObserver: self forKeyPath: keyPath]; } 通常情况下,以现在的多数防护工具的实现,都会发生崩溃。对于swizzle代码外的使用者来说,或许根本不清楚observer早已发生了转移,导致了原有的正确调用出错。解决方案之一是对remove接口同样进行swizzle,使得两次调用的监听对象配套: - (void)sl_removeObserver: (id)observer forKeyPath: (NSString *)keyPath { [self sl_removeObserver: observer.proxy forKeyPath: keyPath]; } 然而这样做之后,首先KVO的行为已经被修改,接口被破坏可能导致潜在的隐患。其次,如果存在多个防护工具,如果按照weakProxy的实现,那么一旦有2个或者更多的防护时,KVO功能将失效: OneWeakProxy *proxy = [OneWeakProxy new]; proxy.client = self; [observedObj addObserver: proxy forKeyPath: keyPath options: NSKeyValueObservingOptionNew context: nil]; TwoWeakProxy *proxy = [TwoWeakProxy new]; proxy.client = self; /// self is OneWeakProxy [observedObj addObserver: proxy forKeyPath: keyPath options: NSKeyValueObservingOptionNew context: nil]; 在第二次生成WeakProxy后并调用方法后,OneWeakProxy创建的对象被释放。如果要避免多个防护工具对流程造成干扰,还需要做更多额外的工作。况且一旦有其中一个没有完美实现,整套防护机制可能就直接崩溃失效了,因此KVO防护不见得是一种好手段 代码整体性 以上面例子来说,KVO是NSObject这个基类提供的能力,由于子类默认沿用父类的方法实现这一原则,这种方法的swizzle实际上影响了全部的对象,例如下面的代码实际上效果是完全一样的: /// swizzle 1 void swizzleTableView() { Method ori = class_getClassMethod([UITableView class], @selector(addObserver:forKeyPath:options:context:)); Method cus = class_getClassMethod([UITableView class], @selector(sl_addObserver:forKeyPath:options:context:)); method_exchange(ori, cus); } /// swizzle 2 void swizzleObj() { Method ori = class_getClassMethod([NSObject class], @selector(addObserver:forKeyPath:options:context:)); Method cus = class_getClassMethod([NSObject class], @selector(sl_addObserver:forKeyPath:options:context:)); method_exchange(ori, cus); } 而第一个方法由于默认实现是NSObject的,因此一旦发生了swizzle所有的对象都会生效,这存在两个问题: 非UITableView对象依旧受到了KVO的拦截影响 没有sl_addObserver:forKeyPath:options:context:的对象会发生崩溃 另一方面,类的接口设计总是偏向于装扮模式的思维,不同层级的类对象在自己的方法被调用起时会执行自身特有的工作,这种设计让继承有足够的灵活性,从viewDidLoad的实现代码可见一斑: - (void)viewDidLoad { [super viewDidLoad]; /// setup work } 换句话说,以这种装扮模式思维来构建的代码,如果中间的一个方法被影响甚至破坏了,在中间的这个类开始往下将呈现塌式破坏,可以想象如果UIView一旦出错,应用几乎丧失展示控件的能力。但假如确实需要swizzle的中间环节,必须保证swizzle不对或者尽量少地对子类对象造成影响