之前的那个版本,选用 TF-Slim API 编写代码,就是因为这套 API 是比较优雅的,比如想调用一次最基本的卷积层运算,如果直接使用 tf.nn.conv2d 的话,代码会是下面这个样子:
1 | input = ... |
如果用 TF-Slim API 编码的话,则会变成下面这种风格:
1 | input = ... |
因为在各种卷积神经网络结构中,通常都会大量的使用卷积运算,构建很多卷积层,并且使用不同的配置参数,所以很明显,TF-Slim 风格的 API 可以很优雅的简化代码。
但是,在看过图像处理领域的一些论文和各种版本的参考代码之后,发现 TF-Slim 还是有一些局限性的。常规的卷积层操作,用 TF-Slim 是可以简化代码,但是神经网络这个领域发展的速度太快了,经常都会有新的论文发表出来,也就经常会遇到一些新的 layer 结构,TF-Slim 并不是总能很方便的表达出这些 layer,因此需要一种更低层一些、但是更灵活,同时还保持优雅的解决办法。
顺着这个思路,后来发现其实 tf.layers 这个 Module 就可以很好的满足前面提到的这些需求。
另外,这次遇到的在 TensorFlow 1.7 上旧模型不收敛的情况,虽然没有准确定位到原因、没找到解决办法,但是分析了一圈后,其实还是怀疑是因为使用 TF-Slim 而引出的问题,虽然 TF-Slim 简化了卷积层相关的代码,但是完整的代码中还是要使用 TensorFlow 中的其他 API 的,TF-Slim 封装出来的抽象度比较高,除了卷积操作的 API,它还封装了其他的一些 API,但是它的抽象设计和 TensorFlow 是有一种分裂感的,混合在一起编程时会觉得有点奇怪,我这次遇到的问题,也可能就是某些 API 使用的不正确而引起的(TF1.0时运行正常,TF1.7时运行不正常)。而 tf.layers 就不会有这种感觉,tf.layers 的抽象度比 TF-Slim 更低一些,它更像是 TensorFlow 的底层 API 的一个延展,并没有引入新的抽象度,这套 API 用起来就更舒服一些。
比如,升级前的 HED 网络,换用 tf.layers 后,代码是下面这个样子:
1 | def vgg_style_hed(inputs, batch_size, is_training): |
上面这份代码里面的一些细节,会在后面的章节里详细介绍,并且会逐步的演化成 MobileNetV2 style 的 HED 网络。这里首先看一下代码的整体结构,相当于是套用了下面这种形式的模板:
1 | def xx_net(inputs, batch_size, is_training): |
这种风格的代码,前面一部分就是定义实现不同功能的各种 layer,后面部分就是用各种 layer 来组装 net 的主体结构。layer 由嵌套函数定义,方便进行各种自定义的配置或组装,net 主体部分,跟 TF-Slim 的风格其实也是类似的,layer 之间的层级关系简单明了,更容易和论文中的配置表格或结构示意图对应起来。我在实现其他网络结构的时候,都是套用的这种代码结构,基本上都能满足灵活性和简洁性的需求。
矩阵的初始化方法有很多种,在 TensorFlow 里,常规初始化方法的效果对比可以看这篇文章 Weight Initialization,能使用 tf.truncated_normal 或 tf.truncated_normal_initializer 进行初始化,说明已经对这个问题有所掌握了,随着学习的深入,更推荐使用另外一种初始化方法 Xavier initialization ,使用起来也比较简单:
1 | W = tf.get_variable('W', shape=[784, 256], |
关于 Xavier initialization 的更多内容,请参考本文末尾部分列出的资料。
Batch Normalization – Lesson 这篇教程对 Batch Normalization 解释的比较清楚,通俗点描述,普通的 Normalization 是对神经网络的输入数据做归一化处理,把输入数据和输出数据的取值都缩放到一个范围内,通常都是 0.0 ~ 1.0 这个区间,而 Batch Normalization 则是把整体的神经网络结构看成是由很多不同的 layer 组成的,对每个 layer 的输入数据再做一次规范化的操作,因为只能在训练的过程中才能获取到每个 layer 上的 input data,而训练过程又是基于 batch 的,所以叫做 Batch Normalization。Batch Normalization 的具体数学公式,这里不详细描述了,有兴趣的读者请参考末尾部分列出的资料,下面仅从工程层面提出一些建议和要注意的细节点。
Batch Normalization 的优势挺多的,比如可以加快模型收敛的速度、可以使用较高的 learning rates、可以降低权重矩阵初始化的难度、可以提高网络的训练效果等等,总而言之,就是要尽量的使用 Batch Normalization 技术。近几年新发表的很多论文中,也是经常看到 Batch Normalization 的身影。
TensorFlow 提供了相关的 API,在 layer 中添加 Batch Normalization 也就是一行代码的事,不过因为 Batch Normalization 里面有一部分参数也是需要参与反向传播过程进行训练的,所以构造优化器的时候,还要额外添加一些代码把 Batch Normalization 的权重参数也包含进去,类似下面这样:
1 | ... |
从前面的代码片段可以看到,用了 Batch Normalization 后,就不再需要添加 bias 偏移向量了,Can not use both bias and batch normalization in convolution layers 这里有解释原因。
前面有一个典型的代码片段:
1 | def _vgg_conv2d(inputs, filters, kernel_size): |
这里容易遇到一个陷进,我之前就掉进去过。在看其他代码和资料的时候,也经常看到 convolution + batch_normalization + relu 这种顺序的代码调用,如果理解的不透彻,很有可能会错误的认为在每一个 convolution layer 的后面都应该添加一个 tf.layers.batch_normalization 调用,但是实际上,如果当前 layer 已经是网络结构中最后的 layer 或者已经属于 output layer 了,其实是不应该再使用 Batch Normalization 的。按照定义,是在 layer 的 input 部分添加 Batch Normalization,而代码里看上去像是在 layer 的 output 上调用了一次 Batch Normalization,这只是为了在代码里让 layer 更容易连接起来,而且,如果是第一层 layer,它的输入就是已经归一化处理过的 input label 数据,这也是不需要 Batch Normalization 的,到了最后一层 layer 的时候,理论上来说是需要 Batch Normalization 的,只不过对应到代码上,最后这层 layer 的 Batch Normalization 是添加在倒数第二层 layer 的输出结果上的。所以,在前面 HED 的代码里,_dsn_deconv2d_with_upsample_factor 和 _output_1x1_conv2d 这两种 layer 的封装函数里都是没有 Batch Normalization 的。
另外,之前展示的代码都是把 batch_normalization 放在了 relu 激活函数的前面,网上的很多代码也是这样写的,其实把 batch_normalization 放在非线性函数的后面也是可以的,而且整体的准确率可能还会有一点点提升,BN – before or after ReLU? 这里有一个简单的数据对比,可以参考。总之,batch_normalization 和激活函数的先后顺序,是可以灵活选择的。
这也是一个容易混淆的地方,其实 Batch Normalization 和 Regularizer 是完全不一样的东西,Batch Normalization 针对的是 layer 的输入数据,而 Regularizer 针对的是 layer 里面的权重矩阵,前者是从数据层面来改善模型的效果,而后者则是通过改善模型自身来提升模型的效果,这两种技术是不冲突的,可以同时使用。
关于卷积的基本概念,A technical report on convolution arithmetic in the context of deep learning 这里有很直观的动画演示,比如下面这种就是最常见的卷积运算:
其他的学习资料里,通常也是基于一个普通的二维矩阵来描述卷积的运算规则,上图这个例子,就是在一个 shape 为 (height, width) 的矩阵上,使用一个 (3, 3) 的卷积核,然后得到一个 shape 同样为 (height, width) 的矩阵。
但是在神经网络领域里面,卷积层 的运算规则其实是比上面这种单纯的 卷积运算 稍微更复杂一些的。在神经网络里面,通常会使用一个 shape 为 (batch_size, height, width, channels) 的 Tensor 来表示图像,比如一个 RGBA 的图像,channels 就是 4,经过某种卷积层的运算后,得到一个新的 Tensor,这个新的 Tensor 的 channels 通常又会变成另外一个数值,可见,这个 channel 也是有一定的映射规则的,标准的卷积运算和 channel 结合起来,才构成了神经网络里面的卷积层。
在介绍具体的卷积层之前,先使用下面这种简单的示意图来表示一个卷积运算:
顺着示意图中箭头的方向,左侧是输入矩阵,中间是卷积核,右侧是输出矩阵。
TensorFlow 框架里的标准卷积层的定义如下:
1 | tf.nn.conv2d( |
因为这里主要是为了讨论 channel 的映射规则,所以假设采用 ‘SAME’ padding,并且 strides 设置为 1,这样的话,输入的 Tensor 和 输出的 Tensor 中,height 和 width 都是相同的值,输入的 Tensor 的 shape 是 (batch_size, height, width, in_channels),如果期望的输出 Tensor 的 shape 是 (batch_size, height, width, out_channels),则作为 filter 的 Tensor 的 shape 应该设置成 (filter_height, filter_width, in_channels, out_channels),其中的 filter_height 和 filter_width 就对应卷积核的 size,这个函数内部的完整计算过程,可以用下面这个示意图来表示:
图中的 in_channels 等于 2,out_channels 等于 5,总共有 in_channels*out_channels = 10 个卷积核(同时还有 5 次矩阵加法操作),仔细看一下这个示意图就会意识到,每一个输出的矩阵都是由两个输入矩阵共同计算出来的,也就是说不同的输入 channel 会一起影响到每一个输出 channel,通道之间是有关联的。
这种网络结构和前面介绍的标准卷积层其实是一样的,只不过 filter 的 shape 是 (1, 1, in_channels, out_channels),也就是说每一个卷积核都只是一个标量值,而非矩阵。表面上看这种结构有点违反『套路』,因为卷积的目的就是要利用周围像素的 加权和 来替代原始位置上的单个像素,或者说卷积每次关注的是一个区域的像素,而非只关注单个像素。
那 1x1 convolution 的目的是什么呢?前面已经提到了,神经网络里面的卷积层,既有卷积运算,也有 channel 之间的运算,所以 1x1 convolution 的重点就在于让不同的 channel 再结合一遍。类似的,也可以用一个简单的示意图表示这种网络结构:
1x1 convolution 的效果,相当于对输入矩阵做了一个简单的标量乘法,它的参数量和计算量都比标准的卷积层少了很多。前面 HED 代码里的 _output_1x1_conv2d 就是一个 1x1 convolution,在后面的讨论中也会遇到多个例子。
标准卷积层运算,不同的输入 channel 会共同参与计算每一个输出 channel,还有另外一种名为 depthwise convolution 的卷积层运算,channel 之间是完全独立的,TensorFlow 里面的定义如下:
1 | tf.nn.depthwise_conv2d( |
类似的,假设采用 ‘SAME’ padding,并且 strides 设置为 1,最后的三个参数使用默认值,这样的话,输入 Tensor 和 输出 Tensor 的 height 和 width 就会是相同的值,输入的 Tensor 的 shape 是 (batch_size, height, width, in_channels),filter Tensor 的 shape 是 (filter_height, filter_width, in_channels, channel_multiplier),则得到的输出 Tensor 的 shape 是 (batch_size, height, width, in_channels * channel_multiplier),这个函数内部的完整计算过程,可以用下面这个示意图来表示:
可以看到,输出 Tensor 的 channels 不能是任意值,只能是 in_channels 的整数倍,这也就是参数 channel_multiplier 的含义。
depthwise convolution 中,channel 之间是完全不会产生互相影响的,这可能也意味着这种方式的模型的复杂度是不够的,所以在实际使用的过程中,separable convolution 是一个更合适的选择,对应的 TensorFlow API 如下:
1 | tf.nn.separable_conv2d( |
同样的,采用 ‘SAME’ padding,并且 strides 设置为 1,最后的三个参数使用默认值,这样的话,输入 Tensor 和 输出 Tensor 的 height 和 width 就会是相同的值。这个 API 的内部首先执行了一次 depthwise convolution,然后执行了一次 1x1 convolution(pointwise convolution),所以 depthwise_filter 的 shape 应该设置为 (filter_height, filter_width, in_channels, channel_multiplier),pointwise_filter 的 shape 应该设置为 (1, 1, channel_multiplier * in_channels, out_channels),示意图如下:
在使用相同的 in_channels 和 out_channels 参数时,tf.nn.separable_conv2d 的运算量会比 tf.nn.conv2d 更小。
前面看到的几种不同的卷积层函数里,可能会有一个参数 rate,如果设置了 rate 并且 rate > 1,则内部执行了另外一种名为 Dilated Convolutions 的卷积运算操作,这种卷积运算的动画示意图如下:
在做边缘检测任务的时候,并没有用到 Dilated Convolutions,但是这种卷积操作也是很常用的,比如在 DeepLab 网络结构的各个版本中,它都是一个很重要的组件,考虑到这篇文章里已经汇总了多种不同的常用卷积操作,出于完整性的考虑,所以也简单提及一下 Atrous Convolution,有兴趣的同学可以进一步深入了解。
HED 网络中是会用到转置卷积层的,简单回忆一下,transposed convolution 的动画示意图如下:
前一篇文章里提到过,当时是使用了双线性放大矩阵(bilinear upsampling kernel)来对反卷积的 kernel 进行的初始化,因为 FCN 要求采用这种初始化方案(HED 的论文中并没有明确的要求使用双线性初始化)。这次重写代码的时候,转置卷积层也统一替换成了 Xavier initialization,仍然能够得到很好的训练效果,同时也严格参照了 HED 的参考代码对转置卷积层的 kernel size 进行设置,具体的参数都在前面的函数 _dsn_deconv2d_with_upsample_factor 里面。
如何初始化 transposed convolution 的卷积核,这个问题其实纠结了很长时间,而且在前一个版本的 HED 的代码中,也尝试过用 tf.truncated_normal 初始化 transposed convolution 的 kernel,当时的确是没有训练出想要的效果,所以有点迷信『双线性初始化』,后来在做 UNet 网络的时候,因为已经接触到 Xavier initialization 方案了,所以也尝试了用 Xavier 对反卷积的 kernel 进行初始化,得到的效果很好,所以才开始慢慢的不再强求于『双线性初始化』。
Google 了很多文章,仍然没有找到关于『双线性初始化』的权威解释,只是找到过一些零星的线索,比如有些模型里,会把 deconvolution 的 kernel 的 learning rate 设置为 0,同时采用双线性插值矩阵对该 kernel 进行初始化,相当于就是通过双线性插值算法对输入矩阵进行上采样(放大)。目前我个人的准则就是,除非论文中有明确的强调要采用某种特殊的初始化方法,否则还是首先使用常规的 Tensor 初始化方案。这篇文章的读者朋友们,如果对这个问题有更清晰的答案,也请指教一下,谢谢~
顺便再举个例子,Deconvolution and Checkerboard Artifacts 这里就是用 resize-convolution 替代了常规的 deconvolution。
前面着重介绍了几种不同的卷积层运算方式,目的就是为了引出这篇文章 An Intuitive Guide to Deep Network Architectures。VGG 作为一个经典的分类网络模型,它的结构其实是很简单的,就是标准卷积层串联在一起,如果想进一步提高 VGG 网络的准确率,一个比较直观的想法就是串联更多的标准卷积层(让网络变得更深)、在每一层里增加更多的卷积核,想法看上去是对的,但是实际的效果很不好,因为这种方式增加了大量的参数,训练起来自然就更难,而且网络的深度加深后,还会引起一个 梯度消失 的问题,所以简单粗暴并不总是有效的,需要想其他的办法。前面给出链接的这篇文章里介绍的三个重要网络结构,ResNet、Inception 和 Xception,就是为了解决这些问题而发展起来的,这三种网络模型使用的 层结构,已经成为了卷积神经网络领域里面的基础技术手段。
关于 ResNet、Inception、Xception 的详细内容,刚才提到的这篇文章就是一个很好的总结,网上也有一份整理过的中文翻译 无需数学背景,读懂 ResNet、Inception 和 Xception 三大变革性架构,在文末的参考资料里面还会列出几篇很棒的文章或代码。
如果是我自己对这三种网络结构做一个简单的总结,我觉得主要是下面几点:
ResNet、Inception、Xception 追求的目标,就是在达到更高的准确率的前提下,尽量在模型大小、模型运算速度、模型训练速度这几个指标之间找一个平衡点,如果在准确性上允许一定的损失,但是追求更小的模型和更快的速度,这就直接催生了 MobileNet 或类似的以手机端或嵌入式端为运行环境的网络结构的出现。
MobileNet V1 和 MobileNet V2 都是基于 Depthwise Separable Convolution 构建的卷积层(类似 Xception,但是并不是和 Xception 使用的 Separable Convolution 完全一致),这是它满足体积小、速度快的一个关键因素,另外就是精心设计和试验调优出来的层结构,下面就对照论文给出两个版本的代码实现。
MobileNet V1 的整体结构其实并没有特别复杂的地方,和 VGG 类似,层和层之间就是普通的串联型的结构,有区别的地方主要在于 layer 的内部,如下图所示:
这个图中没有用箭头表示数据的传递方向,但是只要对卷积神经网络有初步的经验,就能看出来数据是从上往下传递的,左图是标准的卷积层操作,类似于前面 HED 网络中 _vgg_conv2d 函数的结构(回想一下前面说过的 Batch Normalization 和 relu 先后顺序的话题,虽然 Batch Normalization 可以放到激活函数的后面,但是很多论文里面都还是习惯性的放在激活函数的前面,所以这里的代码也会严格的遵照论文中的方式),右侧的图相当于 separable convolution,但是在中间是有两次 Batch Normalization 的。
论文中用一张如下的表格来描述了整体结构:
下面是一份简单的代码实现:
1 | def mobilenet_v1(inputs, alpha, is_training): |
MobileNet V2 的改动就比较大了,首先引入了两种新的 layer 结构,如下图所示:
很明显的一个差异点,就是左边这种层结构引入了残差网络的手段,另外,这两种层结构中,在 depthwise convolution 之前又添加了一个 1x1 convolution 操作,在之前举得几个例子中,1x1 convolution 都是用来降维的,而在 MobileNet V2 里,这个位于 depthwise convolution 之前的 1x1 convolution 其实用来提升维度的,对应论文中 expansion factor 参数的含义,在 depthwise convolution 之后仍然还有一次 1x1 convolution 调用,但是这个 1x1 convolution 并不会跟随一个激活函数,只是一次线性变换,所以这里也不叫做 pointwise convolution,而是对应论文中的 1x1 projection convolution。
网络的整体结构由下面的表格描述:
代码实现如下:
1 | def mobilenet_v2_func_blocks(is_training): |
原始的 HED 使用 VGG 作为基础网络结构来得到 feature maps,参照这种思路,可以把基础网络部分替换为 MobileNet V2,代码如下:
1 | def mobilenet_v2_style_hed(inputs, batch_size, is_training): |
这个 MobileNet V2 风格的 HED 网络,整体结构和 VGG 风格的 HED 并没有区别,只是把 VGG 里面用到的卷积层操作替换成了 MobileNet V2 对应的卷积层,另外,因为 MobileNet V2 的第一个卷积层就设置了 stride=2,并不匹配 dsn1 层的 size,所以额外添加了两个 stride=1 的普通卷积层,把它们的输出作为 dsn1 层。
MobileNet 只是针对手机运行环境设计出来的执行 分类任务 的网络结构,但是,和同样执行分类任务的 ResNet、Inception、Xception 这一类网络结构类似,都可以作为执行其他任务的网络结构的 base net,提取输入 image 的 feature maps,我尝试过 mobilenet_v2_style_unet、mobilenet_v2_style_deeplab_v3plus、mobilenet_v2_style_ssd,都是可以看到效果的。
作为一个参考值,在 iPhone 7 Plus 上运行这个 mobilenet_v2_style_hed 网络并且执行后续的找点算法,FPS 可以跑到12,基本满足实时性的需求。但是当尝试在 Android 上部署的时候,即便是在高价位高配置的机型上,FPS 也很低,卡顿现象很明显。
经过排查,找到了一些线索。在 iPhone 7 Plus 上,计算量的分布如下图所示:
红框中的三种操作占据了大部分的 CPU 时间,用这几个数值做一个粗略估算,1.0 / (32 + 30 + 10 + 6) = 12.8,这和检测到的 FPS 是比较吻合的,说明大量的计算时间都用在神经网络上了,OpenCV 实现的找点算法的耗时是很短的。
但是在 Android 上,情况则完全不一样了,如下图所示:
用红框里的数值计算一下,FPS = 1.0 / (232 + 76 + 29 + 16) = 2.8,达不到实时的要求。从上图还可以看出,在 Android 上,Batch Normalization 消耗了大量的计算时间,而且和 Conv2D 消耗的 CPU 时间相比,不在一个数量级上了,这就和 iOS 平台上完全不是同一种分布规律了。进一步 debug 后发现,我们 Android 平台的 app,由于一些历史原因被限定住了只能使用 32bit 的 .so 动态库,换成 64bit 的 TensorFlow 动态库在独立的 demo app 里面重新测量,mobilenet_v2_style_hed 在 Android 上的运行情况就和 iOS 的接近了,虽然还是比 iOS 慢,但是 CPU 耗时的统计数据是同一种分布规律了。
所以,性能瓶颈就在于 Batch Normalization 在 32bit 的 ARM CPU 环境中执行效率不高,尝试过使用一些编译器优化选项重新编译 32bit 的 TensorFlow 库,但是并没有明显的改善。最后的解决方案是退而求其次,使用 vgg_style_hed,并且不使用 Batch Normalization,经过这样的调整后,Android 上的统计数据如下图:
在使用 TensorFlow 1.7 部署模型的时候,TensorFlow Lite 还未支持 transposed convolution,所以没有使用 TF Lite (目前 github 上已经看到有 Lite 版本的 transpose_conv.cc 了)。TensorFlow Lite 目前发展的很快,以后在选择部署方案的时候,TensorFlow Lite 是优先于 TensorFlow Mobile 的。
How to do Xavier initialization on TensorFlow
聊一聊深度学习的weight initialization
Understanding the backward pass through Batch Normalization Layer
机器学习里的黑色艺术:normalization, standardization, regularization
How could I use Batch Normalization in TensorFlow?
add Batch Normalization immediately before non-linearity or after in Keras?
What does 1x1 convolution mean in a neural network?
How are 1x1 convolutions the same as a fully connected layer?
One by One [ 1 x 1 ] Convolution - counter-intuitively useful
Upsampling and Image Segmentation with Tensorflow and TF-Slim
Image Segmentation using deconvolution layer in Tensorflow
Network In Network architecture: The beginning of Inception
ResNets, HighwayNets, and DenseNets, Oh My!
Inception modules: explained and implemented
TensorFlow implementation of the Xception Model by François Chollet
2018-06-02 update:
这篇博客有一个后续更新版本,请看 手机端运行卷积神经网络实现文档检测功能(二) – 从 VGG 到 MobileNetV2 知识梳理
另外,代码也已开源放在 github 上,https://github.com/fengjian0106/hed-tutorial-for-document-scanning
需求很容易描述清楚,如上图,就是在一张图里,把矩形形状的文档的四个顶点的坐标找出来。
Google 搜索 opencv scan document,是可以找到好几篇相关的教程的,这些教程里面的技术手段,也都大同小异,关键步骤就是调用 OpenCV 里面的两个函数,cv2.Canny() 和 cv2.findContours()。
看上去很容易就能实现出来,但是真实情况是,这些教程,仅仅是个 demo 演示而已,用来演示的图片,都是最理想的简单情况,真实的场景图片会比这个复杂的多,会有各种干扰因素,调用 canny 函数得到的边缘检测结果,也会比 demo 中的情况凌乱的多,比如会检测出很多各种长短的线段,或者是文档的边缘线被截断成了好几条短的线段,线段之间还存在距离不等的空隙。另外,findContours 函数也只能检测闭合的多边形的顶点,但是并不能确保这个多边形就是一个合理的矩形。因此在我们的第一版技术方案中,对这两个关键步骤,进行了大量的改进和调优,概括起来就是:
下面这张图表,能够很好的说明上面列出的这两个问题:
这张图表的第一列是输入的 image,最后的三列(先不用看这张图表的第二列),是用三组不同阈值参数调用 canny 函数和额外的函数后得到的输出 image,可以看到,边缘检测的效果,并不总是很理想的,有些场景中,矩形的边,出现了很严重的断裂,有些边,甚至被完全擦除掉了,而另一些场景中,又会检测出很多干扰性质的长短边。可想而知,想用一个数学模型,适应这么不规则的边缘图,会是多么困难的一件事情。
在第一版的技术方案中,负责的同学花费了大量的精力进行各种调优,终于取得了还不错的效果,但是,就像前面描述的那样,还是会遇到检测不出来的场景。在第一版技术方案中,遇到这种情况的时候,采用的做法是针对这些不能检测的场景,人工进行分析和调试,调整已有的一组阈值参数和算法,可能还需要加入一些其他的算法流程(可能还会引入新的一些阈值参数),然后再整合到原有的代码逻辑中。经过若干轮这样的调整后,我们发现,已经进入一个瓶颈,按照这种手段,很难进一步提高检测效果了。
既然传统的算法手段已经到极限了,那不如试试机器学习/神经网络。
首先想到的,就是仿照人脸对齐(face alignment)的思路,构建一个端到端(end-to-end)的网络,直接回归拟合,也就是让这个神经网络直接输出 4 个顶点的坐标,但是,经过尝试后发现,根本拟合不出来。后来仔细琢磨了一下,觉得不能直接拟合也是对的,因为:
后来还尝试过用 YOLO 网络做 Object Detection,用 FCN 网络做像素级的 Semantic Segmentation,但是结果都很不理想,比如:
前面尝试的几种神经网络算法,都不能得到想要的效果,后来换了一种思路,既然传统的技术手段里包含了两个关键的步骤,那能不能用神经网络来分别改善这两个步骤呢,经过分析发现,可以尝试用神经网络来替换 canny 算法,也就是用神经网络来对图像中的矩形区域进行边缘检测,只要这个边缘检测能够去除更多的干扰因素,那第二个步骤里面的算法也就可以变得更简单了。
按照这种思路,对于神经网络部分,现在的需求变成了上图所示的样子。
边缘检测这种需求,在图像处理领域里面,通常叫做 Edge Detection 或 Contour Detection,按照这个思路,找到了 Holistically-Nested Edge Detection 网络模型。
HED 网络模型是在 VGG16 网络结构的基础上设计出来的,所以有必要先看看 VGG16。
上图是 VGG16 的原理图,为了方便从 VGG16 过渡到 HED,我们先把 VGG16 变成下面这种示意图:
在上面这个示意图里,用不同的颜色区分了 VGG16 的不同组成部分。
从示意图上可以看到,绿色代表的卷积层和红色代表的池化层,可以很明显的划分出五组,上图用紫色线条框出来的就是其中的第三组。
HED 网络要使用的就是 VGG16 网络里面的这五组,后面部分的 fully connected 层和 softmax 层,都是不需要的,另外,第五组的池化层(红色)也是不需要的。
去掉不需要的部分后,就得到上图这样的网络结构,因为有池化层的作用,从第二组开始,每一组的输入 image 的长宽值,都是前一组的输入 image 的长宽值的一半。
HED 网络是一种多尺度多融合(multi-scale and multi-level feature learning)的网络结构,所谓的多尺度,就是如上图所示,把 VGG16 的每一组的最后一个卷积层(绿色部分)的输出取出来,因为每一组得到的 image 的长宽尺寸是不一样的,所以这里还需要用转置卷积(transposed convolution)/反卷积(deconv)对每一组得到的 image 再做一遍运算,从效果上看,相当于把第二至五组得到的 image 的长宽尺寸分别扩大 2 至 16 倍,这样在每个尺度(VGG16 的每一组就是一个尺度)上得到的 image,都是相同的大小了。
把每一个尺度上得到的相同大小的 image,再融合到一起,这样就得到了最终的输出 image,也就是具有边缘检测效果的 image。
基于 TensorFlow 编写的 HED 网络结构代码如下:
1 | def hed_net(inputs, batch_size): |
论文给出的 HED 网络是一个通用的边缘检测网络,按照论文的描述,每一个尺度上得到的 image,都需要参与 cost 的计算,这部分的代码如下:
1 | input_queue_for_train = tf.train.string_input_producer([FLAGS.csv_path]) |
按照这种方式训练出来的网络,检测到的边缘线是有一点粗的,为了得到更细的边缘线,通过多次试验找到了一种优化方案,代码如下:
1 | input_queue_for_train = tf.train.string_input_producer([FLAGS.csv_path]) |
也就是不再让每个尺度上得到的 image 都参与 cost 的计算,只使用融合后得到的最终 image 来进行计算。
两种 cost 函数的效果对比如下图所示,右侧是优化过后的效果:
另外还有一点,按照 HED 论文里的要求,计算 cost 的时候,不能使用常见的方差 cost,而应该使用 cost-sensitive loss function,代码如下:
1 | def class_balanced_sigmoid_cross_entropy(logits, label, name='cross_entropy_loss'): |
在尝试 FCN 网络的时候,就被这个问题卡住过很长一段时间,按照 FCN 的要求,在使用转置卷积(transposed convolution)/反卷积(deconv)的时候,要把卷积核的值初始化成双线性放大矩阵(bilinear upsampling kernel),而不是常用的正态分布随机初始化,同时还要使用很小的学习率,这样才更容易让模型收敛。
HED 的论文中,并没有明确的要求也要采用这种方式初始化转置卷积层,但是,在训练过程中发现,采用这种方式进行初始化,模型才更容易收敛。
这部分的代码如下:
1 | def get_kernel_size(factor): |
HED 网络不像 VGG 网络那样很容易就进入收敛状态,也不太容易进入期望的理想状态,主要是两方面的原因:
为了解决这里遇到的问题,采用的办法就是先使用少量样本图片(比如 2000 张)训练网络,在很短的训练时间(比如迭代 1000 次)内,如果 HED 网络不能表现出收敛的趋势,或者不能达到 5 个尺度的 image 全部有效的状态,那就直接放弃这轮的训练结果,重新开启下一轮训练,直到满意为止,然后才使用完整的训练样本集合继续训练网络。
HED 论文里使用的训练数据集,是针对通用的边缘检测目的的,什么形状的边缘都有,比如下面这种:
用这份数据训练出来的模型,在做文档扫描的时候,检测出来的边缘效果并不理想,而且这份训练数据集的样本数量也很小,只有一百多张图片(因为这种图片的人工标注成本太高了),这也会影响模型的质量。
现在的需求里,要检测的是具有一定透视和旋转变换效果的矩形区域,所以可以大胆的猜测,如果准备一批针对性更强的训练样本,应该是可以得到更好的边缘检测效果的。
借助第一版技术方案收集回来的真实场景图片,我们开发了一套简单的标注工具,人工标注了 1200 张图片(标注这 1200 张图片的时间成本也很高),但是这 1200 多张图片仍然有很多问题,比如对于神经网络来说,1200 个训练样本其实还是不够的,另外,这些图片覆盖的场景其实也比较少,有些图片的相似度比较高,这样的数据放到神经网络里训练,泛化的效果并不好。
所以,还采用技术手段,合成了80000多张训练样本图片。
如上图所示,一张背景图和一张前景图,可以合成出一对训练样本数据。在合成图片的过程中,用到了下面这些技术和技巧:
经过不断的调整和优化,最终才训练出一个满意的模型,可以再次通过下面这张图表中的第二列看一下神经网络模型的边缘检测效果:
TensorFlow 官方是支持 iOS 和 Android 的,而且有清晰的文档,照着做就行。但是因为 TensorFlow 是依赖于 protobuf 3 的,所以有可能会遇到一些其他的问题,比如下面这两种,就是我们在两个不同的 iOS APP 中遇到的问题和解决办法,可以作为一个参考:
Android 上因为本身是可以使用动态库的,所以即便 app 必须使用 protobuf 2 也没有关系,不同的模块使用 dlopen 的方式加载各自需要的特定版本的库就可以了。
模型通常都是在 PC 端训练的,对于大部分使用者,都是用 Python 编写的代码,得到 ckpt 格式的模型文件。在使用模型文件的时候,一种做法就是用代码重新构建出完整的神经网络,然后加载这个 ckpt 格式的模型文件,如果是在 PC 上使用模型文件,用这个方法其实也是可以接受的,复制粘贴一下 Python 代码就可以重新构建整个神经网络。但是,在手机上只能使用 TensorFlow 提供的 C++ 接口,如果还是用同样的思路,就需要用 C++ API 重新构建一遍神经网络,这个工作量就有点大了,而且 C++ API 使用起来比 Python API 复杂的多,所以,在 PC 上训练完网络后,还需要把 ckpt 格式的模型文件转换成 pb 格式的模型文件,这个 pb 格式的模型文件,是用 protobuf 序列化得到的二进制文件,里面包含了神经网络的具体结构以及每个矩阵的数值,使用这个 pb 文件的时候,不需要再用代码构建完整的神经网络结构,只需要反序列化一下就可以了,这样的话,用 C++ API 编写的代码就会简单很多,其实这也是 TensorFlow 推荐的使用方法,在 PC 上使用模型的时候,也应该使用这种 pb 文件(训练过程中使用 ckpt 文件)。
在手机上加载 pb 模型文件并且运行的时候,遇到过一个诡异的错误,内容如下:
1 | Invalid argument: No OpKernel was registered to support Op 'Mul' with these attrs. Registered devices: [CPU], Registered kernels: |
之所以诡异,是因为从字面上看,这个错误的含义是缺少乘法操作(Mul),但是我用其他的神经网络模型做过对比,乘法操作模块是可以正常工作的。
Google 搜索后发现很多人遇到过类似的情况,但是错误信息又并不相同,后来在 TensorFlow 的 github issues 里终于找到了线索,综合起来解释,是因为 TensorFlow 是基于操作(Operation)来模块化设计和编码的,每一个数学计算模块就是一个 Operation,由于各种原因,比如内存占用大小、GPU 独占操作等等,mobile 版的 TensorFlow,并没有包含所有的 Operation,mobile 版的 TensorFlow 支持的 Operation 只是 PC 完整版 TensorFlow 的一个子集,我遇到的这个错误,就是因为使用到的某个 Operation 并不支持 mobile 版。
按照这个线索,在 Python 代码中逐个排查,后来定位到了出问题的代码,修改前后的代码如下:
1 | def deconv(inputs, upsample_factor): |
问题就是由 deconv 函数中的 tf.shape 和 tf.pack 这两个操作引起的,在 PC 版代码中,为了简洁,是基于这两个操作,自动计算出 upsampled_shape,修改过后,则是要求调用者用 hard coding 的方式设置对应的 upsampled_shape。
TensorFlow 是一个很庞大的框架,对于手机来说,它占用的体积是比较大的,所以需要尽量的缩减 TensorFlow 库占用的体积。
其实在解决前面遇到的那个 crash 问题的时候,已经指明了一种裁剪的思路,既然 mobile 版的 TensorFlow 本来就是 PC 版的一个子集,那就意味着可以根据具体的需求,让这个子集变得更小,这也就达到了裁剪的目的。具体来说,就是修改 TensorFlow 源码中的 tensorflow/tensorflow/contrib/makefile/tf_op_files.txt 文件,只保留使用到了的模块。针对 HED 网络,原有的 200 多个模块裁剪到只剩 46 个,裁剪过后的 tf_op_files.txt 文件如下:
1 | tensorflow/core/kernels/xent_op.cc |
需要强调的一点是,这种操作思路,是针对不同的神经网络结构有不同的裁剪方式,原则就是用到什么模块就保留什么模块。当然,因为有些模块之间还存在隐含的依赖关系,所以裁剪的时候也是要反复尝试多次才能成功的。
除此之外,还有下面这些通用手段也可以实现裁剪的目的:
借助所有这些裁剪手段,最终我们的 ipa 安装包的大小只增加了 3M。如果不做手动裁剪这一步,那 ipa 的增量,则是 30M 左右。
按照 HED 论文给出的参考信息,得到的模型文件的大小是 56M,对于手机来说也是比较大的,而且模型越大也意味着计算量越大,所以需要考虑能否把 HED 网络也裁剪一下。
HED 网络是用 VGG16 作为基础网络结构,而 VGG 又是一个得到广泛验证的基础网络结构,因此修改 HED 的整体结构肯定不是一个明智的选择,至少不是首选的方案。
考虑到现在的需求,只是检测矩形区域的边缘,而并不是检测通用场景下的广义的边缘,可以认为前者的复杂度比后者更低,所以一种可行的思路,就是保留 HED 的整体结构,修改 VGG 每一组卷积层里面的卷积核的数量,让 HED 网络变的更『瘦』。
按照这种思路,经过多次调整和尝试,最终得到了一组合适的卷积核的数量参数,对应的模型文件只有 4.2M,在 iPhone 7P 上,处理每帧图片的时间消耗是 0.1 秒左右,满足实时性的要求。
神经网络的裁剪,目前在学术界也是一个很热门的领域,有好几种不同的理论来实现不同目的的裁剪,但是,也并不是说每一种网络结构都有裁剪的空间,通常来说,应该结合实际情况,使用合适的技术手段,选择一个合适大小的模型文件。
TensorFlow 的 API 是很灵活的,也比较底层,在学习过程中发现,每个人写出来的代码,风格差异很大,而且很多工程师又采用了各种各样的技巧来简化代码,但是这其实反而在无形中又增加了代码的阅读难度,也不利于代码的复用。
第三方社区和 TensorFlow 官方,都意识到了这个问题,所以更好的做法是,使用封装度更高但又保持灵活性的 API 来进行开发。本文中的代码,就是使用 TensorFlow-Slim 编写的。
虽然用神经网络技术,已经得到了一个比 canny 算法更好的边缘检测效果,但是,神经网络也并不是万能的,干扰是仍然存在的,所以,第二个步骤中的数学模型算法,仍然是需要的,只不过因为第一个步骤中的边缘检测有了大幅度改善,所以第二个步骤中的算法,得到了适当的简化,而且算法整体的适应性也更强了。
这部分的算法如下图所示:
按照编号顺序,几个关键步骤做了下面这些事情:
对于上面这个例子,第一版技术方案中检测出来的边缘线如下图所示:
有兴趣的读者也可以考虑一下,在这种边缘图中,如何设计算法才能找出我们期望的那个矩形。
Hacker’s guide to Neural Networks
神经网络浅讲:从神经元到深度学习
分类与回归区别是什么?
神经网络架构演进史:全面回顾从LeNet5到ENet十余种架构
数据的游戏:冰与火
为什么“高大上”的算法工程师变成了数据民工?
Facebook人工智能负责人Yann LeCun谈深度学习的局限性
The best explanation of Convolutional Neural Networks on the Internet!
从入门到精通:卷积神经网络初学者指南
Transposed Convolution, Fractionally Strided Convolution or Deconvolution
A technical report on convolution arithmetic in the context of deep learning
Visualizing what ConvNets learn
Visualizing Features from a Convolutional Neural Network
Neural networks: which cost function to use?
difference between tensorflow tf.nn.softmax and tf.nn.softmax_cross_entropy_with_logits
Why You Should Use Cross-Entropy Error Instead Of Classification Error Or Mean Squared Error For Neural Network Classifier Training
Tensorflow 3 Ways
TensorFlow-Slim
TensorFlow-Slim image classification library
Holistically-Nested Edge Detection
深度卷积神经网络在目标检测中的进展
全卷积网络:从图像级理解到像素级理解
图像语义分割之FCN和CRF
Image Classification and Segmentation with Tensorflow and TF-Slim
Upsampling and Image Segmentation with Tensorflow and TF-Slim
Image Segmentation with Tensorflow using CNNs and Conditional Random Fields
How to Build a Kick-Ass Mobile Document Scanner in Just 5 Minutes
MAKE DOCUMENT SCANNER USING PYTHON AND OPENCV
Fast and Accurate Document Detection for Scanning
有些读者可能会注意到一点,这个系列教程的英文标题是 The Power Of Composition In FRP,看上去并不像是中文标题的直接翻译,其实这也是纠结过后的一个妥协的选择,其实我个人更喜欢这个英文标题,因为 Composition 这个词,更能体现出 FRP 的一个精髓理念,如果要用一个中文词语来表示,我觉得『组装』这个词更准确一些。
先看下面这段代码:
1 | - (void)fetchNecessaryDataForAccounts:(NSArray<FMAccount *> *)accounts { |
这个 pipeline 其实用的就是 collect + combineLatest 或者 zip 这种管道模型,只不过管道内部具体的业务不一样,这里的业务就是针对每一个 FMAccount 帐号,下载一些必要的初始数据,然后等每个下载都完成后,再执行后续的业务,主要就是下面几个点:
在编写程序的时候,通常我们都会提到『复用』这个概念,最简单的场景就是函数复用,这里的 pipeline 也是一种复用,只不过 pipeline 不像普通函数那样通过抽象出输入参数和返回结果来实现复用,pipeline 的复用体现在管道的形状上,这里所谓的形状,就是把 FRP 中对 signal 的各种操作组装起来后 pipeline 的形状。多个 map 串联是一种形状,collect + zip 是一种形状,之前的教程中提到的那些案例,都可以理解为一种形状(甚至还可以看成是多个不同形状的 pipeline 的进一步组装),每一种形状的管道,有输入的数据,有输出的数据,同时,还存在各种各样的中间处理环节,每次复用 pipeline 的时候,输入数据、输出数据以及中间处理环节,都是可以根据具体的业务需求灵活的进行填充的。
回到前面这个例子,accounts 是 pipeline 的输入,successAccounts 和 failAccounts 是 pipeline 的输出,其他的操作都可以看成是中间处理环节。这个 pipeline 仅仅是完成了下载数据的功能,在真实的产品需求中,为了更好的照顾用户体验,还希望能够显示出下载进度信息,也就是说,对于 accounts 这个输入,还需要另外一种形式的输出信息,可以体现出下载进度情况。这里还有一个约束条件,[QHOldAccountMigration fetchInitialDataForAccount:account] 这个操作本身是无法表现出下载数据时的进度信息的,因为并不是下载一个文件(在编程惯例中,通常只在上传和下载文件的时候或类似的场景中,才会设计出能体现进度信息的 API),所以这里还需要想办法模拟出一种进度信息用来在 UI 上进行显示,主要代码如下:
1 | - (void)fetchNecessaryDataForAccounts:(NSArray<FMAccount *> *)accounts { |
下面看看这个 pipeline 是如何组装出来的:
这是一个很复杂的 Pipeline,因为要做的业务比较繁琐,如下图:
需求大致可以描述为:
主要的代码如下:
1 | //13 |
代码有点长,而且里面的 signal 也比较多,主要是下面这些点:
RAC 里面的 collect 是一个比较容易理解的操作,它的强大之处,在于和其他的操作进行组合之后,可以完成很复杂的业务逻辑。在看真实业务代码之前,先通过下面的代码初步了解一下这种 Pipeline 的行为模式。collect 相当于 Rx 中的 ToArray 操作
1 | - (void)testCollectSignalsAndCombineLatestOrZip { |
这个代码纯粹只是为了演示 collect 的行为模式:
这段代码的执行结果如下:
1 | 2016-04-28 17:45:38:034 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] __NSArrayM, ( |
可以看到,array 里面包含的是 3 个 signal。另外,因为 signal 已经形成嵌套了,所以迟早是要 flatten 的,那么如何 flatten 呢?
因为 array 里面有 3 个 signal,所以可以构造一种 Pipeline,把这 3 个 signal 合并成一个 signal,然后对合并后的 signal 再做 flatten 操作。合并的时候,可以有不同的策略,先看下面这段代码:
1 | - (void)testCollectSignalsAndCombineLatestOrZip { |
这段代码在接收到 collect 发送的 array 之后,对这个数组里面的 signal 进行了一个 combineLatest 操作,这个时候,原本的 3 个 signal 被 reduce 成了一个 signal,这个 signal 继续被 flatten 一次,然后最终被 Pipeline 的订阅者接收到。
这段代码的执行结果如下(也可能和下面的结果完全不一样,这是正常的,combineLatest 操作就是这样):
1 | 2016-04-28 18:48:14:453 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] NSTaggedPointerString, A-Z-N |
除了 combineLatest,zip 操作也可以把多个 signal reduce 成一个,但是 zip 的策略是不一样的。
1 | - (void)testCollectSignalsAndCombineLatestOrZip { |
这段代码的执行结果是下面这个样子,不像前面的 combineLatest,zip 操作的结果,只能出现下面这种唯一的情况:
1 | 2016-04-28 18:55:01:208 [com.ReactiveCocoa.RACScheduler.backgroundScheduler] NSTaggedPointerString, A-X-M |
前面的代码很抽象,在业务中,能用上这种 Pipeline 吗?当然是可以的,比如下面这段代码:
1 | - (RACSignal *)savaAvatar:(UIImage *)image withContact:(FMContact *)contact { |
这段代码稍微有点复杂,做的事情是让 FMContact 里面的所有 email 地址,和一个 image 关联在一起,并且保存在服务器端,关键是下面这几个点:
这里有一个槽点,rac_setAvatar 每次都需要传入 image 和 email 地址,然后调用服务器接口进行保存操作,这种方式的接口,不够优雅,对于每一个 email 地址,都要重新发送一遍 image,也有点浪费流量,这是一个历史原因造成的问题。更好的方案是,先把 image 上传到服务器端,然后得到这个 image 对应的一个唯一值,比如 id,然后在这里,只需要让这个 image 的 id 和 email 能够关联起来就行了。不过这并不影响这里 Pipeline 的设计,不管是 image 还是 id,Pipeline 的形状是没有区别的。
再看另外一个真实业务,如下图:
这是一个编辑联系人的页面,整体是用 UITableView 实现的,可以动态的增加、删减字段,其中有一个需求,只有当至少有一个字段有数据的时候,右上角的『保存』按钮才可以使用。如果这个页面,不需要动态的增加、删减字段,那这个需求是很容易实现的,如果不使用 UITableView,就算要动态的增加、删减字段,这个需求实现起来也还好,不会很困难。但是现在的问题在于,要在 UITableView 的基础上实现,这就有点复杂了,UITableViewCell 是在复用的,所以不能直接依赖 UITableViewCell 里面的 UITextField 来判断『保存』按钮是否可用,必须严格的使用 MVC 的思路,先把 UI 上所有的操作(增加、删减字段,编辑字段内容)都映射到 model 上,通过 model 再来计算『保存』按钮是否可用。UITableView 的代码,是传统代码和 RAC 混合编写的,RAC 做的事情并不多,主要是把 UITextField 的内容用 signal 发送出来,因为并不复杂(但是也挺繁琐的,产品还提了很多很细节的体验要求),所以这里不详细讨论,主要还是看一下基于 model 构造的 Pipeline:
1 | - (void)initPipline { |
这部分代码有点长,不过不用恐惧,中间有很大一部分代码都是做的类似事情,只需要看其中的一个就行,以 email 字段为例子:
上面这段代码,最终实现出了一个 signal,就是 contactHasNoPros,这个 signal 的订阅者,根据 next 发送的 Bool 值,设置 button 的状态就可以了,代码片段如下:
1 | @weakify(self); |
因为 contactHasNoPros 发送 YES 的时候,表达的含义是联系人所有的字段都没有值,没有值的时候,『保存』按钮应该是不可用状态,所以这里用 not 操作先做一个 Bool 值的取反,然后再设置 button 的 enabled 状态。
]]>这其实应该是最常见的使用场景,有一类业务,是可以抽象成一组按顺序执行的串行任务的,比如下面这段代码
1 | /* |
这段代码做的事情并不复杂,就是传入一个图片的 url 地址,然后下载对应的图片,然后尝试对这张图片进行二维码解码:
这个 Pipeline 的订阅者的代码会是下面这种样子:
1 | -(void)jsCallImageClick:(NSString *)imageUrl imageClickName:(NSString *)imgClickName { |
因为 decodeBarWithURLString 的内部在使用 timeout 的时候,已经通过 RACScheduler 切换到了后台线程,所以在订阅者(UI)这里还要切换回 [RACScheduler mainThreadScheduler]。
下面也是一个真实业务场景中的代码片段,有适当的删减,需求大致可以描述为:FMContact.contactItems 数组里包含的是一个联系人的所有的 email 地址(至少有一个),在用 FMContactCreateAvatarCell 显示这个联系人的头像的时候,要通过其中的一个 email 地址,构造出一个 url 地址,然后下载对应的头像,最后把头像 image 设置到 UIButton 上。
1 | //1 |
这个业务需求看上去也没有太大的难度,大家肯定都可以用传统的代码写出来,但是如果用 FRP,则可以用声明式(declarative)的代码把逻辑写的更清晰:
FRP 是一门学习曲线比较陡峭的技术,回想自己以前的学习过程,也是反反复复好几次,而且总是挫败感很强。不过还好坚持了下来,现在也算是用着比较顺手了。
关于 FRP, 最容易被吐槽的地方就是没有好的学习资料和文档。一开始我也是这种感觉,后来在反复尝试的过程中,发现其实真的不是文档的问题。先说我的结论 —- 不要指望脱离代码能够把 FRP 的原理讲清楚,这是 FRP 和其他编程技术的一个明显差异,这就类似于很难用一段文字把一个数学公式描述清楚一样。而且,即便是开始看用 FRP 编写的各种代码了,还是会觉得太抽象了,仍然需要大量的时间体会代码,或者说,『悟』出其中的一些基本门道。
关于入门学习,没有捷径,最好的办法就是通过代码来学习,下面是我觉得比较好的一些入门学习资料
之所以说 FRP 的学习曲线很陡峭,不仅仅是指它的入门学习比较耗时费脑,当入了门或者稍微找到一些感觉之后,紧接着就会面对第二个问题:FRP 里面提供的都是一些比较抽象的函数操作,怎样才能用这些基本函数来解决各种各样的业务问题?尤其是那些很抽象的操作,怎样才能用起来?
这个系列的文章,主要就是针对后面这第二个问题,做的一些 demo 演示。
可以把 FRP 看成是一种更高级的 Pipeline 编程范式,Pipeline 的一个精髓,就是可以灵活的组合,虽然 FRP 里常用的操作也就那么几十个,但是一旦像搭积木那样对它们进行了组装之后,FRP 的强大之处一下子就展现了出来。
FRP 通常是以库或框架的形式提供给使用者,目前已经有很多常见编程语言的具体实现。在这个系列文章中,将使用 RAC 2 (ReactiveCocoa 的 Objective-C 版本) 进行编写。但是 FRP 本质上是一种编程范式,从 Pipeline 的角度来看,它的侧重点在于如何组装出不同形状的 Pipeline,而不太在乎 Pipeline 的具体构成材料(编程语言),从框架的角度来看,虽然有不同语言版本的实现,但是每个版本里,提供的诸如 map、flattenMap、reduce 等基础操作,在概念上和行为模式上,又都是一样的。所以,FRP 也是一门 “Learn once, write anywhere” 的技术。
FRP 有几个明显的好处,比如可以减少中间状态变量的使用,可以编写紧凑的代码,可以用同步风格编写异步运行的代码,在本系列文章中,也会尽量体现出这些特点。
这个业务其实是非常简单的,就是在某个 UIViewController 里面,当检测到键盘弹出的时候,为了避免键盘遮挡住某个 UIView,需要根据键盘的高度重新对 view 进行 layout,用 RAC 写出来的代码是下面这个样子:
1 | //1 |
用数字标注的地方,是比较关键的点:
这样描述还是很抽象,看不懂,是吧?没关系,早就说过用语言很难描述了。把代码运行起来,通过 NSLog(@"Keyboard size is: %@", value) 这句代码的输出信息体会一下 merge 的实际效果。
在学习的过程中,发现有一个问题很容易被忽略掉,那就是 Signal 的 next、complete、error 这 3 种数据,会在什么时候被发送出来,针对这个问题做过一个总结,放在了 这篇文档 中,主要目的是使用一种简单易懂的格式把 Signal 的关键信息描述出来,这里简单摘录一下。
1 | HotSignal<T, E> // or ColdSignal<T, E> |
HotSignal
: Signal 已经处于活动状态(activated);ColdSignal
: Signal 需要订阅(subscribed)才会活动(activate);T
: 表示只会发送 1 次 next 事件, 内容是类型 T
的实例;T?
: 表示只会发送 1 次 next 事件, 内容是类型 T
的实例或者 nil
;[T]
: 表示会发送 0 到 n 次 next 事件, 内容是类型 T
的实例;[T?]
: 表示会发送 0 到 n 次 next 事件, 内容是类型 T
的实例或者 nil
;None
: 表示不会发送 next 事件;NSError
或 NoError
; NoError
表示 Signal 不会 sendError;无穷多次
,相当于使用者永远也接收不到 Completed 事件,所以这一行可以不写;main
specified
current
, 默认是 current
YES
NO
, 默认是 NO
1 | * HotSignal<T, NoError> |
如上图,这里的需求是,点击右上角的按钮后,该按钮不可以使用,同时在按钮上显示一个倒计时时间,当达到倒计时时间后,按钮恢复可用状态。这个需求并不难,相信大家都可以写出来,但是,每个人写出来的代码,风格肯定千差万别,而且,免不了会需要一些状态变量来记录一些信息,比如定时器对象和倒计时的时间等等。如果换用 RAC,则可以在一段连续的代码中,满足所有的需求,代码如下:
1 | //1 |
对关键代码的描述如下:
sends completed when both the receiver and the last sent signal complete
。从前面的 code 中可以看到,好几个地方都在强调要触发 Completed,这完全就是为了正确的进行内存管理,避免内存泄露,避免手动的调用 disposal。takeUntil:self.rac_willDeallocSignal 是一种常用的手段。
还有一种典型的场景,也可以通过 takeUntil 操作来触发 Completed,代码如下:
1 | - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { |
这段代码也很简单,唯一需要特别注意的就是 takeUntil:[cell rac_prepareForReuseSignal] 这一句,因为 UICollectionViewCell 本身是有一套复用机制的,每个 cell 上的 Pipeline 的生命期和 cell 本身的生命期并不一致,所以不能依赖于 cell.rac_willDeallocSignal,而应该使用 [cell rac_prepareForReuseSignal] 这个更准确的 Signal。
讨论到这里,还可以得到一个结论,在设计 Signal 的时候,要尽量的让这个 Signal 能够发送 Completed 事件,这样才能够充分的利用 Pipeline 的自动释放功能,保持代码的简洁。RAC 框架里,有一些很常用的 Signal,其实它们的内部实现也是用类似 takeUntil 的操作做了这种处理,比如下面这些 Signal:1
2
3
4
5
6
7
8
9@interface UIControl (RACSignalSupport)
- (RACSignal *)rac_signalForControlEvents:(UIControlEvents)controlEvents;
@end
@interface UIGestureRecognizer (RACSignalSupport)
- (RACSignal *)rac_gestureSignal;
@end
RACObserve 宏定义
下面这个 Signal,则是没有 Completed 事件的,要求它的使用者来决定什么时候释放对应的 Pipeline:1
2
3@interface NSNotificationCenter (RACSupport)
- (RACSignal *)rac_addObserverForName:(NSString *)notificationName object:(id)object;
@end
这篇文章的主要内容,是从 Go Concurrency Patterns 翻译过来的。
原文是介绍 Golang 里面的 CSP 并发模型(Communicating Sequential Processes),这里则是使用一个基于 Swift3.0 的库 Venice 编写的代码。
这篇文章的主要目的,并不是鼓励大家立刻就用 Swift 进行后端开发(至少不是目前这个阶段),但是,对于想尝试全栈开发的 iOS 工程师来说,则可以通过这篇文章入门学习 CSP 这种并发编程模型。
2016/04/08 update: 为了成功运行本文中的代码,需要安装 https://swift.org/builds/development/xcode/swift-DEVELOPMENT-SNAPSHOT-2016-03-24-a/swift-DEVELOPMENT-SNAPSHOT-2016-03-24-a-osx.pkg 这个版本的 swift。
2016/04/13 update: Venice 里面的 Channel 不再支持基于自定义运算符的读写操作,只能使用 func api。
观察一下我们周围,能发现什么?
我们的世界里发生的事情,总是一步一步按顺序执行的吗?
或者说,发生在我们身边的所有的事件,是一个很复杂的组合体,里面充满了更独立、更小型的事件单元,这些单元之间,则是有各种各样的交互和组织关系。
其实就像后者描述的这样,顺序处理 (Sequential processing) 并不是完美的建模思路。
并发是独立的计算任务的组合。
并发是一种软件的设计模式,用并发的思维模式,可以编写出更清晰的代码。
并发不是并行,但是可以在并行的基础上形成并发。
如果只有一个单核处理器(单线程模式),则谈不上并行,但是仍然可以写出并发的代码。
另一方面,如果一段代码已经按照并发的思路进行了设计,那它也是可以很容易的在多核处理器(多线程模式)中并行执行。
关于这个话题,更详细的讨论可以参看 Concurrency is not Parallelism
CSP 并不是新技术,Communicating Sequential Processes 是 Tony Hoare 在 1978 年就提出来的概念,甚至在更早的 1975 年,Edsger Dijkstra 的 Guarded Command Language 里面,也能看到 CSP 的影子。
还有其他的一些语言,也有类似的并发模型
Venice / Golang 通过 channels 来实现 CSP。
Erlang 是最接近于原始的 CSP 定义的,通过 name 进行通信,而非 channel。
它们的模型其实是一致的,只不过具体的表现形式有差异。
粗略来看相当于:writing to a file by name (process, Erlang) vs. writing to a file descriptor (channel, Venice / Golang).
这篇文章最主要的目的是讨论并发模式,为了避免陷入编程语言本身的各种细节,我们只会使用到 Swift 很少的语法特性。
1 | import Foundation |
很容易想象到,这段代码的执行结果会是下买这个样子
1 | this is a boring func 0 |
增加一点随机的延时,让 message 出现的时机不可预测 (延迟时间仍然控制在1秒之内)。并且让 boring 函数一直循环运行。
1 | import Foundation |
Venice 的 co 函数,传入的参数是一个函数,在 co 的内部会执行这个传入的函数,但是并不会等待这个函数执行结束,对于 co 的调用者来说,co 函数本身会立刻返回。co 函数其实是开启了一个新的协程 (轻量级线程) 来真正的执行传入的函数。
1 | import Foundation |
上面这段代码的运行结果如下
1 | co a less boring func 0 |
可以看到,boring函数里面的循环只执行了一次,这是因为 co 函数是立刻返回的,紧接着,run03() 执行完 print 后也立刻返回,然后 run03() 的调用者 main 函数也就执行结束了 (进程结束),之前 co 启动的协程自然也就无法继续执行了。
如果想让 co 里面的协程一直运行下去,可以在 co 调用返回后,执行代码中的那段 for loop。
要注意的一点是,for loop 里面调用的 yield,是 Venice 引入的一种操作,意思是让出 CPU 给其他的协程。Golang 是不需要手动进行这种调用的,runtime 会自动的进行调度。
在 Venice 里面,如果是在 channel 上进行读写操作,读写的同时已经相当于调用过 yield 了,所以也不需要使用者再次显式的调用 yield。在后面的例子的,就会看到这种不需要手动调用 yield 的场景。
调整代码成下面这个样子,在 co 调用后,让 run04() 所在的协程 sleep 一小段时间。
1 | import Foundation |
这段代码的执行结果是下面这个样子的
1 | co a less boring func 0 |
nap()是Venice提供的sleep函数,它的内部,相当于调用了yield。
当main函数结束的时候,boring函数所在的协程也会结束。
协程是一段独立运行的代码集合,通过 co 函数来启动。
协程的系统开销是很小的 (比 thread 小很多),可以同时存在大量的协程 (具体到 Venice 底层使用的 libmill,可以同时运行 2000万个 协程,并且每秒可以进行 5000万次 协程上下文切换)。
协程不是线程。
一个程序里面,可以只运行一个线程,但是在这个线程里面,可以包含千万个协程。
可以把协程看成是轻量级的线程。
在 run04() 里面,是不能看到在协程中运行的 boring 函数的运行结果的。
boring 函数仅仅是把 msg 打印到了终端上。
想在协程之间真正的传递数据,需要用到通讯 (communication)。
在 Venice 里面,两个协程之间,通过 Channel 进行通讯。
Channel 的基本操作就是下面这3个:
1 | //声明、初始化 |
用 channel 连接 boring 函数和 run05 函数
1 | import Foundation |
运行结果如下
1 | You say: co a less boring func 0 |
在 channel 上的读、写操作,是同步的、阻塞的。
run05() 执行到 channel.receivingChannel.receive()! 的时候,只有当 channel 里面有数据被写入的时候,这个读操作才会返回 (读到数据的时候才返回),否则 run05() 就会一直在这里等待,不会继续往下执行。
同样的,在 boring 函数里面,执行 channel.send(“(msg) (i)”) 这个写操作的时候,只有当 channel 里面为空的时候,数据才能被写到 channel 里面,channel.send(“(msg) (i)”) 才会返回,否则,send 操作也会阻塞在这里。
在通讯过程中,发送者和接收者,必须都分别完成他们的写和读动作,否则双方就会一直互相等待下去 (死锁)。
channel 在协程之间完成通讯的同时,也达到了同步的目的。
可以创建具有 buffer 的 channel。
这种 channel,当 buffer 还没有写满的时候,是没有前面描述的那种同步特性的。
buffering 有点类似 Erlang 语言里面的 mailboxes。
没有特殊理由的时候,不应该使用 buffered channel。
这篇文章后续的讨论,都不会使用 buffer。
Don’t communicate by sharing memory, share memory by communicating.
Channel 是一等公民,和 class、struct、closure 同等重要。
1 | import Foundation |
这段代码和前面的代码的运行结果,没有什么差别
1 | You say: co a less boring func 0 |
但是代码本身确有明显的变化,boring 函数返回一个 channel 给调用者,同时,在 boring 函数内部,通过 co 启动一个新的协程做具体的业务,并且通过刚才创建的 channel 把结果发送出去。
boring 函数对外提供了一个 service,这个 service 运行在独立的协程里面,并且通过channel 把数据传递给 service 的使用者。
可以同时使用多个 service。
1 | import Foundation |
运行结果如下
1 | Joe 0 (will sleep 996 ms) |
前面 run07() 里面的代码,始终都是先从 joe 里面读取数据,然后再从 ann 里面读取。如果 ann 里面的数据早于 joe 里面的数据就发送了,由于 channel 的同步特性,ann channel 其实会阻塞在它的 send 操作上,直到 run07 从 joe 里面读取完数据后,ann 所在的协程才能继续运行。
为了改善这种情况,可以使用 fan-in 模式。不管是 joe 还是 ann,只要有数据准备好并且执行了 send 操作,都可以立刻读取到。
1 | import Foundation |
运行结果如下
1 | Joe 0 (will sleep 75 ms) |
前面 run08 里面的 fan-in 模式,boring 函数只负责 send 消息,并不需要消息的接收者做一个答复。如果需要,可以像下面这样修改代码
1 | import Foundation |
运行结果会是下面这个样子,并没有明显的区别
1 | Joe 0 (will sleep 551 ms) |
前面介绍的多路复用技术,是通过启动多个协程实现的,每个 channel 对应一个协程。
另一种更常用的办法,是使用 select 操作,在一个协程里面同时读写多个 channel。
可以用 select 操作重新实现一遍 fan-in 模式
1 | import Foundation |
运行结果和之前的 fan-in 没有区别
1 | Ann 0 (will sleep 816 ms) |
这里用的 select 操作,和 Linux / Unix 里面的 select、poll、epoll,都是类似的,只不过前者监听的是 channel,后者监听的是 fd
定时器是基于 channel 实现出来的,当达到定时时间的时候,定时器 channel 上会发送一个消息。
定时器可以放在 select 操作的里面
1 | import Foundation |
运行结果是下面这个样子
1 | Joe 0 (will sleep 48 ms) |
前面的 run11,是在每次进入 select 的时候,设置了一个超时 channel。
也可以在 while 循环的外面,设置一个整体的超时 channel,像下面这样
1 | import Foundation |
运行结果如下
1 | Joe 0 (will sleep 586 ms) |
boring 函数的调用者,可以主动的让 boring 内部的协程停止工作,也是通过 channel 来实现。
1 | import Foundation |
运行结果仍然是类似的
1 | Joe, and will sleep 154 ms |
接着上面的例子,当 run13 向 quit channel 发送 true 的时候,run13 怎样才能知道 boring 函数成功的结束了自己的运行呢?让 boring 告诉它的调用者就行,同样,还是通过 quit channel。
1 | import Foundation |
现在运行结果会变成下面这个样子
1 | Joe, and will sleep 220 ms |
1 | import Foundation |
运行结果如下
1 | Joe says: 10001 |
让我们具体看一下 CSP 这种并发模型,是如何用在系统软件的开发中的。
问: Google search 需要做什么事情?
答: 输入一个搜索关键字 (query),得到一组搜索结果 (和一些广告)。
问: 怎样获取这样的一组搜索结果?
答: 把搜索关键字分别发送给 Web search service,Image search service,YouTube search service,Maps search service,News search service 等等,然后把它们返回的结果再组合到一起。
那么,怎样做呢?
模拟 3 个 search service,每次执行 search 的时候,随机延时一小段时间。
1 | import Foundation |
google 函数有一个输入参数,返回一个数组。
google 内部按照顺序依次调用 web、image、video search service,然后把它们的结果组装在一个数组内。
1 | import Foundation |
运行结果是下面这个样子
1 | google search v1.0, use time: 1237 ms. |
并发调用 web、image、video search service,然后等待它们的返回结果。
不使用锁机制,不使用条件状态变量,不使用 callback。
1 | import Foundation |
运行结果如下
1 | google search v2.0, use time: 871 ms. |
很明显,并发执行的效果比顺序执行的效果好很多。
还可以加上超时机制,如果某个 search service 执行的时间太长,就不等待它的返回结果。
不使用锁机制,不使用条件状态变量,不使用 callback。
1 | import Foundation |
如果看到下面这种形式的运行结果,则说明是触发了超时的条件
1 | timeout. |
问:怎样才能避免丢弃响应速度更慢的服务器返回的搜索结果?
答:使用 Replicate 策略。同时向多个同类型的 search service 发送请求,使用第一个返回来的查询结果。
1 | private func first(query query: String, replicas: ((String) -> GoogleSearchResult)...) -> GoogleSearchResult { |
仍然不使用锁机制,不使用条件状态变量,不使用 callback。
1 | import Foundation |
最终的运行结果如下
1 | google search v3.0, use time: 506 ms. |
coroutine 和 channel 是一种很好的设计思想,可以解决某些类型的问题。
但是,有时我们仍然会面对一些需要用传统思路来解决的小问题,也就是基于锁机制 (共享内存)。
这两种不同的技术思路,并不冲突,它们是可以共存的。
正确的工具做正确的事情。
这篇文章里面的 demo code 位于 https://github.com/fengjian0106/CSP-tutorial.git
]]>前三个方案,都是用传统的算法思路,基于编辑距离来实现模糊匹配,但是在手机上无法满足输入法的性能需求,尤其是查询速度这一点,而且也无法做到和Minuum或Fleksy类似的纠错效果。最终的第4个方案,则是彻底更换了思路,直接用机器学习中的 kNN 算法,把字符串映射到更抽象的几何空间中,也就是所谓的特征向量,进行纯粹的数学计算。学习和研究的过程中,是直接用Python做的代码原型验证,放到github上了,有兴趣的朋友可以看看 https://github.com/fengjian0106/Minuum-Fleksy-Fuzzy-Matching
]]>iBeacon
实现室内定位,利用一个小巧的超声波硬件设备,周期性的广播信标信号,手机直接使用麦克风接收这个音频信号并且解码,得到信标信号中的有效数据,最后再根据这个数据进行室内定位的算法逻辑处理。声波通信部分,是技术基础,开发难度比较大,在当时的情况下,对于产品来说,是一个技术壁垒。现在已经过去两年时间了,而且其实出于商业层面的原因,团队也早已放弃这个产品转战其他方向了,所以我还是准备把其中的一些技术细节记录下来。前两点没有太多可说的,对应的开发文档中有很详尽的描述,只不过稍微偏底层一些,只要静下心来老老实实的啃啃文档,还是可以搞定的。第3点中用到的技巧,可能不太常见,我会详细解释一下。
基于标准的2FSK,假如约定用18kHz的音频信号表示二进制的0,用19kHz的音频信号表示二进制的1,同时约定每一个bit持续的发送时间为50ms,假设要发送一个8bit的二进制数据0b11001010(忽略同步和校验部分的bit),对于发送端来说,代码逻辑其实比较简单,只需要让特定频率的引号信号发送特定的时间就行了。但是对于接收端来说,代码就很困难了,虽然用的是2FSK,但是并没有专用的硬件来完成调制解调过程,所以要完全用代码来模拟整个过程,这个里面就涉及到了傅里叶变换、滤波等大量的数字信号处理里面的内容,这些处理完后,才会真正的进入到通信协议栈里面处理二进制的0和1。
如果按照标准的2FSK方式,接收端的代码必须用定时器记录0或1(18kHz或19kHz)持续的时间,然后用这个时间值和50ms做比较,才能判断出这一部分音频片段对应了多少个连续的0或1。而且这仅仅是理论上对解码算法的描述,实际情况中,发送端维持的每个bit位的持续时间是50ms,进入空气中后,会和其他的各种各样的音频信号混杂在一起,然后才进入接收端进行变换和滤波等操作,这个时候,是很难保证每个bit位仍然能够维持在50ms的(即便有50ms,代码仍然会很难编写),正式因为这些原因,成功解码数据的概率并不高。为了改善这种情况,对2FSK做了一些调整,这里借鉴了数字电路里面的一些概念和技术。在数电的串行接口电路中,使用高低电平来表示二进制的1和0,根据传输比特率的约定,每个电平会持续特定的时间,这类似于我们的音频系统中约定的每个bit持续发送50ms,这通常称为电平检测(根据电平值持续的时间进行检测),还有另外一种称为边缘检测的技术,它不依赖于每个电平值持续的时间,而是依赖于电平值的变化事件,比如电平从高变为低(从1变为0)。这里就是使用了边缘检测这种方式来处理音频信号,接收端需要关注的,是音频信号频率值的变化,而不是每个频率值持续的时间。为了实现这种方式,还需要对之前的约定做一些调整,调整为18kHz和19kHz的音频信号都可以表示二进制的0,20kHz和21kHz的音频信号都可以表示二进制的1,如果是为了表示两个连续的0,那么就应该是18kHz的音频信号持续50ms,然后变成19kHz的音频信号持续50ms(或者先发送19kHz的,再发送18kHz的),对于连续的1,也采用类似的策略。举个例子,对于二进制数据0b11101000,转换成频率值后,可能就会是这样的一组值 [20kHz,21kHz,20kHz,18kHz,21kHz,18kHz,19kHz,18kHz],因为每一个bit对应的频率值都会发生变化,那么接收端就可以忽略每个bit持续的时间,只需要检测出每一次频率值发生变化就行了,每一次变化后得到的数值,就可以对应到当前的bit位的二进制值。用了这种调制解调的思路后,接收端的代码,写起来就很容易了:]
]]>在做一个音乐播放器app的过程中,有一个需求是可以边播放边缓存,并且不能浪费流量(不能播放时有一次http下载,离线缓存时又有另一次http下载)。
查了一大堆文档,做了很多原型代码,最终还是发现 AVQueuePlayer
并不是基于 URL Loading System 实现的,所以不可能直接使用其中的cache系统,只能想其他的办法,比如:
ffmpeg
第三方多媒体框架来实现。 AVQueuePlayer
进行http流媒体播放,但是在app内运行一个支持cache的 http proxy
,让 AVQueuePlayer
通过这个 proxy 请求多媒体文件。第一种方案是比较容易想到的,但是其实实现的难度比较大,三个模块都要使用偏底层的接口,学习成本很高。第二种方案其实也是学习成本很高,而且只能使用 ffmpeg
的软解码器,功耗也会很大。前两个方案还有一个共同的问题,就是需要自己考虑如何和iOS系统做整合,比如如何优雅的实现后台播放功能等等。综合考虑后,还是选择了第三种方案,虽然也有一定的学习成本,而且需要修改标准的 http proxy
协议,但是大部分模块都是用iOS的SDK实现的,和操作系统贴合的最紧密。
自己完整的实现一个 http proxy
,也是一件很复杂的事情,所以还是偏向于找开源方案,对比了几个开源方案后,最终选择在 Polipo 的基础上进行修改。
有一个技术细节需要说明一下,当使用浏览器、不使用代理的时候,通过抓包可以看到浏览器发出的http请求如下(忽略了无关内容):
1 | GET / HTTP/1.1 |
当使用浏览器并且使用代理的时候,通过抓包可以看到浏览器发出的http请求如下(忽略了无关内容):
1 | GET http://www.example.com/ HTTP/1.1 |
最重要的一个区别就是 GET
请求后面的路径信息,前者是相对路径 /
,而后者是绝对路径 http://www.example.com/
。因为浏览器本身支持设置代理,所以浏览器会拼接合适的路径信息并且发送。但是iOS的 AVQueuePlayer
并不支持 http proxy
功能,无法和标准的代理服务器协同工作,所以只能同时在 AVQueuePlayer
和 Polipo 上做一些小的修改。
使用 AVQueuePlayer
的时候,需要做一些 magic trick
,关键代码如下:
1 | httpProxyUrl = [NSURL URLWithString:[NSString stringWithFormat:@"http://127.0.0.1:%d/http://%@/music_new/%@", httpProxyPort, kKYMediaServiceManagerRemoteServerIpAndPort, mediaInfo.fileName]]; |
可以这样来理解这段代码,假设app内的 http proxy
的地址为 127.0.0.1:9258
,实际的多媒体文件的地址为 http://media.test.com/xxx.mp3
,那么 AVQueuePlayer
请求的最终地址就应该是:
1 | http://127.0.0.1:9258/http://media.test.com/xxx.mp3 |
在 Polipo 中,解析得到的最终目的服务器的地址(相对路径)是 /http://media.test.com/xxx.mp3
,需要稍微修改一下源代码,去掉最左侧的 /
字符。具体就是在 client.c
文件的 httpClientHandlerHeaders
函数中添加一小段代码,也就是 #ifdef POLIPO_KUYQI_VERSION
和 #endif
之间的那一段:
1 | int |
node.js
感兴趣,所以也学习过服务器开发的一些皮毛。前段时间公司的一个产品,要覆盖iOS、Android和微信web这3个前端,微信web页面的第一个版本,是服务器端同事用最传统的web技术做出来的,也就是使用后端模板渲染的技术组装页面,前端js则使用了 jQuery
做简单的操作。东西做出来后,体验特别的不好,尤其是每次页面跳转都要加载一个新页面,会有延迟。后来狠下决心重做一遍,完全采用前端渲染的技术。当时本来就缺人手,好歹我也算是会用JavaScript,义不容辞的自然也就把这个项目接手过来了。做的过程很累,特别的赶进度,而且是摸着石头过河,未知的因素很多。还好iOS做的很熟悉了,很多经验或问题,可以直接照搬到web端,借助Google和Stack Overflow,记录了一大堆笔记出来。前两天另一个团队启动新项目,微信web端也是要做的,同事便找到我,想要一些经验分享,琢磨了一下,笔记写的比较凌乱,毕竟主要是给自己看的,符合自己的思维模式习惯,但是并不适合给别人看,干脆整理一份出来,方便别人查看,对自己也是再次清理一下思路。
这个话题没有唯一答案,而且特别的松散,风格很多。
目前团队里使用 http://webuild.envato.com/blog/how-to-scale-and-maintain-legacy-css-with-sass-and-smacss/ 这种方案,但是并不完全遵照它的代码模板,比如我们不使用 Sass
语法,而是用 SCSS
语法。
另外还推荐阅读 http://blog.jobbole.com/47702/ 这个里面提到了几乎所有的技术、框架、思路,比如 Sass
、BEM
、OOCSS
、SMACSS
、OrganicCSS
等等。
Sass
编写CSS这个没啥多说的,语法并不难。引入了编程语言中的一些思路和技巧,对于iOS和Android开发者来说,很容易上手。
参照 http://markdotto.com/2011/11/29/css-property-order/ 以及 http://codegeekz.com/standardizing-css-property-order/
CSS Box Model
中的 width
和 height
我们使用 CSS3
中的 border-box
模型,这个就和iOS中的 UIView
的尺寸模型保持了一致,也更直观,容易理解。
Flexbox
做 Layout项目的前期,为了实现一些手机上常见的布局,我们大量使用了CSS中的 float
、table-cell
等等,但是代码会比较复杂,而且可读性不好。直到发现了神器 Flexbox
。
另外,还有一个基于 Sass
的工具 https://github.com/mastastealth/sass-flex-mixin.git,帮助我们更好的进行编码。
Yeoman
实现工作流不懂得使用工具的web开发者,不是好前端,嘿嘿 ^_^
MV*
框架 Or jQuery
类型的库AngularJS
的 Ionic 框架。Ionic
是对手机适配的最好的框架(没有之一)。但是 Ionic
的体积比较大,官方宣传时定义其为 framework for developing hybrid mobile apps。如果是对网速不敏感的使用场景,或者网速很快的场景,其实 Ionic
是可以做 web app
的。jQuery UI
的库,但是是基于 zepto
的,很轻量级,而且也提供了不少的 widget
。但是为了轻量,并没有套用 MV*
模式,所以应用场景复杂的时候,代码通常会组织的比较凌乱。交互界面复杂的时候,还会暴露出各种各样的坑,比如click事件穿透,就让我们大吃苦头。gmu
了,如果真正只需要开发轻量级的页面,我宁愿直接用 Sass
+ zepto
或 http://minifiedjs.com/ 来实现。MVC
框架,在体积大小和功能上有合理的舍取,但是框架本身只注重设计模式的引入,并不包含一套完整的针对mobile的 widget
,所以我们还整理了另外一种思路,就是基于 Backbone
,再加上各种各样小的lib,根据需求组合起来使用。这种方案可能存在的问题就是这些lib各自为政,不像 AngularJS
这种框架一样都在一个体系内协同工作,所以开发的时候也许会有很多坑,得做一遍才会有深刻的体会。有两个例子,非常值得学习参考,https://github.com/ccoenraets/directory-backbone-ratchet 和 http://n12v.com/2-way-data-binding/。widget
按照iOS平台上的开发经验,针对mobile,常见的 widget
包括这些(有官方SDK自带的,也有大量第三方开源的,iOS平台现在很完善,有很多 widget
可以拿来即用)
widget
widget
,比如iOS自带的 UIAlertView
和 UIActionSheet
UINavigationController
和 UITabBarController
,以及第三方开源的 https://github.com/ECSlidingViewController/ECSlidingViewController.gitmobile web平台上,widget
的生态环境并不好。相对而言,Ionic 自带的widget
是最完善的,而且有框架的支持,也更容易实现自定义的 widget
。唯一的问题就是 Ionic
框架比较大。
Backbone
或 gmu
中,除了最常见的 widget
外,其他的通常都只能自己实现,比如在使用 gmu
的时候,我们就只能自己编写ActionSheet
、全屏HUD、免干扰式的下拉信息提示框、以及系统级的页面导航控制器。由于缺少框架级的支持,除了 ActionSheet
外,其他几个 widget
的代码实现都很粗暴,而且遇到了各种各样的bug。
实际项目中,最好从一开始做交互设计的时候,就考虑 widget
的问题,尽量使用最常见的 widget
,舍弃一些复杂的交互方式。
Ternary Search Tree
。一年后,也就是最近,随着iOS8的发布,我们也要发布一款iOS版的输入法。Ternary Search Tree
实现 prefix match
的速度很快,但是因为只是一个纯粹的内存数据结构,所以输入法词库的容量是一个瓶颈。在android平台上,考虑多方面因素后,我们的词库中只有3万左右的单词量。iOS平台上做原型验证的时候,词库容量也只能做到10万左右。但是实际的业务需求中,是希望词库容量可以进一步增大的。词库容量扩充这个问题,其实一直是一个难题,在 Ternary Search Tree
上也做过一些优化,但是变化并不明显。反而是在做server端开发,学习 LevelDB
的时候,碰巧发现 LevelDB
是一个很好的替代。首先 LevelDB
支持 prefix search
,而且搜索速度也很快,测试数据表明完全满足我们的业务需求,其次 LevelDB
是将数据存储在文件系统上的,没有了内存大小的限制,词库的容量很轻松就可以扩充100倍以上,而且有了这种近乎无限的词库容量后,之前有一些需要复杂算法甚至很难实现的业务需求,现在也可以在超大词库的基础上,用“简单但是粗暴”的算法实现出来。
iOS平台上的 Core Data
是一套相当好用的数据持久化存储框架,唯一可能存在
的问题就是性能,因此有些开发者在某些场景中,还是愿意去选择使用 SQLite
。有了这次的开发经验后,相信在某些应用场景中, LevelDB
也将会是一个很好的替代方案,比如 Square 开源的 Viewfinder 中的客户端,就是用 LevelDB
实现的数据存储。LevelDB
的核心是 LSM-Tree
,其实 SQLite4 的核心,也是 LSM-Tree
,小伙伴们,你们知道吗 :-)
说实话,页面加载速度这个问题,挺出乎意料的,以前我们团队也做了这么多iOS应用了,从来没有在页面速度上遇到过问题,用 Instruments
、 NSLog
对比分析了一遍,测量出来的页面加载时间,也和其他应用中页面加载消耗的时间差不多。大家讨论了一下为什么用户会觉得慢,得出的结论是,输入法本来就是一个效率型的工具app,用户心理的期待之一,就是键盘的速度要快,而普通类型的app,用户对速度不会这么敏感。
问题已经出来了,还是得想办法去优化,吭哧吭哧写代码调试,从3个方面压缩了页面加载切换时消耗的时间:
Auto Layout
约束条件,直接在 layoutSubviews
方法中设置subview的 frame
,关于这个优化思路,可以看看 Optimising Autolayout。需要强调的是,我们并不是否定 Auto Layout
,实际上我们团队现在采用的思路是 Auto Layout
和 Manual Frame Layout
一起使用,代码布局和xib布局一起使用,根据页面的需求做出更合适的选择。这款输入法app,我们还全面切换到使用 ReactiveCocoa
这个框架进行开发,当时也怀疑过是不是因为这个框架造成了性能的损失,从 Instruments
的测量数据来看,我们的顾虑是多余的, ReactiveCocoa
虽然使得整个函数调用栈的层次增加了不少,但是,这不是性能瓶颈。