注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Core Image 和视频

在这篇文章中,我们将研究如何将 Core Image 应用到实时视频上去。我们会看两个例子:首先,我们把这个效果加到相机拍摄的影片上去。之后,我们会将这个影响作用于拍摄好的视频文件。它也可以做到离线渲染,它会把渲染结果返回给视频,而不是直接显示在屏幕上。总览当...
继续阅读 »

在这篇文章中,我们将研究如何将 Core Image 应用到实时视频上去。我们会看两个例子:首先,我们把这个效果加到相机拍摄的影片上去。之后,我们会将这个影响作用于拍摄好的视频文件。它也可以做到离线渲染,它会把渲染结果返回给视频,而不是直接显示在屏幕上。

总览

当涉及到处理视频的时候,性能就会变得非常重要。而且了解黑箱下的原理 —— 也就是 Core Image 是如何工作的 —— 也很重要,这样我们才能达到足够的性能。在 GPU 上面做尽可能多的工作,并且最大限度的减少 GPU 和 CPU 之间的数据传送是非常重要的。之后的例子中,我们将看看这个细节。

优化资源的 OpenGL ES

CPU 和 GPU 都可以运行 Core Image,在这个例子中,我们要使用 GPU,我们做如下几样事情。

我们首先创建一个自定义的 UIView,它允许我们把 Core Image 的结果直接渲染成 OpenGL。我们可以新建一个 GLKView 并且用一个 EAGLContext 来初始化它。我们需要指定 OpenGL ES 2 作为渲染 API,在这两个例子中,我们要自己触发 drawing 事件 (而不是在 -drawRect: 中触发),所以在初始化 GLKView 的时候,我们将 enableSetNeedsDisplay 设置为 false。之后我们有可用新图像的时候,我们需要主动去调用 -display

在这个视图里,我们保持一个对 CIContext 的引用,它提供一个桥梁来连接我们的 Core Image 对象和 OpenGL 上下文。我们创建一次就可以一直使用它。这个上下文允许 Core Image 在后台做优化,比如缓存和重用纹理之类的资源等。重要的是这个上下文我们一直在重复使用。

上下文中有一个方法,-drawImage:inRect:fromRect:,作用是绘制出来一个 CIImage。如果你想画出来一个完整的图像,最容易的方法是使用图像的 extent。但是请注意,这可能是无限大的,所以一定要事先裁剪或者提供有限大小的矩形。一个警告:因为我们处理的是 Core Image,绘制的目标以像素为单位,而不是点。由于大部分新的 iOS 设备配备 Retina 屏幕,我们在绘制的时候需要考虑这一点。如果我们想填充整个视图,最简单的办法是获取视图边界,并且按照屏幕的 scale 来缩放图片 (Retina 屏幕的 scale 是 2)。


从相机获取像素数据

对于 AVFoundation 如何工作的概述,我们想从镜头获得 raw 格式的数据。我们可以通过创建一个 AVCaptureDeviceInput 对象来选定一个摄像头。使用 AVCaptureSession,我们可以把它连接到一个 AVCaptureVideoDataOutput。这个 data output 对象有一个遵守 AVCaptureVideoDataOutputSampleBufferDelegate 协议的代理对象。这个代理每一帧将接收到一个消息:

func captureOutput(captureOutput: AVCaptureOutput!,
didOutputSampleBuffer: CMSampleBuffer!,
fromConnection: AVCaptureConnection!) {

我们将用它来驱动我们的图像渲染。在我们的示例代码中,我们已经将配置,初始化以及代理对象都打包到了一个叫做 CaptureBufferSource 的简单接口中去。我们可以使用前置或者后置摄像头以及一个回调来初始化它。对于每个样本缓存区,这个回调都会被调用,并且参数是缓冲区和对应摄像头的 transform:

source = CaptureBufferSource(position: AVCaptureDevicePosition.Front) {
(buffer, transform) in
...
}

我们需要对相机返回的数据进行变换。无论你如何转动 iPhone,相机的像素数据的方向总是相同的。在我们的例子中,我们将 UI 锁定在竖直方向,我们希望屏幕上显示的图像符合照相机拍摄时的方向,为此我们需要后置摄像头拍摄出的图片旋转 -π/2。前置摄像头需要旋转 -π/2 并且加一个镜像效果。我们可以用一个 CGAffineTransform 来表达这种变换。请注意如果 UI 是不同的方向 (比如横屏),我们的变换也将是不同的。还要注意,这种变换的代价其实是非常小的,因为它是在 Core Image 渲染管线中完成的。

接着,要把 CMSampleBuffer 转换成 CIImage,我们首先需要将它转换成一个 CVPixelBuffer。我们可以写一个方便的初始化方法来为我们做这件事:

extension CIImage {
convenience init(buffer: CMSampleBuffer) {
self.init(CVPixelBuffer: CMSampleBufferGetImageBuffer(buffer))
}
}

现在我们可以用三个步骤来处理我们的图像。首先,把我们的 CMSampleBuffer 转换成 CIImage,并且应用一个形变,使图像旋转到正确的方向。接下来,我们用一个 CIFilter 滤镜来得到一个新的 CIImage 输出。我们使用了 Florian 的文章 提到的创建滤镜的方式。在这个例子中,我们使用色调调整滤镜,并且传入一个依赖于时间而变化的调整角度。最终,我们使用之前定义的 View,通过 CIContext 来渲染 CIImage。这个流程非常简单,看起来是这样的:

source = CaptureBufferSource(position: AVCaptureDevicePosition.Front) {
[unowned self] (buffer, transform) in
let input = CIImage(buffer: buffer).imageByApplyingTransform(transform)
let filter = hueAdjust(self.angleForCurrentTime)
self.coreImageView?.image = filter(input)
}

当你运行它时,你可能会因为如此低的 CPU 使用率感到吃惊。这其中的奥秘是 GPU 做了几乎所有的工作。尽管我们创建了一个 CIImage,应用了一个滤镜,并输出一个 CIImage,最终输出的结果是一个 promise:直到实际渲染才会去进行计算。一个 CIImage 对象可以是黑箱里的很多东西,它可以是 GPU 算出来的像素数据,也可以是如何创建像素数据的一个说明 (比如使用一个滤镜生成器),或者它也可以是直接从 OpenGL 纹理中创建出来的图像。

下面是演示视频

从影片中获取像素数据

我们可以做的另一件事是通过 Core Image 把这个滤镜加到一个视频中。和实时拍摄不同,我们现在从影片的每一帧中生成像素缓冲区,在这里我们将采用略有不同的方法。对于相机,它会推送每一帧给我们,但是对于已有的影片,我们使用拉取的方式:通过 display link,我们可以向 AVFoundation 请求在某个特定时间的一帧。

display link 对象负责在每帧需要绘制的时候给我们发送消息,这个消息是按照显示器的刷新频率同步进行发送的。这通常用来做 自定义动画,但也可以用来播放和操作视频。我们要做的第一件事就是创建一个 AVPlayer 和一个视频输出:

player = AVPlayer(URL: url)
videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferDict)
player.currentItem.addOutput(videoOutput)

接下来,我们要创建 display link。方法很简单,只要创建一个 CADisplayLink 对象,并将其添加到 run loop。

let displayLink = CADisplayLink(target: self, selector: "displayLinkDidRefresh:")
displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)

现在,唯一剩下的就是在 displayLinkDidRefresh: 调用的时候获取视频每一帧。首先,我们获取当前的时间,并且将它转换成当前播放项目里的时间比。然后我们询问 videoOutput,如果当前时间有一个可用的新的像素缓存区,我们把它复制一下并且调用回调方法:

func displayLinkDidRefresh(link: CADisplayLink) {
let itemTime = videoOutput.itemTimeForHostTime(CACurrentMediaTime())
if videoOutput.hasNewPixelBufferForItemTime(itemTime) {
let pixelBuffer = videoOutput.copyPixelBufferForItemTime(itemTime, itemTimeForDisplay: nil)
consumer(pixelBuffer)
}
}

我们从一个视频输出获得的像素缓冲是一个 CVPixelBuffer,我们可以把它直接转换成 CIImage。正如上面的例子,我们会加上一个滤镜。在这个例子里,我们将组合多个滤镜:我们使用一个万花筒的效果,然后用渐变遮罩把原始图像和过滤图像相结合,这个操作是非常轻量级的。

创意地使用滤镜

大家都知道流行的照片效果。虽然我们可以将这些应用到视频,但 Core Image 还可以做得更多。

Core Image 里所谓的滤镜有不同的类别。其中一些是传统的类型,输入一张图片并且输出一张新的图片。但有些需要两个 (或者更多) 的输入图像并且混合生成一张新的图像。另外甚至有完全不输入图片,而是基于参数的生成图像的滤镜。

通过混合这些不同的类型,我们可以创建意想不到的效果。

混合图片

在这个例子中,我们使用这些东西:

Combining filters

上面的例子可以将图像的一个圆形区域像素化。

它也可以创建交互,我们可以使用触摸事件来改变所产生的圆的位置。

Core Image Filter Reference 按类别列出了所有可用的滤镜。请注意,有一部分只能用在 OS X。

生成器和渐变滤镜可以不需要输入就能生成图像。它们很少自己单独使用,但是作为蒙版的时候会非常强大,就像我们例子中的 CIBlendWithMask 那样。

混合操作和 CIBlendWithAlphaMask 还有 CIBlendWithMask 允许将两个图像合并成一个。

CPU vs. GPU

iOS 和 OS X 的图形栈。需要注意的是 CPU 和 GPU 的概念,以及两者之间数据的移动方式。

在处理实时视频的时候,我们面临着性能的挑战。

首先,我们需要能在每一帧的时间内处理完所有的图像数据。我们的样本中采用 24 帧每秒的视频,这意味着我们有 41 毫秒 (1/24 秒) 的时间来解码,处理以及渲染每一帧中的百万像素。

其次,我们需要能够从 CPU 或者 GPU 上面得到这些数据。我们从视频文件读取的字节数最终会到达 CPU 里。但是这个数据还需要移动到 GPU 上,以便在显示器上可见。

避免转移

一个非常致命的问题是,在渲染管线中,代码可能会把图像数据在 CPU 和 GPU 之间来回移动好几次。确保像素数据仅在一个方向移动是很重要的,应该保证数据只从 CPU 移动到 GPU,如果能让数据完全只在 GPU 上那就更好。

如果我们想渲染 24 fps 的视频,我们有 41 毫秒;如果我们渲染 60 fps 的视频,我们只有 16 毫秒,如果我们不小心从 GPU 下载了一个像素缓冲到 CPU 里,然后再上传回 GPU,对于一张全屏的 iPhone 6 图像来说,我们在每个方向将要移动 3.8 MB 的数据,这将使帧率无法达标。

当我们使用 CVPixelBuffer 时,我们希望这样的流程:

Flow of image data

CVPixelBuffer 是基于 CPU 的 (见下文),我们用 CIImage 来包装它。构建滤镜链不会移动任何数据;它只是建立了一个流程。一旦我们绘制图像,我们使用了基于 EAGL 上下文的 Core Image 上下文,而这个 EAGL 上下文也是 GLKView 进行图像显示所使用的上下文。EAGL 上下文是基于 GPU 的。请注意,我们是如何只穿越 GPU-CPU 边界一次的,这是至关重要的部分。

工作和目标

Core Image 的图形上下文可以通过两种方式创建:使用 EAGLContext 的 GPU 上下文,或者是基于 CPU 的上下文。

这个定义了 Core Image 工作的地方,也就是像素数据将被处理的地方。与工作区域无关,基于 GPU 和基于 CPU 的图形上下文都可以通过执行 createCGImage(…)render(_, toBitmap, …) 和 render(_, toCVPixelBuffer, …),以及相关的命令来向 CPU 进行渲染。

重要的是要理解如何在 CPU 和 GPU 之间移动像素数据,或者是让数据保持在 CPU 或者 GPU 里。将数据移过这个边界是需要很大的代价的。

缓冲区和图像

在我们的例子中,我们使用了几个不同的缓冲区图像。这可能有点混乱。这样做的原因很简单,不同的框架对于这些“图像”有不同的用途。下面有一个快速总览,以显示哪些是以基于 CPU 或者基于 GPU 的:

描述
CIImage它们可以代表两种东西:图像数据或者生成图像数据的流程。
CIFilter 的输出非常轻量。它只是如何被创建的描述,并不包含任何实际的像素数据。
如果输出时图像数据的话,它可能是纯像素的 NSData,一个 CGImage, 一个 CVPixelBuffer,或者是一个 OpenGL 纹理
CVImageBuffer这是 CVPixelBuffer (CPU) 和 CVOpenGLESTexture (GPU) 的抽象父类.
CVPixelBufferCore Video 像素缓冲 (Pixel Buffer) 是基于 CPU 的。
CMSampleBufferCore Media 采样缓冲 (Sample Buffer) 是 CMBlockBuffer 或者 CVImageBuffer 的包装,也包括了元数据。
CMBlockBufferCore Media 区块缓冲 (Block Buffer) 是基于 GPU 的

需要注意的是 CIImage 有很多方便的方法,例如,从 JPEG 数据加载图像或者直接加载一个 UIImage 对象。在后台,这些将会使用一个基于 CGImage 的 CIImage 来进行处理。

结论

Core Image 是操纵实时视频的一大利器。只要你适当的配置下,性能将会是强劲的 —— 只要确保 CPU 和 GPU 之间没有数据的转移。创意地使用滤镜,你可以实现一些非常炫酷的效果,神马简单色调,褐色滤镜都弱爆啦。所有的这些代码都很容易抽象出来,深入了解下不同的对象的作用区域 (GPU 还是 CPU) 可以帮助你提高代码的性能。


原文:http://www.objc.io/issue-23/core-image-video.html

译者:考高这点小事

高考这件小事


收起阅读 »

使用 Swift 进行函数式信号处理

作为一个和 Core Audio 打过很长时间交道的工程师,苹果发布 Swift 让我感到兴奋又疑惑。兴奋是因为 Swift 是一个为性能打造的现代编程语言,但是我又不是非常确定函数式编程是否可以应用到 “我的世界”。幸运的是,很多人已经探索和克服了这些问题,...
继续阅读 »

作为一个和 Core Audio 打过很长时间交道的工程师,苹果发布 Swift 让我感到兴奋又疑惑。兴奋是因为 Swift 是一个为性能打造的现代编程语言,但是我又不是非常确定函数式编程是否可以应用到 “我的世界”。幸运的是,很多人已经探索和克服了这些问题,所以我决定将我从这些项目中学习到的东西应用到 Swift 编程语言中去。


信号

信号处理的基本当然是信号。在 Swift 中,我可以这样定义信号:

public typealias Signal = Int -> SampleType

你可以把 Signal 类想象成一个离散时间函数,这个函数会返回一个时间点上的信号值。在大多数信号处理的教科书中,这个会被写做 x[t], 这样一来它就很符合我的世界观了。

现在我们来定义一个给定频率的正弦波:

public func sineWave(sampleRate: Int, frequency: ParameterType) -> Signal {
let phi = frequency / ParameterType(sampleRate)
return { i in
return SampleType(sin(2.0 * ParameterType(i) * phi * ParameterType(M_PI)))
}
}

sineWave 函数会返回一个 SignalSignal 本身是一个将采样点的索引映射为输出样点的函数。我将这些不需要“输入”的信号称为信号发生器,因为它们不需要任何其他的东西就能创造信号。

但是我们正在讨论信号处理。那么如何更改一个信号呢?

任何关于信号处理的高层面的讨论,都不可能离开一个基础,那就是如何控制增益 (或者音量):

public func scale(s: Signal, amplitude: ParameterType) -> Signal {
return { i in
return SampleType(s(i) * SampleType(amplitude))
}
}

scale 函数接受一个名为 s 的 Signal 作为输入,然后返回一个施加了标量之后的新 Signal。每次调用这个经过 scale 后的信号,返回的值都是对应的 s(i) 然后通过所提供的 amplitude 进行加成,来作为输出。很容易对吧?但是很快这些构件就会变得混乱起来。来看看以下的例子:

public func mix(s1: Signal, s2: Signal) -> Signal {
return { i in
return s1(i) + s2(i)
}
}

这让我们能够将两个信号混合成一个信号。我们甚至可以混合任意多个信号:

public func mix(signals: [Signal]) -> Signal {
return { i in
return signals.reduce(SampleType(0)) { $0 + $1(i) }
}
}

这可以让我们干很多事情;但是一个 Signal 仅仅限于一个单一的音频频道,有些音效需要复杂的操作的组合同时发生才能做到。

处理 Block

我们如何才能以更灵活的方式在信号和处理器之间建立联系,来让信号处理更接近于我们所想呢?有很多流行的环境,比如说 Max 和 PureData,这些环境会建立信号处理的 “blocks”,并以此来创造强大的音效和演奏工具。

Faust 是一个为此设计出来的函数式编程语言,它是一个用来编写高度复杂 (而且高性能) 的信号处理代码的强大工具。Faust 定义了一系列运算符来让你建立 blocks (处理器),这和信号流图像很相似。

类似地,我用同样的方式建立了一个可以高效工作的环境。

使用我们之前定义的 Signal,我们可以基于这个概念进行扩展。

public protocol BlockType {
typealias SignalType
var inputCount: Int { get }
var outputCount: Int { get }
var process: [SignalType] -> [SignalType] { get }

init(inputCount: Int, outputCount: Int, process: [SignalType] -> [SignalType])
}

一个 Block 有多个输入,多个输出,和一个 process 函数,这个函数将信号从输入集合转换成输出集合。Blocks 可以有零个或多个输入,也可以有零个或多个输出。

你可以用以下的方法来建立串行的 blocks。

public func serial<B: BlockType>(lhs: B, rhs: B) -> B {
return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
return rhs.process(lhs.process(inputs))
})
}

这个函数将 lhs block 的输出当做 rhs block 的输入,然后返回结果。就好像在两个 blocks 中间连起一根线一样。当你想要并行地执行多个 blocks 的时候,事情就变得有意思起来:

public func parallel<B: BlockType>(lhs: B, rhs: B) -> B {
let totalInputs = lhs.inputCount + rhs.inputCount
let totalOutputs = lhs.outputCount + rhs.outputCount

return B(inputCount: totalInputs, outputCount: totalOutputs, process: { inputs in
var outputs: [B.SignalType] = []

outputs += lhs.process(Array(inputs[0..<lhs.inputCount]))
outputs += rhs.process(Array(inputs[lhs.inputCount..<lhs.inputCount+rhs.inputCount]))

return outputs
})
}

一组并行运行的 blocks 将输入和输出结合在一起,并创建了一个更大的 block。比如一对产生的正弦波的 Block 组合在一起可以创建一个 DTMF 音调,或者两个单频延迟的 Block 可以组成一个立体延迟 Block等。这个概念在实践中是非常强大的。

那么混合器呢?我们如何从多个输入得到一个单频道的结果?我们可以用如下函数来将多个 block 合并在一起:

public func merge<B: BlockType where B.SignalType == Signal>(lhs: B, rhs: B) -> B {
return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
let leftOutputs = lhs.process(inputs)
var rightInputs: [B.SignalType] = []

let k = lhs.outputCount / rhs.inputCount
for i in 0..<rhs.inputCount {
var inputsToSum: [B.SignalType] = []
for j in 0..<k {
inputsToSum.append(leftOutputs[i+(rhs.inputCount*j)])
}
let summed = inputsToSum.reduce(NullSignal) { mix($0, $1) }
rightInputs.append(summed)
}

return rhs.process(rightInputs)
})
}

从 Faust 借用一个惯例,输入的混合是这样进行的:右手边 block 的输入来自于左手边对输入取模后的输出。举个例子,将六个频道的三个立体声轨变成一个立体输出的 block:输出频道 0,2,4 被混合 (比如相加) 进输入频道 0,然后输出频道 1,3,5 会被混合进输入频道 1。

同样的,你可以用相反的方法将 block 的输出分开。

public func split<B: BlockType>(lhs: B, rhs: B) -> B {
return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
let leftOutputs = lhs.process(inputs)
var rightInputs: [B.SignalType] = []

// 从 lhs 将频道逐个复制输入中
let k = lhs.outputCount
for i in 0..<rhs.inputCount {
rightInputs.append(leftOutputs[i%k])
}

return rhs.process(rightInputs)
})
}

对于输出我们也使用一个类似的惯例,一个立体声 block 作为三个立体声 block 的输入 (总共接受六个声道),也就是说,频道 0 作为输入 0,2,4,而频道 1 作为 1,3,5 的输入。

我们当然不想被这些很长的函数束缚住手脚,所以我写了这些运算符:

// 并行
public func |-<B: BlockType>(lhs: B, rhs: B) -> B

// 串行
public func --<B: BlockType>(lhs: B, rhs: B) -> B

// 分割
public func -<<B: BlockType>(lhs: B, rhs: B) -> B

// 合并
public func >-<B: BlockType where B.SignalType == Signal>(lhs: B, rhs: B) -> B

(我觉得“并行”运算符的定义并不是特别好,因为它看上去和几何中的“垂直”尤其相似,但是现在就这样,非常欢迎大家的意见)

现在有了这些运算符,你可以建立一些有趣的 blocks “图”。比如说 DTMF 音调发生器:

let dtmfFrequencies = [
( 941.0, 1336.0 ),

( 697.0, 1209.0 ),
( 697.0, 1336.0 ),
( 697.0, 1477.0 ),

( 770.0, 1209.0 ),
( 770.0, 1336.0 ),
( 770.0, 1477.0 ),

( 852.0, 1209.0 ),
( 852.0, 1336.0 ),
( 852.0, 1477.0 ),
]

func dtmfTone(digit: Int, sampleRate: Int) -> Block {
assert( digit < dtmfFrequencies.count )
let (f1, f2) = dtmfFrequencies[digit]

let f1Block = Block(inputCount: 0, outputCount: 1, process: { _ in [sineWave(sampleRate, f1)] })
let f2Block = Block(inputCount: 0, outputCount: 1, process: { _ in [sineWave(sampleRate, f2)] })

return ( f1Block |- f2Block ) >- Block(inputCount: 1, outputCount: 1, process: { return $0 })
}

dtmfTone 函数处理两个并行的正弦发生器,然后将它们融合成一个 “单位元 block”,这个 block 只是将自己的输入复制到输出。记住这个函数的返回值本身就是一个 block,所以你可以在更大的系统中使用这个block。

可以看得出来这个想法蕴含了很多的潜力。通过创建可以使用更紧凑和容易理解的 DSL (domain specific language) 来描述复杂系统的环境,我们可以花更少的时间来思考单个 block 的细节,并轻易地把所有东西组合到一起。

实践

如果我今天要开始做一个要求最高性能以及丰富功能的新项目,我会毫不犹豫的使用 Faust。如果你对函数式音频编程感兴趣的话,我极力推荐 Faust。

话虽如此,我一上提到想法的可行性很大程度上依赖于苹果对编译器的改进,编译器需要具有能识别我们定义在 block 中的模式,并输出更智能的代码的能力。也就是说,苹果需要像编译 Haskell 一样来编译 Swift。在 Haskell 中函数式编程模式会被压缩成某一个目标 CPU 的矢量运算。

说实话,我觉得 Swift 在苹果的管理下是很好的,我们也会在将来看见我在以上呈现的想法会变得很常见,而且性能也会变得非常好。


原文链接:http://www.objc.io/issue-24/functional-signal-processing.html

译者:李子轩



收起阅读 »

【开源项目】用环信IM实现的一款教学助手

教学助手开发环境:Tools : Android Studio 4.1.2os : windows 10code : kotlin配置文件:appId 目录 com.kangaroo.studentedu.app.appIdappCe(证书) 目录 com.k...
继续阅读 »

教学助手

开发环境:

  • Tools : Android Studio 4.1.2
  • os : windows 10
  • code : kotlin

配置文件:

  • appId 目录 com.kangaroo.studentedu.app.appId
  • appCe(证书) 目录 com.kangaroo.studentedu.app.appCe

运行环境:

  • os : Android 5.0 +

项目包含内容:

  • Android project
  • 安装包

第三方:

  • 声网灵动课堂 sdk
  • 声网直播 sdk
  • 环信IM sdk

项目背景

疫情期间在线教育火了,各种直播教育软件都开始推广。教育软件开始了火热。就拿我公司来说,我公司是数据化教育行业的一员。在疫情期间帮助学校进行了作业发布,作业批改的业务,帮助学校提升了疫情期间的教学质量。 就拿普通中小学来举例: 互动白板功能非常重要,老师上课会用互动白板功能,老师录课也会用互动白板功能。除了上课和录课,老师还可以通过设备来下发电子考题,考试题目。考试完毕后还可以统计到本堂课的上课质量。

  • 拿一些特殊课堂举例: 艺术课,体育课,音乐课等,这些课堂有时可能不需要白板这样的功能,互动直播功能又变得比较适合。
  • 拿一些校外辅导举例: 校外辅导的校长需要了解当前学校老师教学情况的数据,了解学生上课的数据,了解学校招生的数据。通过这些数据来提升学校的应受。

运行说明

本项目登录功能全部采用环信sdk提供的登录功能呢,支持单设备登录,互踢功能。 由于本项目没有后台,很多功能和数据都是在本地做的处理

安装包:安装包

一个app提供两种身份登录(学生,老师),两种权限(学生,老师)


老师分为2种类型

  1. 普通老师(带有白板功能)
  2. 体育老师等艺术类老师(带有直播功能)

学生端


主功能界面


学生主要有4种功能

签到

学生地理位置的签到,老师会收到学生签到的通知,那么进一步老师会在考勤上记录学生的情况

课堂点评

学生会对老师当堂课程进行点评,打分,可以发送图片内容。点评的数据,在数据统计里展示,学校管理员,或校长,会直接看到,那么校长会知道教学质量

写作业

老师端会给当天课程进行布置作业,布置一些图片作业,或者文字作业。学生要写作业

数据统计

学生的数据主要针对各科的数据进行统计,直观的看自己的平均发展。查漏补缺,提升自己薄弱的方面



课程表

课程表会展示 管理员在后台给老师和学生排的课程,下方课程便是今天学生该上的课程(直播课,或白板课)。

通讯录

教学互动,老师和学生可以直接交流。对今天不会的课程进行答疑

消息列表

消息列表

我的

退出登录,等基本展示

老师端




主功能界面

老师主要有6种功能

学员考勤

勾选今日到校的学生进行考勤管理。

课堂点评

老师可以查看学生对自己的评价,提升自我教学质量

布置作业

老师端会给当天课程进行布置作业,布置一些图片作业,或者文字作业。学生要写作业

批改作业

老师会对发上的作业,进行及时批改。

我的班级

可以查看当前班级,查看学生数据

数据统计

老师关心的班级男女比例,出勤率,课堂评价,作业提交率等,根据数据来对自己的教学质量更改

课程表

课程表会展示 管理员在后台给老师和学生排的课程,下方课程便是今天老师要教的(直播课,或白板课)。

通讯录

教学互动,老师和学生可以直接交流。对今天不会的课程进行答疑

消息列表

消息列表

我的

退出登录,等基本展示


github地址:https://github.com/smartbackme/RTE-2021-Innovation-Challenge/tree/master/Application-Challenge/%5B%E5%8F%B2%E5%A4%A7%E4%BC%9F%5D%20%E6%95%99%E5%AD%A6%E5%8A%A9%E6%89%8B

安装包下载地址:com.kangaroo.studentedu-release-v1.0.0-20210528152329L.apk


欢迎添加环信冬冬微信,联系该项目作者

收起阅读 »

Kotlin 源码 | 降低代码复杂度的法宝

随着码龄增大,渐渐意识到团队代码中的最大的敌人是“复杂度”。不合理的复杂度是降低代码质量,增加沟通成本的元凶。Kotlin 在降低代码复杂度方面有着诸多法宝。这一篇就以两个常见的业务场景来剖析下简单和复杂的关系。若要用一句话概括这关系,我最喜欢这一句:“一切简...
继续阅读 »

随着码龄增大,渐渐意识到团队代码中的最大的敌人是“复杂度”。不合理的复杂度是降低代码质量,增加沟通成本的元凶。

Kotlin 在降低代码复杂度方面有着诸多法宝。这一篇就以两个常见的业务场景来剖析下简单和复杂的关系。若要用一句话概括这关系,我最喜欢这一句:“一切简单的背后都蕴藏着复杂”。

启动线程和读取文件容是 Android 开发中两个颇为常见的场景。分别给出 Java 和 Kotlin 的实现,在惊叹两种语言表达力上悬殊的差距的同时,逐层剖析 Kotlin 语法简单背后的复杂。

启动线程

先看一个简单的业务场景,在 java 中用下面的代码启动一个新线程:

 Thread thread = new Thread() {
@Override
public void run() {
doSomething() // 业务逻辑
super.run();
}
};
thread.setDaemon(false);
thread.setPriority(-1);
thread.setName("thread");
thread.start();

启动线程是一个常用操作,其中除了 doSomething() 之外的其他代码都具有通用性。难道每次启动线程时都复制粘贴这一坨代码吗?不优雅!得抽象成一个静态方法以便到处调用:

public class ThreadUtil {
public static Thread startThread(Callback callback) {
Thread thread = new Thread() {
@Override
public void run() {
if (callback != null) callback.action();
super.run();
}
};
thread.setDaemon(false);
thread.setPriority(-1);
thread.setName("thread");
thread.start();
return thread;
}

public interface Callback {
void action();
}
}

仔细分析下这里引入的复杂度,一个新的类ThreadUtil及静态方法startThread(),还有一个新的接口Callback

然后就可以像这样构建线程了:

ThreadUtil.startThread( new Callback() {
@Override
public void action() {
doSomething();
}
})

对比下 Kotlin 的解决方案thread()

public fun thread(
start: Boolean = true,
isDaemon: Boolean = false,
contextClassLoader: ClassLoader? = null,
name: String? = null,
priority: Int = -1,
block: () -> Unit
): Thread {
val thread = object : Thread() {
public override fun run() {
block()
}
}
if (isDaemon)
thread.isDaemon = true
if (priority > 0)
thread.priority = priority
if (name != null)
thread.name = name
if (contextClassLoader != null)
thread.contextClassLoader = contextClassLoader
if (start)
thread.start()
return thread
}

thread()方法把构建线程的细节全都隐藏在方法内部。

然后就可以像这样启动一个新线程:

thread { doSomething() }

这简洁的背后是一系列语法特性的支持:

1. 顶层函数

Kotlin 中把定义在类体外,不隶属于任何类的函数称为顶层函数thread()就是这样一个函数。这样定义的好处是,可以在任意位置,方便地访问到该函数。

Kotlin 的顶层函数被编译成 java 代码后就变成一个类中的静态函数,类名是顶层函数所在文件名+Kt 后缀。

2. 高阶函数

若函数的参数或者返回值是 lambda 表达式,则称该函数为高阶函数

thread()方法的最后一个参数是 lambda 表达式。在 Kotlin 中当调用函数只传入一个 lambda 类型的参数时,可以省去括号。所以就有了thread { doSomething() }这样简洁的调用。

3. 参数默认值 & 命名参数

thread()函数包含了 6 个参数,为啥在调用时可以只传最后一个参数?因为其余的参数都在定义时提供了默认值。这个语法特性叫参数默认值

当然也可以忽略默认值,重新为参数赋值:

thread(isDaemon = true) { doSomething() }

当只想重新为某一个参数赋值时,不用将其余参数都重写一遍,只需用参数名 = 参数值,这个语法特性叫命名参数

逐行读取文件内容

再看一个稍复杂的业务场景:“读取文件中每一行的内容并打印”,用 Java 实现的代码如下:

File file = new File(path)
BufferedReader bufferedReader = null;
try {
bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
String line;
// 循环读取文件中的每一行并打印
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
// 关闭资源
if (bufferedReader != null) {
try {
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

对比一下 Kotlin 的解决方案:

File(path).readLines().foreach { println(it) }

一句话搞定,就算没学过 Kotlin 也能猜到这是在干啥,语义是如此简洁清晰。这样的代码写的时候畅快,读的时候悦目。

之所以简单,是因为 Kotlin 通过各种语法特性将复杂度分层并隐藏在了后背。

1. 扩展方法

拨开简单的面纱,探究背后隐藏的复杂:

// 为 File 扩展方法 readLines()
public fun File.readLines(charset: Charset = Charsets.UTF_8): List<String> {
// 构建字符串列表
val result = ArrayList<String>()
// 遍历文件的每一行并将内容添加到列表中
forEachLine(charset) { result.add(it) }
// 返回列表
return result
}

扩展方法是 Kotlin 在类体外给类新增方法的语法,它用类名.方法名()表达。

把 Kotlin 编译成 java,扩展方法就是新增了一个静态方法:

final class FilesKt__FileReadWriteKt {
// 静态函数的第一个参数是 File
public static final List readLines(@NotNull File $this$readLines, @NotNull Charset charset) {
Intrinsics.checkNotNullParameter($this$readLines, "$this$readLines");
Intrinsics.checkNotNullParameter(charset, "charset");
final ArrayList result = new ArrayList();
FilesKt.forEachLine($this$readLines, charset, (Function1)(new Function1() {
public Object invoke(Object var1) {
this.invoke((String)var1);
return Unit.INSTANCE;
}

public final void invoke(@NotNull String it) {
Intrinsics.checkNotNullParameter(it, "it");
result.add(it);
}
}));
return (List)result;
}
}

静态方法中的第一个参数是被扩展对象的实例,所以在扩展方法中可以通过this访问到类实例及其公共方法。

File.readLines() 的语义简单明了:遍历文件的每一行,将其添加到列表中并返回。

复杂度都被隐藏在了forEachLine(),它也是 File 的扩展方法,此处应该是this.forEachLine(charset) { result.add(it) },this 通常可以省略。forEachLine()是个好名字,一眼看去就知道是在遍历文件的每一行。

public fun File.forEachLine(charset: Charset = Charsets.UTF_8, action: (line: String) -> Unit): Unit {
BufferedReader(InputStreamReader(FileInputStream(this), charset)).forEachLine(action)
}

forEachLine()中将 File 层层包裹最终形成一个 BufferReader 实例,并且调用了 Reader 的扩展方法forEachLine()

public fun Reader.forEachLine(action: (String) -> Unit): Unit = 
useLines { it.forEach(action) }

forEachLine()调用了同是 Reader 的扩展方法useLines(),从名字细微的差别就可以看出uselines()完成了文件所有行内容的整合,而且这个整合的结果是可以被遍历的。

2. 泛型

哪个类能整合一组元素,并可以被遍历?沿着调用链继续往下:

public inline fun <T> Reader.useLines(block: (Sequence<String>) -> T): T =
buffered().use { block(it.lineSequence()) }

Reader 在useLines()中被缓冲化:

public inline fun Reader.buffered(bufferSize: Int = DEFAULT_BUFFER_SIZE): BufferedReader =
// 如果已经是 BufferedReader 则直接返回,否则再包一层
if (this is BufferedReader) this else BufferedReader(this, bufferSize)

紧接着调用了use(),使用 BufferReader:

// Closeable 的扩展方法
public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
var exception: Throwable? = null
try {
// 触发业务逻辑(扩展对象实例被传入)
return block(this)
} catch (e: Throwable) {
exception = e
throw e
} finally {
// 无论如何都会关闭资源
when {
apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
this == null -> {}
exception == null -> close()
else ->
try {
close()
} catch (closeException: Throwable) {}
}
}
}

这次的扩展函数不是一个具体类,而是一个泛型,并且该泛型的上界是Closeable,即为所有可以被关闭的类新增一个use()方法。

use()扩展方法中,lambda 表达式block代表了业务逻辑,扩展对象作为实参传入其中。业务逻辑在try-catch代码块中被执行,最后在finally中关闭了资源。上层可以特别省心地使用这个扩展方法,因为不再需要在意异常捕获和资源关闭。

3. 重载运算符 & 约定

读取文件内容的场景中,use() 中的业务逻辑是将BufferReader转换成LineSequence,然后遍历它。这里的遍历和类型转换分别是怎么实现的?

// 将 BufferReader 转化成 Sequence
public fun BufferedReader.lineSequence(): Sequence<String> =
LinesSequence(this).constrainOnce()

还是通过扩展方法,直接构造了LineSequence对象并将BufferedReader传入。这种通过组合方式实现的类型转换和装饰者模式颇为类似(关于装饰者模式的详解可以点击使用组合的设计模式 | 美颜相机中的装饰者模式

LineSequence 是一个 Sequence:

// 序列
public interface Sequence<out T> {
// 定义如何构建迭代器
public operator fun iterator(): Iterator<T>
}

// 迭代器
public interface Iterator<out T> {
// 获取下一个元素
public operator fun next(): T
// 判断是否有后续元素
public operator fun hasNext(): Boolean
}

Sequence是一个接口,该接口需要定义如何构建一个迭代器iterator。迭代器也是一个接口,它需要定义如何获取下一个元素及是否有后续元素。

2 个接口中的 3 个方法都被保留词operator修饰,它表示重载运算符,即重新定义运算符的语义。Kotlin 中预定义了一些函数名和运算符的对应关系,称为约定。当前这个约定就是iterator() + next() + hasNext()for循环的约定。

for 循环在 Kotlin 中被定义为“遍历迭代器提供的元素”,需要和in保留词一起使用:

public inline fun <T> Sequence<T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

Sequence 有一个扩展法方法forEach()来简化遍历语法,内部就使用了“for + in”来遍历序列中所有的元素。

所以才可以在Reader.forEachLine()中用如此简单的语法实现遍历文件中的所有行。

public fun Reader.forEachLine(action: (String) -> Unit): Unit = 
useLines { it.forEach(action) }

关于 Sequence 的用法实例可以点击Kotlin 基础 | 望文生义的 Kotlin 集合操作

LineSequence 的语义是 Sequence 中每一个元素都是文件中的一行,它在内部实现iterator()接口,构造了一个迭代器实例:

// 行序列:在 BufferedReader 外面包一层 LinesSequence
private class LinesSequence(private val reader: BufferedReader) : Sequence<String> {
override public fun iterator(): Iterator<String> {
// 构建迭代器
return object : Iterator<String> {
private var nextValue: String? = null // 下一个元素值
private var done = false // 迭代是否结束

// 判断迭代器中是否有下一个元素,并顺便获取下一个元素存入 nextValue
override public fun hasNext(): Boolean {
if (nextValue == null && !done) {
// 下一个元素是文件中的一行内容
nextValue = reader.readLine()
if (nextValue == null) done = true
}
return nextValue != null
}

// 获取迭代器中下一个元素
override public fun next(): String {
if (!hasNext()) {
throw NoSuchElementException()
}
val answer = nextValue
nextValue = null
return answer!!
}
}
}
}

LineSequence 内部的迭代器在hasNext()中获取了文件中一行的内容,并存储在nextValue中,完成了将文件中每一行的内容转换成 Sequence 中的一个元素。

当在 Sequence 上遍历时,文件中每一行的内容就一个个出现在迭代中。这样做的好处是对内存更加友好,LineSequence 并没有持有文件中所有行的内容,它只是定义了如何获取文件中下一行的内容,所有的内容只有等待遍历时,才一个个地浮现出来。

用一句话总结 Kotlin 逐行读取文件内容的算法:用缓冲流(BufferReader)包裹文件,再用行序列(LineSequence)包裹缓冲流,序列迭代行为被定义为读取文件中一行的内容。遍历序列时,文件内容就一行行地被添加到列表中。

总结

顶层函数、高阶函数、默认参数、命名参数、扩展方法、泛型、重载运算符,Kotlin 利用了这些语法特性隐藏了实现常用业务功能的复杂度,并且在内部将复杂度分层。

分层是降低复杂度的惯用手段,它不仅让复杂度分散,使得同一时刻只需面对有限的复杂度,并且可以通过对每一层取一个好名字来概括本层的语义。除此之外,它还有助于定位问题(缩小问题范围)并增加代码可复用性(每层单独复用)。

是不是也可以效仿这种分层的思想方法,在写代码之前,琢磨一下,复杂度是不是太高了?可以运用那些语言特性实现合理的抽象将复杂度分层?以避免复杂度在一个层次被铺开。

收起阅读 »

Kotlin 协程 | CoroutineContext 为什么要设计成 indexed set?(一)

CoroutineContext是 Kotlin 协程中的核心概念,它是用来干嘛的?它由哪些元素组成?它为什么要这样设计?这篇试着分析源码以回答这些问题。 indexed set 既是 set 又是 map? CoroutineContext的定义如下: /*...
继续阅读 »

CoroutineContext是 Kotlin 协程中的核心概念,它是用来干嘛的?它由哪些元素组成?它为什么要这样设计?这篇试着分析源码以回答这些问题。


indexed set 既是 set 又是 map?


CoroutineContext的定义如下:


/**
* Persistent context for the coroutine. It is an indexed set of [Element] instances.
* An indexed set is a mix between a set and a map.
* Every element in this set has a unique [Key].
*/
public interface CoroutineContext { ... }

暂且把CoroutineContext译成协程上下文,简称上下文。


从注解来看,上下文是一个Element的集合,这种集合被称为indexed set。它是介于 set 和 map 之间的一种结构。set 意味着其中的元素有唯一性,map 意味着每个元素都对应一个键。


public interface CoroutineContext {
// Element 也是一个上下文
public interface Element : CoroutineContext { ... }
}

没想到Element也是一个上下文,所以协程上下文是包含了一系列上下文的集合(自己包含自己)。暂且称在协程上下文内部的一系列上下文为子上下文


上下文如何保证子上下文各自的唯一性?


public interface CoroutineContext {
public interface Key<E : Element>
}

上下文为每个子上下文分配了一个Key,它是一个带有类型信息的接口。这个接口通常被实现为companion object


// 子上下文:Job
public interface Job : CoroutineContext.Element {
// Job 的静态 Key
public companion object Key : CoroutineContext.Key<Job> { ... }
}

// 子上下文:拦截器
public interface ContinuationInterceptor : CoroutineContext.Element {
// 拦截器的静态 Key
companion object Key : CoroutineContext.Key<ContinuationInterceptor>
}

// 子上下文:协程名
public data class CoroutineName( val name: String ) : AbstractCoroutineContextElement(CoroutineName) {
// 协程名的静态 Key
public companion object Key : CoroutineContext.Key<CoroutineName>
}

// 子上下文:异常处理器
public interface CoroutineExceptionHandler : CoroutineContext.Element {
// 异常处理器的静态 Key
public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
}

列举了若干源码中定义的子上下文,它们有一个共性,都会在内部声明一个静态的Key,类内部的静态变量意味着被所有类实例共享,即全局唯一的 Key 实例可以对应多个子上下文实例。然而在一个类似 map 的结构中,每个键必须是唯一的,因为对相同的键 put 两次值,新值会代替旧值。如此一来,键的唯一性这就保证了上下文中的所有子上下文实例都是唯一的。这就是indexed set集合的内涵。


做个阶段性总结:





  1. 协程上下文是一个元素的集合,单个元素本身也是一个上下文,所以协程上下文的定义是递归的,自包含的(自己包含若干个自己)。




  2. 协程上下文这个集合有点像 set 结构,因为其中的元素都是唯一的,不重复的。为了做到这一点,每一个元素都配有一个静态的键实例,构成一组键值对,这使得它又有点像 map 结构。这种介于 set 和 map 之间的结构称为indexed set





从 indexed set 获取元素


集合必然提供了存取其中元素的方法,CoroutineContextElement元素的集合,取元素的方法定义如下:


public interface CoroutineContext {
// 根据 key 在上下文中查找元素
public operator fun <E : Element> get(key: Key<E>): E?
}

get()方法输入 Key 返回 Element。CoroutineContext 的子类Element有一个get()的实现:


public interface CoroutineContext {
// 元素
public interface Element : CoroutineContext {
// 元素的键
public val key: Key<*>
public override operator fun <E : Element> get(key: Key<E>): E? =
// 如果给定键和元素本身键相同,则返回当前元素,否则返回空
if (this.key == key) this as E else null
}
}

协程上下文是元素的集合,而元素也是一个上下文,所以元素也是一个元素的集合(解释递归的定义有点像绕口令)。只不过这个元素集合有一点特别,它只包含一个元素,即它本身。这从Element.get()方法的实现中也可以看出:当从 Element 的元素集合中获取元素时,要么返回自身,要么返回空。


协程上下文还有一个实现类叫CombinedContext混合上下文,它的get()实现如下:


// 混合上下文(大蒜)
internal class CombinedContext(
// 左上下文
private val left: CoroutineContext,
// 右元素
private val element: Element
) : CoroutineContext, Serializable {
// 根据 key 在上下文中查找元素
override fun <E : Element> get(key: Key<E>): E? {
var cur = this
while (true) {
// 如果输入 key 和右元素的 key 相同,则返回右元素(剥去大蒜的一片)
cur.element[key]?.let { return it }
// 若右元素不匹配,则向左继续查找
val next = cur.left
// 如果左上下文是混合上下文,则开始向左递归(剥去一片后还是一个大蒜,继续剥)
if (next is CombinedContext) {
cur = next
}
// 若左上下文不是混合上下文,则结束递归
else {
return next[key]
}
}
}
}

CombinedContext.get() 用 while 循环实现了类似递归的效果。CombinedContext的定义本身就是递归的,它包含两个成员:leftelement,其中left是一个协程上下文,若left实例是另一个CombinedContext,就发生了自己包含自己的递归情况,这结构非常像大蒜:left是“蒜体”,element是“蒜皮”。当剥开一片蒜皮后,发现还是一颗大蒜,只是变小了而已。


CombinedContext.get() 这个算法就好比是“找到一棵大蒜中指定的一片蒜皮”,每剥去一片,都检查一下是不是想要的那一片,若不是就继续剥下一片,就这样递归地进行下去,直到命中了指定片或大蒜被剥空了。


CombinedContext这颗大蒜还是偏心的,即它的最后一片不在正中心,而是在最左边(当left的类型不再是CombinedContext时),但遍历这颗大蒜是从最右边开始向左进行的,这使得每一片蒜皮拥有不同的优先级,越早被遍历到,优先级越高。


做一个阶段性总结:



CombinedContext是协程上下文的一个具体实现,就像协程上下文一样,它也包含了一组元素,这组元素被组织成 “偏心大蒜” 这种自包含的结构。偏心大蒜也是 indexed set 的一种具体实现,即它用唯一键对应唯一值的方式保证了集合中元素的唯一性。但和 set 和 map 这种“平”的结构不同的是,偏心大蒜内元素天然是有层级的,遍历大蒜结构是从外层向内(从右到左)进行的,越先被遍历到的元素自然具有较高的优先级。



向 indexed set 追加元素


说完取元素操作,接着说存元素:


public interface CoroutineContext {
// 重载操作符
public operator fun plus(context: CoroutineContext): CoroutineContext =
// 若追加上下文是空的(等于啥也没追加),则直接返回当前山下文(高性能返回)
if (context === EmptyCoroutineContext) this else
// 以当前上下文为初始值进行累加
context.fold(this) { acc, element -> // 累加算法 }
}

CoroutineContext 使用operator保留词重载了plus操作符,即重新定义运算符的语义。Kotlin 中预定义了一些函数名和运算符的对应关系,称为约定。当前这个就是plus()+的约定。当两个 CoroutineContext 实例通过+相连时,就等价于调用了plus()方法,这样做的目的是增加代码可读性。


plus() 的返回值是CoroutineContext,这使得c1 + c2 + c3这样的链式调用变得方便。


EmptyCoroutineContext是一个特殊的上下文,它不包含任何元素,这从它的get()方法的实现中可见一斑:


// 空协程上下文
public object EmptyCoroutineContext : CoroutineContext, Serializable {
// 返回空元素
public override fun <E : Element> get(key: Key<E>): E? = null
...
}

plus() 中调用的CoroutineContext.fold()是将协程上下文中元素进行累加的接口:


public interface CoroutineContext {
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
}

fold() 需要输入一个累加初始值initial和累加算法operation。先来看看 plus() 方法中定义的累加算法:


public interface CoroutineContext {
public operator fun plus(context: CoroutineContext): CoroutineContext =
if (context === EmptyCoroutineContext) this else
// 以当前上下文为初始值进行累加
context.fold(this) { acc, element ->
// 将追加的元素抽出以便将其重定位
val removed = acc.minusKey(element.key)
// 若集合中只包含追加元素,则不需要重定位,直接返回
if (removed === EmptyCoroutineContext) element else {
// 获取元素集合中的 Interceptor
val interceptor = removed[ContinuationInterceptor]
// 如果元素集合中不包含 Interceptor 则将追加元素作为最外层蒜皮
if (interceptor == null) CombinedContext(removed, element) else {
// 如果元素集合中包含 Interceptor 则将其抽出以便将其重定位
val left = removed.minusKey(ContinuationInterceptor)
// 元素集合中只包含 Interceptor 和追加元素
if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
// 将 Interceptor 作为最外层蒜皮,追加元素作为次外层蒜皮
CombinedContext(CombinedContext(left, element), interceptor)
}
}
}
}

累加算法有两个输入参数,一个代表当前累加值acc,另一个代表新追加的元素element。上述算法可以概括为:“当向协程上下文中追加元素时,总是会将所有元素重定位。定位原则如下:将 Interceptor 和新追加的元素依次放在偏心大蒜的最外层和次外层。”


minusKey()


其中minusKey()也是协程上下文的一个接口:


public interface CoroutineContext {
public fun minusKey(key: Key<*>): CoroutineContext
}

minusKey()返回一个协程上下文,该上下文的元素集合中去掉了 key 对应的元素。Element 对该接口的实现如下:


public interface Element : CoroutineContext {
public override fun minusKey(key: Key<*>): CoroutineContext =
if (this.key == key) EmptyCoroutineContext else this
}

因为 Element 只包含一个元素,如果要去掉的元素就是它自己,则返回一个空上下文,否则返回自己。


CombineContext 对 minusKey() 的实现如下:


internal class CombinedContext(
private val left: CoroutineContext,
private val element: Element
) : CoroutineContext, Serializable {
public override fun minusKey(key: Key<*>): CoroutineContext {
// 1. 如果最外层就是要去掉的元素,则直接返回左上下文
element[key]?.let { return left }
// 2. 在左上下文中去掉对应元素
val newLeft = left.minusKey(key)
return when {
// 2.1 左上下文中也不包含对应元素
newLeft === left -> this
// 2.2 左上下文中除了对应元素外不包含任何元素,返回右元素
newLeft === EmptyCoroutineContext -> element
// 2.3 将移除了对应元素的左上下文和右元素组合成新得混合上下文
else -> CombinedContext(newLeft, element)
}
}

可以总结为:在偏心大蒜结构中找到对应的蒜皮,并把它剔除,然后将剩下的所有蒜皮按原来的顺序重新组合成偏心大蒜结构。


Element.fold()


分析完累加算法之后,看看Elementfold()的实现:


public interface CoroutineContext {
public interface Element : CoroutineContext {
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
operation(initial, this)
}

Element 在这个方法中将自己作为追加值。结合上面的累加算法,可以这样理解 Element 累加:“Element 总是将自己作为被追加的元素,即 Element 总是会出现在偏心大蒜的最外层。”


举个例子:


val e1 = Element()
val e2 = Element()
val context = e1 + e2

上述代码中的 context 是一个什么结构?推理如下:



  • e1 + e2 等价于e2.fold(e1)

  • 因为 e2 是 Element 类型,所以调用 Element.fold(),等价于operation(e1, e2)

  • operation 就是上述累加算法,结合累加算法,最终得出 context = CombinedContext(e1, e2)


再举一个更复杂的例子:


val e1 = Element()
val e2 = Element()
val e3 = Element()
val c = CombinedContext(e1, e2)
val context = c + e3

上述代码中的 context 是一个什么结构?推理如下:



  • c + e3 等价于e3.fold(c)

  • 因为 e3 是 Element 类型,所以调用 Element.fold(),等价于operation(c, e3)

  • operation 就是上述累加算法,结合累加算法,最终得出 context = CombinedContext(c, e2)

  • 将 context 完全展开如下:CombinedContext(CombinedContext(e1, e2), e3)


做一个阶段性总结:



两个协程上下文做加法运算意味着将它们的元素合并形成一个新的更大的偏心大蒜。若被加数是 Element 类型的,即被加数中只包含一个元素,则该元素总是被追加到偏心的大蒜的最外层。



CombinedContext.fold()


再来看看CombinedContextfold()的实现:


internal class CombinedContext(
private val left: CoroutineContext,
private val element: Element
) : CoroutineContext, Serializable {
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
operation(left.fold(initial, operation), element)
}

这就比 Element 的复杂多了,因为有递归。


还是举一个例子:


val e1 = Element()
val e2 = Element()
val e3 = Element()
val c = CombinedContext(e1, e2)
val context = e3 + c // 和上一个例子几乎是一样的,只是换了下加数与被加数的位置

上述代码中的 context 是一个什么结构?推理如下:



  • e3 + c 等价于c.fold(e3)

  • 因为 c 是 CombinedContext 类型,所以调用 CombinedContext.fold(),等价于operation(e1.fold(e3), e2)

  • 其中e1.fold(e3)等价于operation(e3, e1),它的值为 CombinedContext(e3, e1)

  • 将第三步结果代入第二步,最终得出 context = CombinedContext(CombinedContext(e3, e1), e2)


再做一个阶段性总结:



两个协程上下文做加法运算意味着将它们的元素合并形成一个新的更大的偏心大蒜。若被加数是 CombinedContext 类型的,即被加数包含一个左侧的蒜体和一个右侧的蒜皮,则蒜皮还是在原来的位置待着,蒜体会和加数融合成新的偏心大蒜结构。



总结


这一篇介绍了 CoroutineContext 的数据结构,它包含如下特征:



  1. 协程上下文是一个元素的集合,单个元素本身也是一个上下文,所以协程上下文的定义是递归的,自包含的(自己包含若干个自己)。

  2. 协程上下文这个集合有点像 set 结构,因为其中的元素都是唯一的,不重复的。为了做到这一点,每一个元素都配有一个静态的键实例,构成一组键值对,这使得它又有点像 map 结构。这种介于 set 和 map 之间的结构称为indexed set

  3. CombinedContext是协程上下文的一个具体实现,就像协程上下文一样,它也包含了一组元素,这组元素被组织成 “偏心大蒜” 这种自包含的结构。偏心大蒜也是 indexed set 的一种具体实现,即它用唯一键对应唯一值的方式保证了集合中元素的唯一性。但和 set 和 map 这种“平”的结构不同的是,偏心大蒜内元素天然是有层级的,遍历大蒜结构是从外层向内(从右到左)进行的,越先被遍历到的元素自然具有较高的优先级。

  4. 两个协程上下文做加法运算意味着将它们的元素合并形成一个新的更大的偏心大蒜。若被加数是 Element 类型的,即被加数中只包含一个元素,则该元素总是被追加到偏心的大蒜的最外层。若被加数是 CombinedContext 类型的,即被加数包含一个左侧的蒜体和一个右侧的蒜皮,则蒜皮还是在原来的位置待着,蒜体会和加数融合成新的偏心大蒜结构。

作者:唐子玄
链接:https://juejin.cn/post/6978613779252641799
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS开发笔记(十一)— UITableView、ARC、xcconfig、Push

前言分享iOS开发中遇到的问题,和相关的一些思考,本次内容包括:UITableView滚动问题、ARC、xcconfig、Push证书。正文UITableViewUITableView在reloadData 的时候,如果height的高度发生较大变化,cont...
继续阅读 »

前言

分享iOS开发中遇到的问题,和相关的一些思考,本次内容包括:UITableView滚动问题、ARC、xcconfig、Push证书。

正文

UITableView

UITableView在reloadData 的时候,如果height的高度发生较大变化,contentOffset无法保持原来的大小时,会发生滚动的效果。如果直接reloadData再setContentOffset:设置位置,仍会出现滚动的效果。
如果需要去除该滚动效果,可以在reloadData之后,调用scrollToRowAtIndexPath并设置animated:NO,最后再用setContentOffset:微调位置。
同理,如果需要在reloadData后,手动scroll到header时,可用同上的解决方案。

UITableView还有类似的问题,如果列表项过多时,scrollToRowAtIndexPath有时并不准确,比如有1000行时滚动到第500行,此时可能会出现滚到501或者499行的情况。
究其原因,是因为UITableView不会调用1~499行所有的heightFor和cellFor方法,所以无法准确计算出来位置。
从这里去分析,如果需要滚动到准确的位置,可以用estimatedRowHeight的属性,设置和行高一样的高度;在行高各不相同的场景,可以设置estimatedRowHeight为大致的数字,在scrollToRowAtIndexPath之后通过setContentOffset:微调位置。

// 解决部分 UITableView不滚动的问题,实现的效果是某个cell在点击后就扩展高度
- (void)onMoreContentClick:(SSBookDetailContentCell *)cell {
++self.showNums;
CGPoint offset = self.contentTableView.contentOffset;
[self.contentTableView beginUpdates];
[self.contentTableView reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:1 inSection:0]] withRowAnimation:UITableViewRowAnimationNone];
[self.contentTableView endUpdates];
[self.contentTableView.layer removeAllAnimations];
[self.contentTableView setContentOffset:offset animated:NO];
}

ARC

Automatic Reference Counting(ARC)是编译器特性,由编译器插入对象内存管理代码,实现内存管理。
如果仅仅是retain/release的管理,非常容易理解,但是插入的代码如何实现weak、strong这些运行时特性?
最近同事遇到一个问题,以下代码会crash:
他实现了一个editingButton的getter,同时在dealloc的时候将其移除;
如果editingButton在整个生命周期都没有初始化时,则在dealloc使用getter会触发初始化,然后在下面的weakify(self);这一行crash。

- (void)dealloc
{
[self.editingView removeFromSuperview];
[self.editingButton removeFromSuperview]; // crash
}

- (UIButton *)editingButton
{
if (!_editingButton)
{
_editingButton = [UIButton buttonWithType:UIButtonTypeCustom];
......
weakify(self); // CRASH
[_editingButton ss_addEventHandler:^(id _Nonnull sender) {
......
} forControlEvents:UIControlEventTouchDown];
}
return _editingButton;
}

闪退的堆栈如下


在ARC的文档中找到闪退的方法,其中有一段描述如下:


当dealloc开始的时候,weakSelf的指针应该都已经被重置为nil;如果在dealloc的函数中再次初始化weakSelf指针会出现异常。

另外,在dealloc方法执行属性的getter方法也是不合理,因为属性的getter方法大都包括如果未创建就创建并初始化的逻辑。
ARC的文档 这份文档也是非常好的ARC学习资料。

xcconfig

xcconfig是用来保存build setting键值对的文件,里面是一些纯文本;

//:configuration = Debug
PRODUCT_BUNDLE_IDENTIFIER = com.loyinglin.dev
DISPLAY_NAME = 测试标题
PRODUCT_NAME = Learning
GCC_TREAT_WARNINGS_AS_ERRORS = YES

//:configuration = Debug
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 SSDEBUG=1

比如这里配置是一份debug的xcconfig,其中PRODUCT_BUNDLE_IDENTIFIER = com.loyinglin.dev的键值会在编译的时候生效。
xcconfig有什么用?
一个Xcode工程,一定会有Debug的开发环境和Release的发布环境,可能会有Testflight的灰度环境、DailyBuild的持续集成环境、XXLanguage的多语言环境、TestCoverage的覆盖率测试环境、IAP的内购测试环境等;每个环境所用的证书不同,APP安装后显示的名字不同,provision file也不同等等。
一种方案是使用Target来解决,公用的部分设置在project,每个环境根据各自特点自定义某些设置;这样带来的后果是target数量增多明显,而target增多带来的后果是当需要新增extension的时候会工作量巨大,并且多环境的管理难度加剧。
另外一种方案是使用Configuration来区分环境,而xcconfig就是用来管理Configuration的文件。

如何创建和使用xcconfig?

1、在Xcode中新建文件,输入config,选择configuration settings file;这一步是创建xcconfig的文件。


2、在Xcode中选中工程,在configurations中选择需要配置的选项,这里以debug为例,点击后选择刚刚已经创建的xcconfig,则可以把xcconfig和debug的编译选项绑定在一起。


如果你用了cocoaPod,你会发现这一项已经有了CocoaPod创建xcconfig,如果选择了自己新建的xcconfig,则会编译失败;
此时可以在自己新建的xcconfig头文件中加入以下代码:

#include "Pods/Target Support Files/Pods-YourName/Pods-YourName.debug.xcconfig"

注意需要修改成自己的工程名。

3、在build setting选中某个配置项,cmd+c复制然后到xcconfig的文件中,cmd+v就可以复制配置项到xcconfig中。
注意如果这个配置项在build setting已经有自定义值,需要将其删除,原因下面解释。


xcconifg的配置和工程默认配置、手动在build setting配置有什么区别?

配置的结果优先级不同,我的理解是:
a、project默认配置是最低优先级,因为是最基础的配置;
b、target配置基于project,但target默认会添加一些配置,优先级比上面高;
c、xcconfig的配置是target某个config的配置,优先级比上面高;
d、target的build setting中直接添加的配置项,优先级比上面高;


知道上面的关系后,我们可以解决使用xcconifg时,CI 打包xcconifg配置项不生效的问题:
检查是否对应配置项是否在target的build setting中直接添加;

如果需要新增某个configuration,可以直接duplicate已有的configuration,但是如果使用Pods需要重新pod install,以生成对应的pod工程的配置项,否则会出现下图的报错:


Push 证书

.p12是连接苹果APNs服务器的证书(公钥+私钥);
.cer 是苹果的证书文件(公钥);
.pem是OpenSSL的证书文件(公钥+私钥);
当我们生成push证书时,其实就是将我们本地的p12通过脚本,导出对应的pem文件;
下面是一段常用的脚本:

P12_CERT=AppStorePush.p12 # p12证书文件
PASSWD=loying # p12密码

EXPORT_CERT=AppStorePush.pem # 导出pem证书
EXPORT_KEY=AppStorePushWithKey.pem # 导出的pem私钥,有密码
EXPORT_KEY_UNENCRY=AppStorePushWithoutKey.pem # 导出的pem私钥,无密码
EXPORT_KEY_AND_CERT=AppStore_ck.pem # 含有证书和私钥的pem

openssl pkcs12 -clcerts -nokeys -out ${EXPORT_CERT} -in ${P12_CERT} -passin pass:${PASSWD} # 导出证书

openssl pkcs12 -nocerts -passout pass:${PASSWD} -out ${EXPORT_KEY} -in ${P12_CERT} -passin pass:${PASSWD} #导出私钥,有密码

openssl rsa -in ${EXPORT_KEY} -passin pass:${PASSWD} -out ${EXPORT_KEY_UNENCRY} # 导出私钥,无密码

cat ${EXPORT_CERT} ${EXPORT_KEY_UNENCRY} > ${EXPORT_KEY_AND_CERT} # 证书和私钥合起来

openssl s_client -connect gateway.push.apple.com:2195 -cert ${EXPORT_CERT} -key ${EXPORT_KEY_UNENCRY} # 测试 push证书

# gateway.push.apple.com
# gateway.sandbox.push.apple.com

在调试Push的时候,以下这个软件(App Store可以下载)非常便捷:


使用时配置好证书(可以点击connect验证是否连接APNs成功),再从iPhone获取到deviceToken添加到设备列表,便可以使用推送。

总结

这些都是在项目中遇到的一些问题,UITableView这个是老生常谈,ARC那篇文档是很好的学习资料,xcconfig需要多研究,未来随着版本和渠道增多会越来越复杂,Push在Easy APNs Provider这个软件出来后就很好测试,再也不用登录信鸽去手动配置Push。
新的一年,继续搬砖和学习。

链接:https://www.jianshu.com/p/0093cb8c5a35

收起阅读 »

iOS股票K线图、分时图绘制

介绍:1、这是以雪球APP为原型,基于 iOS的K线开源项目。2、该项目整体设计思路已经经过某成熟证券APP的商业认证。3、本项目将K线业务代码尽可能缩减,保留核心功能,可流畅、高效实现手势交互。4、K线难点在于手势交互和数据动态刷新上,功能并不复杂,关键在于...
继续阅读 »

介绍:

1、这是以雪球APP为原型,基于 iOS的K线开源项目。
2、该项目整体设计思路已经经过某成熟证券APP的商业认证。
3、本项目将K线业务代码尽可能缩减,保留核心功能,可流畅、高效实现手势交互。
4、K线难点在于手势交互和数据动态刷新上,功能并不复杂,关键在于设计思路。

演示:


建议:

如果搭建K线为公司业务,不建议采用集成度高的开源代码。庞大臃肿,纵然短期匆忙上线,难以应付后期灵活需求变更。
Objective-C版请移步 https://github.com/cclion/CCLKLineChartView
Swift版请移步 https://github.com/cclion/KLineView

设计思路&&难点:

K线难点在于手势的处理上,捏合、长按、拖拽都需要展示不同效果。以下是Z君当时做K线时遇到的问题的解决方案;

1. 捏合手势需要动态改变K线柱的宽度,对应的增加或减少当前界面K线柱的展示数量,并且根据当前展示的数据计算出当前展示数据的极值。
采用UITableView类实现,将K线柱封装在cell中,在tableview中监听捏合手势,对应改变cell的高度,同时刷新cell中K线柱的布局来实现动态改变K线柱的宽度。

采用UITableView还有一个好处就是可以采用cell的重用机制降低内存。

注意:因为UITableView默认是上下滑动,而K线柱是左右滑动,Z君这里将UITableView做了一个顺时针90°的旋转。


2. K线柱绘制

K线柱采用CAShapeLayer配合UIBezierPath绘制,内存低,效率高,棒棒哒!

关于CAShapeLayer的使用大家可以看这篇 https://zsisme.gitbooks.io/ios-/content/chapter6/cashapelayer.html
(现在的google、baidu,好文章都搜不到,一搜全是简单调用两个方法就发的博客,还是翻了两年前的收藏才找到这个网站,强烈推荐大家)

3. 捏合时保证捏合中心点不变,两边以捏合中间点为中心进行收缩或扩散

因为UITableView在改变cell的高度时,默认时不会改变偏移量,所以不能保证捏合的中心点不变,这里我们的小学知识就会用上了。


我们可以通过变量定义控件间距离。


保证捏合中心点的中K线柱的中心点还在捏合前,就需要c1 = c2 ,计算出O2,在捏合完,设置偏移量为O2即可。


4. K线其他线性指标如何绘制

在K线中除了K线柱之外,还有其他均线指标,连贯整个数据显示区。


由图可以看出均线指标由每个cell中心点的数据连接相邻的cell中心点的数据。我们依旧将绘制放在cell中,将相连两个cell的线分割成两段,分别在各自所属的cell中绘制。

需要我们做的就是就是在cell中传入相邻的cell的soureData,计算出相邻中点的位置,分为两段绘制。


大家针对K线有什么问题都可以在下面留言,会第一时间解答。
未完待续

转自:https://www.jianshu.com/p/104857287bc4

收起阅读 »

Babel配置傻傻看不懂?

1.2 AST 是什么玩意?👨‍🎓 啊斌同学: 上面说到的抽象语法树AST又是什么玩意?答:我们上文提到,Babel在解析是时候会通过将code转换为AST抽象语法树,本质上是代码语法结构的一种抽象表示,通过以树🌲形的结构形式表现出它的语法结构,抽象在于它的语...
继续阅读 »

前沿:文章起源在于,朋友跟树酱说在解决项目兼容IE11浏览器过程中,遇到“眼花缭乱”的babel配置和插件等,傻傻分不清配置间的区别、以及不了解如何引用babel插件才能让性能更佳,如果你也有这方面的疑虑,这篇文章可能适合你

1.babel

babel是个什么玩意? Babel本质上是一个编辑器,也就是个“翻译官”的角色,比如树酱听不懂西班牙语,需要别人帮我翻译成为中文,我才晓得。那么Babel就是帮助浏览器翻译的,让web应用能够运行旧版本的浏览器中,比如IE11浏览器不支持Promise等ES6语法,那这个时候在IE11打开你写的web应用,应用就无法正常运行,这时候就需要Babel来“翻译”成为IE11能读懂的

1.1 Babel是怎么工作的?

本质上单独靠Babel是无法完成“翻译”,比如官网的例子const babel = code => code;不借助Babel插件的前提,输出是不会把箭头函数“翻译”的,如果想完成就需要用到插件,更多概念点点击 官方文档

Babel工作原理本质上就是三个步骤:解析、转换、输出,如下👇所示,

1.2 AST 是什么玩意?

👨‍🎓 啊斌同学: 上面说到的抽象语法树AST又是什么玩意?

答:我们上文提到,Babel在解析是时候会通过将code转换为AST抽象语法树,本质上是代码语法结构的一种抽象表示,通过以树🌲形的结构形式表现出它的语法结构,抽象在于它的语言形态不会体现在原始代码code中

下面介绍下在前端项目开发中一些AST的应用场景:

  • Vue模版解析: 我们平时写的.vue文件通过vue-template-compiler解析,.vue文件处理为一个AST
  • Babel的“翻译” : 如将ES6转换为ES5过程中转为AST
  • webpack的插件UglifyJS: uglifyjs-webpack-plugin用来压缩资源,uglifyjs会遇到需要解析es6语法,这个过程中本质上也是借助babel-loader

你可以安装通过本地安装babel-cli做个验证,通过babel-cli编译js文件,玩玩“翻译”

🌲推荐阅读:

1.3 开发自己的babel插件需要了解什么?

👨‍🎓 啊可同学: 树酱,我想自己使用AST开发一个babel插件需要使用到哪些东西呢?

答:我们上一节中提到babel不借助“外援”的话,自己是无法完成翻译,而一个完整的“翻译”的过程是需要走完解析、转换、输出才能完成整个闭环,而这其中的每个环节都需要借助babel以下这些API

  • @babel/parser: babel解析器将源代码code解析成 AST
  • @babel/generator: 将AST解码生成js代码 new Code
  • @babel/traverse : 用来遍历AST树,可以用来改造AST~,如替换或添加AST原始节点
  • @babel/core:包括了整个babel工作流

下面是一个简单“翻译”的demo~

👦:啊宽同学:你不是说@babel/parser是也将源代码code解析成 AST吗?为啥@babel/core也是?

答:@babel/core包含的是整个babel工作流,在开发插件的过程中,如果每个API都单独去引入岂不是蒙蔽了来吧~于是就有了@babel/core插件,顾名思义就是核心插件,他将底层的插件进行封装(包含了parser、generator等),提高原有的插件开发效率,简化过程,好一个“🍟肯德基全家桶”

🌲推荐阅读:

1.4 Babel插件相关

讲完Babel的基本使用,接下来聊聊插件,上文提到单独靠babel是“难成大器”的,需要插件的辅助才能实现霸业,那插件是怎么搞的呢?

通过第一节的学习我们知道完成第一步骤解析完AST后,接下来是进入转换,插件在这个阶段就起到关键作用了。

1.4.1 插件的使用

告诉Babel该做什么之前,我们需要创建一个配置文件.babelrc或者babel.config.js文件

如果我想把es2015的语法转化为es5 及支持es2020的链式写法,我可以这样写

上图所示👆,我们可以看到我们配置两个东西 presentplugin

👨‍🎓 啊可同学:babel不是只需要plugin来帮忙翻译吗,这个present又是什么玩意?

答:presets是预设,举个例子:有一天树酱要去肯德基买鸡翅、薯条、可乐、汉堡。然后我发现有个套餐A包含了(薯条、可乐、汉堡),那这个present就相当于套餐A,它包含了一些插件集合,一个大套餐,这样我就只需要一个套餐A+鸡翅就搞定了,不用配置很多插件。

就好比上面的es2015“套餐”,其实就是Babel团队将同属ES2015相关的很多个plugins集合到babel-preset-es2015一个preset中去

👧 啊琪同学:@babel/preset-env这个是什么?我看很多babel的配置都有

答:@babel/preset-env这个是一个present预设,换句话说就是“豪华大礼包”,包括一系列插件的集合,包含了我们常用的es2015,es2016, es2017等最新的语法转化插件,允许我们使用最新的js语法,比如 let,const,箭头函数等等,但不包括stage-x阶段的插件。换句话说,他包含了我们上文提到了es2015,是个“全家桶”了,而不仅是个套餐了。

1.4.2 自定义 present

👦 啊斌同学:树酱,那我是不是可以自己搞一个预设present?

答: 可以的,但是你可以以 babel-preset-* 的命名规范来创建一个新项目,然后创建一个packjson并安装好定影的依赖和一个index.js 文件用于导出 .babelrc,最终发布到npm中,如下所示

1.4.3 关于 polyfill

比如我们在开发中使用,会使用到一些es6的新特征比如Array.from等,但不是所有的 JavaScript 环境都支持 Array.from,这个时候我们可以使用 Polyfill(代码填充,也可译作兼容性补丁)的“黑科技”,因为babel只转换新的js语法,如箭头函数等,但不转换新的API,比如Symbol、Promise等全局对象,这时候需要借助@babel/polyfill,把es的新特性都装进来,使用步骤如下
  • npm 安装 : npm install --save @babel/polyfill
  • 文件顶部导入 polyfillimport @babel/polyfilll

🙅‍♂️:缺点:全局引入整个 polyfill包,如promise会被全局引入,污染全局环境,所以不建议使用,那有没有更好的方式?可以直接使用@babel/preset-env并修改配置,因为@babel/preset-env包含了@babel/polyfill插件,看下一节

1.4.4 如何通过修改@babel/preset-env配置优化

完成上面的配置,然后用Babel编译代码,我们会发现有时候打出的包体积很大,因为@babel/polyfill有些会被全局引用,那你要弄清楚@babel/preset-env的配置

@babel/preset-env 中与 @babel/polyfill 的相关参数有两个如下:

  • targets: 支持的目标浏览器的列表
  • useBuiltIns: 参数有 “entry”、”usage”、false 三个值。默认值是false,此参数决定了babel打包时如何处理@babel/polyfilll 语句

主要聊聊关于useBuiltIns的不同配置如下:

  • entry: 去掉目标浏览器已支持的polyfilll 模块,将浏览器不支持的都引入对应的polyfilll 模块。
  • usage: 打包时会自动根据实际代码的使用情况,结合 targets 引入代码里实际用到部分 polyfilll模块
  • false: 不会自动引入 polyfilll 模块,对polyfilll模块屏蔽

🌲建议:使用 useBuiltIns: usage来根据目标浏览器的支持情况,按需引入用到的 polyfill 文件,这样打包体积也不会过大

1.4.5 webpack打包如何使用babel?

对于@babel/core@babel/preset-env 、@babel/polyfill等这些插件,当我们在使用webpack进行打包的时候,如何让webpack知道按这些规则去编译js。这时就需要babel-loader了,它相当于一个中间桥梁,通过调用babel/core中的API来告知webpack要如何处理。

1.4.6 开发工具库,涉及到babel使用怎么避免污染环境?

👦 啊斌同学:我开发了一个工具库,也使用了babel,如果引用polyfill,如何避免使用导致的污染环境?

答:在开发工具库或者组件库时,就不能再使用babel-polyfill了,否则可能会造成全局污染,可以使用@babel/runtime。它不会污染你的原有的方法。遇到需要转换的方法它会另起一个名字,否则会直接影响使用库的业务代码,使用@babel/runtime主要在于

  • 可以减小库和工具包的体积,规避babel编译的工具函数在每个模块里都重复出现的情况
  • 在没有使用 @babel/runtime 之前,库和工具包一般不会直接引入 polyfill。否则像 Promise 这样的全局对象会污染全局命名空间,这就要求库的使用者自己提供 polyfill。这些 polyfill 一般在库和工具的使用说明中会提到,比如很多库都会有要求提供 es5 的 polyfill。在使用 babel-runtime 后,库和工具只要在 package.json 中增加依赖 babel-runtime,交给 babel-runtime 去引入 polyfill就可以了

如何使用 @babel/runtime

  • 1.npm安装
npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime
  • 2.配置

1.5 关于babel容易混淆的点

1.5.1 babel-core和@babel/core 区别

👦:啊呆同学:babel-core和@babel/core是什么区别?

答;@babel是在babel7中版本提出来的,就类似于 vue-cli 升级后使用@vue/cli一样的道理,所以babel7以后的版本都是使用 @babel 开头声明作用域,


收起阅读 »

如何用 JS 一次获取 HTML 表单的所有字段 ?

问:如何用 JS 一次获取 HTML 表单的所有字段 ?考虑一个简单的 HTML 表单,用于将任务保存在待办事项列表中:<form> <label for="name">用户名</label> <input...
继续阅读 »

问:如何用 JS 一次获取 HTML 表单的所有字段 ?

考虑一个简单的 HTML 表单,用于将任务保存在待办事项列表中:

<form>
<label for="name">用户名</label>
<input type="text" id="name" name="name" required>

<label for="description">简介</label>
<input type="text" id="description" name="description" required>

<label for="task">任务</label>
<textarea id="task" name="task" required></textarea>

<button type="submit">提交</button>
</form>


上面每个字段都有对应的的typeID和 name属性,以及相关联的label。 用户单击“提交”按钮后,我们如何从此表单中获取所有数据?

有两种方法:一种是用黑科技,另一种是更清洁,也是最常用的方法。为了演示这种方法,我们先创建form.js,并引入文件中。

从事件 target 获取表单字段

首先,我们在表单上为Submit事件注册一个事件侦听器,以停止默认行为(它们将数据发送到后端)。

然后,使用this.elementsevent.target.elements访问表单字段:

相反,如果需要响应某些用户交互而动态添加更多字段,那么我们需要使用FormData

使用 FormData

首先,我们在表单上为submit事件注册一个事件侦听器,以停止默认行为。接着,我们从表单构建一个FormData对象:

const form = document.forms[0];

form.addEventListener("submit", function(event) {
event.preventDefault();
const formData = new FormData(this);
});

除了append()delete()get()set()之外,FormData 还实现了Symbol.iterator。这意味着它可以用for...of 遍历:

const form = document.forms[0];

form.addEventListener("submit", function(event) {
event.preventDefault();
const formData = new FormData(this);

for (const formElement of formData) {
console.log(formElement);
}
})


除了上述方法之外,entries()方法获取表单对象形式:

const form = document.forms[0];

form.addEventListener("submit", function(event) {
event.preventDefault();
const formData = new FormData(this);
const entries = formData.entries();
const data = Object.fromEntries(entries);
});


这也适合Object.fromEntries() (ECMAScript 2019)

为什么这有用?如下所示:

const form = document.forms[0];

form.addEventListener("submit", function(event) {
event.preventDefault();
const formData = new FormData(this);
const entries = formData.entries();
const data = Object.fromEntries(entries);

// send out to a REST API
fetch("https://some.endpoint.dev", {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json"
}
})
.then(/**/)
.catch(/**/);
});


一旦有了对象,就可以使用fetch发送有效负载。

小心:如果在表单字段上省略name属性,那么在FormData对象中刚没有生成。

总结

要从HTML表单中获取所有字段,可以使用:

  • this.elementsevent.target.elements,只有在预先知道所有字段并且它们保持稳定的情况下,才能使用。

使用FormData构建具有所有字段的对象,之后可以转换,更新或将其发送到远程API。*


原文:https://www.valentinog.com/bl...

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

收起阅读 »

自动化注册组件,自动化注册路由--懒人福利(vue,react皆适用)

我是一个react主义者,这次因为项目组关系必须用vue,作为vue小白就记录一下开发过程中的一些骚想法。正文1. 对于路由的操作可能用过umi的同学知道,umi有一套约定式路由的系统,开发过程中可以避免每写一个页面就去手动import到路由的数组中,你只需要...
继续阅读 »

我是一个react主义者,这次因为项目组关系必须用vue,作为vue小白就记录一下开发过程中的一些骚想法。

正文

1. 对于路由的操作

可能用过umi的同学知道,umi有一套约定式路由的系统,开发过程中可以避免每写一个页面就去手动import到路由的数组中,你只需要按照规则,就可以自动化的添加路由。

完美,我们今天就简单实现一个约定式路由的功能。

首先把vue自己的路由注释掉

// const routes: Array = [
// {
// path: "/login",
// name: "login",
// component: Login,
// },
// // {
// // path: "/about",
// // name: "About",
// // // route level code-splitting
// // // this generates a separate chunk (about.[hash].js) for this route
// // // which is lazy-loaded when the route is visited.
// // component: () =>
// // import(/* webpackChunkName: "about" */ "../views/About.vue"),
// // },
// ];


可以看到代码非常的多,随着页面的增加也会越来越多。当然vue的这种方式也有很多好处:比如支持webpack的魔法注释,支持懒加载

接下来就去实现我们的约定式路由吧!

我们这次用到的API是require.context,大家可能以为需要安装什么包,不用不用!这是webpack的东西!具体API的介绍大家可以自行百度了

首先用这玩意去匹配对应规则的页面,然后提前创好我们的路由数组以便使用。

const r = require.context("../views", true, /.vue/);
const routeArr: Array = [];

接下来就是进行遍历啦,匹配了../views文件下的页面,遍历匹配结果,如果是按照我们的规则创建的页面就去添加到路由数组中

比如我现在的views文件夹里是这样的

// 遍历
r.keys().forEach((key) => {
console.log(key) //这里的匹配结果就是 ./login/index.vue ./product/index.vue
const keyArr = key.split(".");
if (key.indexOf("index") > -1) {
// 约定式路由构成方案,views文件夹下的index.vue文件都会自动化生成路由
// 但是我不想在路由中出现index,我只想要login,product,于是对path进行改造。
// 这部其实是有很多优化空间的。大家可以自己试着用正则去提取
const pathArr = keyArr[1].split("/");
routeArr.push({
name: pathArr[1],
path: "/" + pathArr[1],
component: r(key).default, // 这是组件
});
}
});


一起来看一下自动匹配出来的路由数组是什么模样

完美🚖达成了我们的需求。去页面看一看!

完美实现! 最后把全部代码送上。这样就实现了约定式自动注册路由,避免了手动添加的烦恼,懒人必备

import Vue from "vue";
import VueRouter, { RouteConfig } from "vue-router";
const r = require.context("../views", true, /.vue/);
const routeArr: Array = [];
r.keys().forEach((key) => {
const keyArr = key.split(".");
if (key.indexOf("index") > -1) {
// 约定式路由构成方案,views文件夹下的index.vue文件都会自动化生成路由
const pathArr = keyArr[1].split("/");
routeArr.push({
name: pathArr[1],
path: "/" + pathArr[1],
component: r(key).default, // 这是组件
});
}
});
Vue.use(VueRouter);

const router = new VueRouter({
mode: "history",
base: process.env.BASE_URL,
routes: routeArr,
});

export default router;


2.组件

经过上一章的操作,我们可以写页面了,然后就写到了组件。我发现每次使用组件都要在使用的页面去import,非常的麻烦。

通过上一章的想法,我们是不是也可以自动化导入组件呢?

我的想法是:

  • 通过一个方法把components文件下的所有组件进行统一的管理
  • 需要的页面可以用这个方法传入对应的规则,统一返回组件
  • 这个方法可以手动导入,也可以全局挂载。

先给大家看一下我的components文件夹

再看一下现在的页面长相

ok。我们开始在index.ts里撸代码吧

首先第一步一样的去匹配,这里只需要匹配当前文件夹下的所有vue文件

const r = require.context("./"true/.vue/);

然后声明一个方法,这个方法可以做到fn('规则')返回对应的组件,代码如下。

function getComponent(...names: string[]): any {
const componentObj: any = {};
r.keys().forEach((key) => {
const name = key.replace(/(\.\/|\.vue)/g, "");
if (names.includes(name)) {
componentObj[name] = r(key).default;
}
});
return componentObj;
}
export { getComponent };

我们一起来看看调用结果吧

打印结果:

看到这个结果不难想象页面的样子吧! 当然跟之前一样啦!当然实现啦!

非常的完美!

最后

由于项目比较急咯,我还有一些骚想法没有时间去整理去查资料实现,暂时先这样吧~

如果文内有错误,敬请大家帮我指出!(反正我也不一定改哈哈)

最后!谢谢!拜拜!

收起阅读 »

ES6 中 module 备忘清单,你可能知道 module 还可以这样用!

这是一份备忘单,展示了不同的导出方式和相应的导入方式。 它实际上可分为3种类型:名称,默认值和列表 ?// 命名导入/导出 export const name = 'value'import { name } from '...'// 默认导出/导入expor...
继续阅读 »

这是一份备忘单,展示了不同的导出方式和相应的导入方式。 它实际上可分为3种类型:名称,默认值和列表 ?

// 命名导入/导出 
export const name = 'value'
import { name } from '...'

// 默认导出/导入
export default 'value'
import anyName from '...'

// 重命名导入/导出
export { name as newName }
import { newName } from '...'

// 命名 + 默认 | Import All
export const name = 'value'
export default 'value'
import * as anyName from '...'

// 导出列表 + 重命名
export {
name1,
name2 as newName2
}
import {
name1 as newName1,
newName2
} from '...'


接下来,我们来一个一个的看?

命名方式

这里的关键是要有一个name

export const name = 'value';
import { name } from 'some-path/file';

console.log(name); // 'value'

大家都说简历没项目写,我就帮大家找了一个项目,还附赠【搭建教程】

默认方式

使用默认导出,不需要任何名称,所以我们可以随便命名它?

export default 'value'
import anyName from 'some-path/file'

console.log(anyName) // 'value'

❌ 默认方式不用变量名

export default const name = 'value';  
// 不要试图给我起个名字!

命名方式 和 默认方式 一起使用

命名方式 和 默认方式 可以同个文件中一起使用?

eport const name = 'value'
eport default 'value'
import anyName, { name } from 'some-path/file'

导出列表

第三种方式是导出列表(多个)

const name1 = 'value1'
const name2 = 'value2'

export {
name1,
name2
}
import {name1, name2 } from 'some-path/file'

console.log(
name1, // 'value1'
name2, // 'value2'
)

需要注意的重要一点是,这些列表不是对象。它看起来像对象,但事实并非如此。我第一次学习模块时,我也产生了这种困惑。真相是它不是一个对象,它是一个导出列表

// ❌ Export list ≠ Object
export {
name: 'name'
}

重命名的导出

对导出名称不满意?问题不大,可以使用as关键字将其重命名。

const name = 'value'

export {
name as newName
}
import { newName } from 'some-path/file'

console.log(newName); // 'value'

// 原始名称不可访问
console.log(name); // ❌ undefined

❌ 不能将内联导出与导出列表一起使用

export const name = 'value'

// 你已经在导出 name ☝️,请勿再导出我
export {
name
}

大家都说简历没项目写,我就帮大家找了一个项目,还附赠【搭建教程】

重命名导入

同样的规则也适用于导入,我们可以使用as关键字重命名它。

const name1 = 'value1'
const name2 = 'value2'

export {
name1,
name2 as newName2
}
import {
name1 as newName1,
newName2
} from '...'

console.log(newName1); // 'value1'
console.log(newName2); // 'value2'


name1; // undefined
name2; // undefined

导入全部

export const name = 'value'

export default 'defaultValue'
import * as anyName from 'some-path/file'

console.log(anyName.name); // 'value'
console.log(anyName.default); // 'defaultValue'

命名方式 vs 默认方式

是否应该使用默认导出一直存在很多争论。 查看这2篇文章。

就像任何事情一样,答案没有对错之分。正确的方式永远是对你和你的团队最好的方式。

命名与默认导出的非开发术语

假设你欠朋友一些钱。 你的朋友说可以用现金或电子转帐的方式还钱。 通过电子转帐付款就像named export一样,因为你的姓名已附加在交易中。 因此,如果你的朋友健忘,并开始叫你还钱,说他没收到钱。 这里,你就可以简单地向他们显示转帐证明,因为你的名字在付款中。 但是,如果你用现金偿还了朋友的钱(就像default export一样),则没有证据。 他们可以说当时的 100 块是来自小红。 现金上没有名称,因此他们可以说是你本人或者是任何人?

那么采用电子转帐(named export)还是现金(default export)更好?

这取决于你是否信任的朋友?, 实际上,这不是解决这一难题的正确方法。 更好的解决方案是不要将你的关系置于该位置,以免冒险危及友谊,最好还是相互坦诚。 是的,这个想法也适用于你选择named export还是default export。 最终还是取决你们的团队决定,哪种方式对团队比较友好,就选择哪种,毕竟不是你自己一个人在战斗,而是一个团体?

原文:https://segmentfault.com/a/1190000040187607

收起阅读 »

20个 Javascript 技巧,提高我们的摸鱼时间!

使用方便有用的方法,以减少代码行数,提高我们的工作效率,增加我们的摸鱼时间。在我们的日常任务中,我们需要编写函数,如排序、搜索、寻找惟一值、传递参数、交换值等,所以在这里分享一下我工作多年珍藏的几个常用技巧和方法,以让大家增加摸鱼的时间。这些方法肯定会帮助你:...
继续阅读 »

使用方便有用的方法,以减少代码行数,提高我们的工作效率,增加我们的摸鱼时间。

在我们的日常任务中,我们需要编写函数,如排序、搜索、寻找惟一值、传递参数、交换值等,所以在这里分享一下我工作多年珍藏的几个常用技巧和方法,以让大家增加摸鱼的时间。

这些方法肯定会帮助你:

  • 减少代码行
  • Coding Competitions
  • 增加摸鱼的时间

1.声明和初始化数组

我们可以使用特定的大小来初始化数组,也可以通过指定值来初始化数组内容,大家可能用的是一组数组,其实二维数组也可以这样做,如下所示:

const array = Array(5).fill(''); 
// 输出
(5) ["", "", "", "", ""]

const matrix = Array(5).fill(0).map(() => Array(5).fill(0))
// 输出
(5) [Array(5), Array(5), Array(5), Array(5), Array(5)]
0: (5) [0, 0, 0, 0, 0]
1: (5) [0, 0, 0, 0, 0]
2: (5) [0, 0, 0, 0, 0]
3: (5) [0, 0, 0, 0, 0]
4: (5) [0, 0, 0, 0, 0]
length: 5

2. 求和,最小值和最大值

我们应该利用 reduce 方法快速找到基本的数学运算。

const array = [5,4,7,8,9,2];

求和

array.reduce((a,b) => a+b);
// 输出: 35

最大值

array.reduce((a,b) => a>b?a:b);
// 输出: 9

最小值

array.reduce((a,b) => a<b?a:b);
// 输出: 2

3.排序字符串,数字或对象等数组

我们有内置的方法sort()reverse()来排序字符串,但是如果是数字或对象数组呢

字符串数组排序

const stringArr = ["Joe", "Kapil", "Steve", "Musk"]
stringArr.sort();
// 输出
(4) ["Joe", "Kapil", "Musk", "Steve"]

stringArr.reverse();
// 输出
(4) ["Steve", "Musk", "Kapil", "Joe"]

数字数组排序

const array  = [40, 100, 1, 5, 25, 10];
array.sort((a,b) => a-b);
// 输出
(6) [1, 5, 10, 25, 40, 100]

array.sort((a,b) => b-a);
// 输出
(6) [100, 40, 25, 10, 5, 1]

对象数组排序

const objectArr = [ 
{ first_name: 'Lazslo', last_name: 'Jamf' },
{ first_name: 'Pig', last_name: 'Bodine' },
{ first_name: 'Pirate', last_name: 'Prentice' }
];
objectArr.sort((a, b) => a.last_name.localeCompare(b.last_name));
// 输出
(3) [{…}, {…}, {…}]
0: {first_name: "Pig", last_name: "Bodine"}
1: {first_name: "Lazslo", last_name: "Jamf"}
2: {first_name: "Pirate", last_name: "Prentice"}
length: 3

4.从数组中过滤到虚值

像 0undefinednullfalse""''这样的假值可以通过下面的技巧轻易地过滤掉。

const array = [3, 0, 6, 7, '', false];
array.filter(Boolean);


// 输出
(3) [3, 6, 7]

5. 使用逻辑运算符处理需要条件判断的情况

function doSomething(arg1){ 
arg1 = arg1 || 10;
// 如果arg1没有值,则取默认值 10
}

let foo = 10;
foo === 10 && doSomething();
// 如果 foo 等于 10,刚执行 doSomething();
// 输出: 10

foo === 5 || doSomething();
// is the same thing as if (foo != 5) then doSomething();
// Output: 10

6. 去除重复值

const array  = [5,4,7,8,9,2,7,5];
array.filter((item,idx,arr) => arr.indexOf(item) === idx);
// or
const nonUnique = [...new Set(array)];
// Output: [5, 4, 7, 8, 9, 2]

7. 创建一个计数器对象或 Map

大多数情况下,可以通过创建一个对象或者Map来计数某些特殊词出现的频率。

let string = 'kapilalipak';

const table={};
for(let char of string) {
table[char]=table[char]+1 || 1;
}
// 输出
{k: 2, a: 3, p: 2, i: 2, l: 2}

或者

const countMap = new Map();
for (let i = 0; i < string.length; i++) {
if (countMap.has(string[i])) {
countMap.set(string[i], countMap.get(string[i]) + 1);
} else {
countMap.set(string[i], 1);
}
}
// 输出
Map(5) {"k" => 2, "a" => 3, "p" => 2, "i" => 2, "l" => 2}

8. 三元运算符很酷

function Fever(temp) {
return temp > 97 ? 'Visit Doctor!'
: temp < 97 ? 'Go Out and Play!!'
: temp === 97 ? 'Take Some Rest!': 'Go Out and Play!';;
}

// 输出
Fever(97): "Take Some Rest!"
Fever(100): "Visit Doctor!"

9. 循环方法的比较

  • for 和 for..in 默认获取索引,但你可以使用arr[index]
  • for..in也接受非数字,所以要避免使用。
  • forEachfor...of 直接得到元素。
  • forEach 也可以得到索引,但 for...of 不行。

10. 合并两个对象

const user = { 
name: 'Kapil Raghuwanshi',
gender: 'Male'
};
const college = {
primary: 'Mani Primary School',
secondary: 'Lass Secondary School'
};
const skills = {
programming: 'Extreme',
swimming: 'Average',
sleeping: 'Pro'
};

const summary = {...user, ...college, ...skills};

// 合并多个对象
gender: "Male"
name: "Kapil Raghuwanshi"
primary: "Mani Primary School"
programming: "Extreme"
secondary: "Lass Secondary School"
sleeping: "Pro"
swimming: "Average"

11. 箭头函数

箭头函数表达式是传统函数表达式的一种替代方式,但受到限制,不能在所有情况下使用。因为它们有词法作用域(父作用域),并且没有自己的thisargument,因此它们引用定义它们的环境。

const person = {
name: 'Kapil',
sayName() {
return this.name;
}
}
person.sayName();
// 输出
"Kapil"

但是这样:

const person = {
name: 'Kapil',
sayName : () => {
return this.name;
}
}
person.sayName();
// Output
"

13. 可选的链

const user = {
employee: {
name: "Kapil"
}
};
user.employee?.name;
// Output: "Kapil"
user.employ?.name;
// Output: undefined
user.employ.name
// 输出: VM21616:1 Uncaught TypeError: Cannot read property 'name' of undefined

13.洗牌一个数组

利用内置的Math.random()方法。

const list = [1, 2, 3, 4, 5, 6, 7, 8, 9];
list.sort(() => {
return Math.random() - 0.5;
});
// 输出
(9) [2, 5, 1, 6, 9, 8, 4, 3, 7]
// 输出
(9) [4, 1, 7, 5, 3, 8, 2, 9, 6]

14.双问号语法

const foo = null ?? 'my school';
// 输出: "my school"

const baz = 0 ?? 42;
// 输出: 0

剩余和展开语法

function myFun(a,  b, ...manyMoreArgs) {
return arguments.length;
}
myFun("one", "two", "three", "four", "five", "six");

// 输出: 6

const parts = ['shoulders', 'knees']; 
const lyrics = ['head', ...parts, 'and', 'toes'];

lyrics;
// 输出:
(5) ["head", "shoulders", "knees", "and", "toes"]

16.默认参数

const search = (arr, low=0,high=arr.length-1) => {
return high;
}
search([1,2,3,4,5]);

// 输出: 4

17. 将十进制转换为二进制或十六进制

const num = 10;

num.toString(2);
// 输出: "1010"
num.toString(16);
// 输出: "a"
num.toString(8);
// 输出: "12"

18. 使用解构来交换两个数

let a = 5;
let b = 8;
[a,b] = [b,a]

[a,b]
// 输出
(2) [8, 5]

19. 单行的回文数检查

function checkPalindrome(str) {
return str == str.split('').reverse().join('');
}
checkPalindrome('naman');
// 输出: true

20.将Object属性转换为属性数组

const obj = { a: 1, b: 2, c: 3 };

Object.entries(obj);
// Output
(3) [Array(2), Array(2), Array(2)]
0: (2) ["a", 1]
1: (2) ["b", 2]
2: (2) ["c", 3]
length: 3

Object.keys(obj);
(3) ["a", "b", "c"]

Object.values(obj);
(3) [1, 2, 3]



原文:https://dev.to/techygeeky/top...


收起阅读 »

Compose Column控件讲解并且实现一个淘宝商品item的效果

前情提要本篇文章主要对 Compose 中的 Column 进行使用解析,文章结束会使用 Column 和 Row 配合实现一个淘宝商品 Item 的效果,最终效果预览:如果您对 Column 的用法比较娴熟,可以直接看最后一节的内容Column 简单说明Co...
继续阅读 »


前情提要

本篇文章主要对 Compose 中的 Column 进行使用解析,文章结束会使用 Column 和 Row 配合实现一个淘宝商品 Item 的效果,

最终效果预览:

如果您对 Column 的用法比较娴熟,可以直接看最后一节的内容

Column 简单说明

Column 对应于我们开发中的 LinearLayout.vertical,可以垂直的摆放内部控件

因为 Row 和 Column 是想通的,只不过 Column 是垂直方向布局的,而 Row 是水平方向布局。所以讲完了 Column 你只需要把例子代码中的 Column 换成 Row 就可以自行查看 Row 的效果了

Column 参数介绍

modifier

用来定义 Column 的各种属性,比如可以定义宽度、高度、背景等

  1. 示例代码

    设置 modifier 的时候可以链式调用

@Composable
fun DefaultPreview() {
Column(modifier = Modifier
.width(300.dp)
.height(200.dp)
.background(color = Color.Green)) {

}
}
  1. 实现效果

展示了一个绿色填充的矩形

verticalArrangement

实现内部元素的竖直对齐效果

关于 verticalArrangement 我们的示例代码如下

后面介绍每种效果的时候会更改 verticalArrangement 的值进行展示

Row() {
Spacer(modifier = Modifier.width(100.dp))
Column(
modifier = Modifier
.width(50.dp)
.height(200.dp)
.background(color = Color.Green),
// verticalArrangement = Arrangement.SpaceAround
) {
Image(modifier = Modifier.size(20.dp),painter = painterResource(id = R.drawable.apple), contentDescription = null)
Image(modifier = Modifier.size(20.dp),painter = painterResource(id = R.drawable.apple), contentDescription = null)
Image(modifier = Modifier.size(20.dp),painter = painterResource(id = R.drawable.apple), contentDescription = null)
Image(modifier = Modifier.size(20.dp),painter = painterResource(id = R.drawable.apple), contentDescription = null)
}
}
不设置该属性的效果
  1. 效果

  2. 结论

    不设置该属性的时候,内部元素贴着顶部紧凑排列

Arrangement.Center
  1. 效果

  1. 结论

    所有元素垂直居中,紧凑排列

Arrangement.SpaceBetween
  1. 效果

  1. 结论

    元素之间均分空间,与顶部和底部之间无间距

SpaceAround 效果
  1. 效果

  1. 结论

    内部元素等分空间,并且顶部和底部留间距(顶部元素距离顶部的距离和底部元素距离底部的距离与元素等分的长度不一致)

Arrangement.SpaceEvenly
  1. 效果

  1. 结论

    所有元素均分空间(顶部元素距离顶部的距离和底部元素距离底部的距离与元素等分的长度一致)

Arrangement.Bottom
  1. 效果

  1. 结论

    所有元素靠近底部,紧凑排列

Arrangement.spacedBy(*.dp)

可以设置元素间的等分距离

比如我们设置 20dp,Arrangement.spacedBy(20.dp)

  1. 效果

  1. 结论

元素之间距离为 20dp,靠近顶部排列

horizontalAlignment

实现 Column 的水平约束

Alignment.Start 居开始的位置对齐
  1. 效果

  1. 结论

    当前模拟器 Start 就是 Right,所以内部元素居左侧对齐

Alignment.CenterHorizontally 水平居中
  1. 效果

  2. 结论

    内部元素水平居中对齐

Alignment.End
  1. 效果

  1. 结论

    当前模拟器 End 就是 Left,所以内部元素居右侧对齐

content

关于这个属性,注释中都没写他我也就先不研究了

使用 Column 实现淘宝商品 item 布局

  1. 本例中目标效果图如下

  1. 代码
@Composable
fun DefaultPreview2() {
Row(
modifier = Modifier.fillMaxSize(1f).background(color= Color.Gray),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Column(
modifier = Modifier
.width(200.dp)
.background(color = Color.White)
.padding(all = 10.dp)
) {
Image(
painter = painterResource(id = R.drawable.apple),
contentDescription = null,
modifier = Modifier.size(180.dp).clip(RoundedCornerShape(10.dp))
)
Text(
text = "当天发,不要钱",
fontSize = 20.sp,
style = TextStyle(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(vertical = 2.dp)
)
Row(
modifier = Modifier.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "¥说了不要钱",
fontSize = 14.sp,
color = Color(0xff9f8722)
)
Text(text = "23人免费拿", fontSize = 12.sp)
}
Row(
modifier = Modifier
.width(200.dp)
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "不要钱")
Spacer(modifier = Modifier.weight(1f))//通过设置weight让Spacer把Row撑开,实现后面的图片居右对齐的效果
Image(
painter = painterResource(id = android.R.drawable.btn_star_big_on),
contentDescription = null,
)
}
}
}
}
  1. 实现说明

本商品 item 分为四部分:

第一部分:图片,我们使用 Image 实现

第二部分:商品描述,使用一个 Text

第三部分:价格,使用 Row 套两个 Text 实现

第四部分:分期情况,使用 Row 套一个 Text 和 Image 完成,注意因为图片要居右对齐,所以中间需要使用一个 Spacer 挤满剩余宽度。

淘宝商品 item 实现要点

  1. 我们可以使用 modifier = Modifier .width(200.dp) 设置 Column 的宽度
  2. Modifier.padding(all = 10.dp)可以设置四个方向的内边距
  3. modifier = Modifier.size(180.dp).clip(RoundedCornerShape(10.dp)可以设置圆角,因为本例中图片背景和控件背景都是白色,所以看不出来效果
  4. 最底部的控件需要让收藏按钮贴近父控件右侧对齐,使用 Modifier.weight 实现: Spacer(modifier = Modifier.weight(1f))
收起阅读 »

手把手教集成EaseIMKit源码

准备工作我们已经安装了cocoapods (如果没有安装,请百度搜索安装cocoapods教程,并安装)下载EaseIM源码: 源码地址:http://docs-im.easemob.com/im/ios/other/easeimkitEaseIMKit 使用...
继续阅读 »

准备工作

我们已经安装了cocoapods (如果没有安装,请百度搜索安装cocoapods教程,并安装)

下载EaseIM源码: 源码地址:http://docs-im.easemob.com/im/ios/other/easeimkit

EaseIMKit 使用指南 -> 简介 -> EaseIMKit 源码地址 EaseIMKit工程

下载完成后,如下目录 (其中红框内的两个文件夹是我们需要的文件夹)




一.创建工程 + 放入相关文件夹 + 创建Podfile文件


在这里,我创建了一个叫showDemo的工程,将第零步下载的源码文件中,红框圈住的两个文件夹复制,粘贴入新建的工程文件夹内

创建Podfile文件

如下:




二.修改Podfile文件内容

其中红框圈住部分为重要部分



注:最下面红框 为生成Framework而加入.
//================================
platform :ios, '11.0'
workspace 'appName.xcworkspace'
use_frameworks!
target 'appName' do
# pod 'MBProgressHUD'
# pod 'Masonry'
# pod 'MJRefresh'
# pod 'SDWebImage'
# pod 'AFNetworking'
#以上为常用第三方库,根据实际情况添加.(井号#代表注释)
#添加环信的SDK
pod 'HyphenateChat'
#加入EaseIMKit源码
pod 'EaseIMKit', :path => './EaseIMKit/EaseIMKit.podspec'
#若需要添加音视频功能,则需要集成如下SDK
# pod 'AgoraRtcEngine_iOS', '3.3.1' #添加声网SDK
# pod 'EaseCallKit' #添加环信CallKit
end
target 'EaseIMKit' do
project './EaseIMKit/EaseIMKit.xcodeproj'
pod 'HyphenateChat'
pod 'EMVoiceConvert'
end
//================================


三.执行pod install

四.打开项目

打开工作空间,工作空间文件如下图所示,右键打开.




打开之后,整体目录如下:



这里需要注意:

EaseIMKit.framework(EaseIMKit -> Products -> EaseIMKit.framework)的名字应该是黑色的.

如果是红色的,代表文件不存在,解决方法:如上图,标记为2的地方,按照图示选项,运行一次,文件即可变黑.

五.加入framework并运行

加入Framework



运行起来吧.

六.举个例子

(建议command + b先进行编译一下)在需要引入头文件的地方,加入相关头文件,并写代码,举例说明:



七.常见报错

如果工程报错,信息如下:




而且重新pod install也没有用.
解决方案:
我们需要清理掉之前所有pod的第三方,重新pod.
清理可使用cocoapods-clean
由于cocoapods-clean并非cocoapods自带,我们需要额外安装
终端输入命令:
sudo gem install cocoapods-clean
并回车,进行安装cocoapods-clean



cd到工程文件夹目录下,如下:



先执行
pod clean
完成后,再执行
pod install

--- end ---



收起阅读 »

从 Flutter 和前端角度出发,聊聊单线程模型下如何保证 UI 流畅性

一、单线程模型的设计1. 最基础的单线程处理简单任务假设有几个任务:任务1: "姓名:" + "杭城小刘"任务2: "年龄:" + "1995" + "02" + "20"任务3: "大小:" + (2021 - 1995 + 1)任务4: 打印任务1、2、3...
继续阅读 »

一、单线程模型的设计

1. 最基础的单线程处理简单任务

假设有几个任务:

  • 任务1: "姓名:" + "杭城小刘"
  • 任务2: "年龄:" + "1995" + "02" + "20"
  • 任务3: "大小:" + (2021 - 1995 + 1)
  • 任务4: 打印任务1、2、3 的结果

在单线程中执行,代码可能如下:

//c
void mainThread () {
string name = "姓名:" + "杭城小刘";
string birthday = "年龄:" + "1995" + "02" + "20"
int age = 2021 - 1995 + 1;
printf("个人信息为:%s, %s, 大小:%d", name.c_str(), birthday.c_str(), age);
}

线程开始执行任务,按照需求,单线程依次执行每个任务,执行完毕后线程马上退出。

2. 线程运行过程中来了新的任务怎么处理?

问题1 介绍的线程模型太简单太理想了,不可能从一开始就 n 个任务就确定了,大多数情况下,会接收到新的 m 个任务。那么 section1 中的设计就无法满足该需求。

要在线程运行的过程中,能够接受并执行新的任务,就需要有一个事件循环机制。最基础的事件循环可以想到用一个循环来实现。

// c++
int getInput() {
int input = 0;
cout<< "请输入一个数";
cin>>input;
return input;
}

void mainThread () {
while(true) {
int input1 = getInput();
int input2 = getInput();
int sum = input1 + input2;
print("两数之和为:%d", sum);
}
}

相较于第一版线程设计,这一版做了以下改进:

  • 引入了循环机制,线程不会做完事情马上退出。
  • 引入了事件。线程一开始会等待用户输入,等待的时候线程处于暂停状态,当用户输入完毕,线程得到输入的信息,此时线程被激活。执行相加的操作,最终输出结果。不断的等待输入,并计算输出。

3. 处理来自其他线程的任务

真实环境中的线程模块远远没有这么简单。比如浏览器环境下,线程可能正在绘制,可能会接收到1个来自用户鼠标点击的事件,1个来自网络加载 css 资源完成的事件等等。第二版线程模型虽然引入了事件循环机制,可以接受新的事件任务,但是发现没?这些任务之来自线程内部,该设计是无法接受来自其他线程的任务的。

从上图可以看出,渲染主线程会频繁接收到来自于 IO 线程的一些事件任务,当接受到的资源加载完成后的消息,则渲染线程会开始 DOM 解析;当接收到来自鼠标点击的消息,渲染主线程则会执行绑定好的鼠标点击事件脚本(js)来处理事件。

需要一个合理的数据结构,来存放并获取其他线程发送的消息?

消息队列这个词大家都听过,在 GUI 系统中,事件队列是一个通用解决方案。

消息队列(事件队列)是一种合理的数据结构。要执行的任务添加到队列的尾部,需要执行的任务,从队列的头部取出。

有了消息队列之后,线程模型得到了升级。如下:

可以看出改造分为3个步骤:

  • 构建一个消息队列
  • IO 线程产生的新任务会被添加到消息队列的尾部
  • 渲染主线程会循环的从消息队列的头部读取任务,执行任务

伪代码。构造队列接口部分

class TaskQueue {
public:
Task fetchTask (); // 从队列头部取出1个任务
void addTask (Task task); // 将任务插入到队列尾部
}

改造主线程

TaskQueue taskQueue;
void processTask ();
void mainThread () {
while (true) {
Task task = taskQueue.fetchTask();
processTask(task);
}
}

IO 线程

void handleIOTask () {
Task clickTask;
taskQueue.addTask(clickTask);
}

Tips: 事件队列是存在多线程访问的情况,所以需要加锁。

4. 处理来自其他线程的任务

浏览器环境中, 渲染进程经常接收到来自其他进程的任务,IO 线程专门用来接收来自其他进程传递来的消息。IPC 专门处理跨进程间的通信。

5. 消息队列中的任务类型

消息队列中有很多消息类型。内部消息:如鼠标滚动、点击、移动、宏任务、微任务、文件读写、定时器等等。

消息队列中还存在大量的与页面相关的事件。如 JS 执行、DOM 解析、样式计算、布局计算、CSS 动画等等。

上述事件都是在渲染主线程中执行的,因此编码时需注意,尽量减小这些事件所占用的时长。

6. 如何安全退出

Chrome 设计上,确定要退出当前页面时,页面主线程会设置一个退出标志的变量,每次执行完1个任务时,判断该标志。如果设置了,则中断任务,退出线程

7. 单线程的缺点

事件队列的特点是先进先出,后进后出。那后进的任务也许会被前面的任务因为执行时间过长而阻塞,等待前面的任务执行完毕才可以执行后面的任务。这样存在2个问题。

  • 如何处理高优先级的任务

    假如要监控 DOM 节点的变化情况(插入、删除、修改 innerHTML),然后触发对应的逻辑。最基础的做法就是设计一套监听接口,当 DOM 变化时,渲染引擎同步调用这些接口。不过这样子存在很大的问题,就是 DOM 变化会很频繁。如果每次 DOM 变化都触发对应的 JS 接口,则该任务执行会很长,导致执行效率的降低

    如果将这些 DOM 变化做为异步消息,假如消息队列中。可能会存在因为前面的任务在执行导致当前的 DOM 消息不会被执行的问题,也就是影响了监控的实时性

    如何权衡效率和实时性?微任务 就是解决该类问题的。

    通常,我们把消息队列中的任务成为宏任务,每个宏任务中都包含一个微任务队列,在执行宏任务的过程中,假如 DOM 有变化,则该变化会被添加到该宏任务的微任务队列中去,这样子效率问题得以解决。

    当宏任务中的主要功能执行完毕欧,渲染引擎会执行微任务队列中的微任务。因此实时性问题得以解决

  • 如何解决单个任务执行时间过长的问题

    可以看出,假如 JS 计算超时导致动画 paint 超时,会造成卡顿。浏览器为避免该问题,采用 callback 回调的设计来规避,也就是让 JS 任务延后执行。

二、 flutter 里的单线程模型

1. event loop 机制

Dart 是单线程的,也就是代码会有序执行。此外 Dart 作为 Flutter 这一 GUI 框架的开发语言,必然支持异步。

一个 Flutter 应用包含一个或多个 isolate,默认方法的执行都是在 main isolate 中;一个 isolate 包含1个 Event loop 和1个 Task queue。其中,Task queue 包含1个 Event queue 事件队列和1个 MicroTask queue 微任务队列。如下:

为什么需要异步?因为大多数场景下 应用都并不是一直在做运算。比如一边等待用户的输入,输入后再去参与运算。这就是一个 IO 的场景。所以单线程可以再等待的时候做其他事情,而当真正需要处理运算的时候,再去处理。因此虽是单线程,但是给我们的感受是同事在做很多事情(空闲的时候去做其他事情)

某个任务涉及 IO 或者异步,则主线程会先去做其他需要运算的事情,这个动作是靠 event loop 驱动的。和 JS 一样,dart 中存储事件任务的角色是事件队列 event queue。

Event queue 负责存储需要执行的任务事件,比如 DB 的读取。

Dart 中存在2个队列,一个微任务队列(Microtask Queue)、一个事件队列(Event Queue)。

Event loop 不断的轮询,先判断微任务队列是否为空,从队列头部取出需要执行的任务。如果微任务队列为空,则判断事件队列是否为空,不为空则从头部取出事件(比如键盘、IO、网络事件等),然后在主线程执行其回调函数,如下:

2. 异步任务

微任务,即在一个很短的时间内就会完成的异步任务。微任务在事件循环中优先级最高,只要微任务队列不为空,事件循环就不断执行微任务,后续的事件队列中的任务持续等待。微任务队列可由 scheduleMicroTask 创建。

通常情况,微任务的使用场景比较少。Flutter 内部也在诸如手势识别、文本输入、滚动视图、保存页面效果等需要高优执行任务的场景用到了微任务。

所以,一般需求下,异步任务我们使用优先级较低的 Event Queue。比如 IO、绘制、定时器等,都是通过事件队列驱动主线程来执行的。

Dart 为 Event Queue 的任务提供了一层封装,叫做 Future。把一个函数体放入 Future 中,就完成了同步任务到异步任务的包装(类似于 iOS 中通过 GCD 将一个任务以同步、异步提交给某个队列)。Future 具备链式调用的能力,可以在异步执行完毕后执行其他任务(函数)。

看一段具体代码:

void main() {
print('normal task 1');
Future(() => print('Task1 Future 1'));
print('normal task 2');
Future(() => print('Task1 Future 2'))
.then((value) => print("subTask 1"))
.then((value) => print("subTask 2"));
}
//
lbp@MBP  ~/Desktop  dart index.dart
normal task 1
normal task 2
Task1 Future 1
Task1 Future 2
subTask 1
subTask 2

main 方法内,先添加了1个普通同步任务,然后以 Future 的形式添加了1个异步任务,Dart 会将异步任务加入到事件队列中,然后理解返回。后续代码继续以同步任务的方式执行。然后再添加了1个普通同步任务。然后再以 Future 的方式添加了1个异步任务,异步任务被加入到事件队列中。此时,事件队列中存在2个异步任务,Dart 在事件队列头部取出1个任务以同步的方式执行,全部执行(先进先出)完毕后再执行后续的 then。

Future 与 then 公用1个事件循环。如果存在多个 then,则按照顺序执行。

例2:

void main() {
Future(() => print('Task1 Future 1'));
Future(() => print('Task1 Future 2'));

Future(() => print('Task1 Future 3'))
.then((_) => print('subTask 1 in Future 3'));

Future(() => null).then((_) => print('subTask 1 in empty Future'));
}
lbp@MBP  ~/Desktop  dart index.dart
Task1 Future 1
Task1 Future 2
Task1 Future 3
subTask 1 in Future 3
subTask 1 in empty Future

main 方法内,Task 1 添加到 Future 1中,被 Dart 添加到 Event Queue 中。Task 1 添加到 Future 2中,被 Dart 添加到 Event Queue 中。Task 1 添加到 Future 3中,被 Dart 添加到 Event Queue 中,subTask 1 和 Task 1 共用 Event Queue。Future 4中任务为空,所以 then 里的代码会被加入到 Microtask Queue,以便下一轮事件循环中被执行。

综合例子

void main() {
Future(() => print('Task1 Future 1'));
Future fx = Future(() => null);
Future(() => print("Task1 Future 3")).then((value) {
print("subTask 1 Future 3");
scheduleMicrotask(() => print("Microtask 1"));
}).then((value) => print("subTask 3 Future 3"));

Future(() => print("Task1 Future 4"))
.then((value) => Future(() => print("sub subTask 1 Future 4")))
.then((value) => print("sub subTask 2 Future 4"));

Future(() => print("Task1 Future 5"));

fx.then((value) => print("Task1 Future 2"));

scheduleMicrotask(() => print("Microtask 2"));

print("normal Task");
}
lbp@MBP  ~/Desktop  dart index.dart
normal Task
Microtask 2
Task1 Future 1
Task1 Future 2
Task1 Future 3
subTask 1 Future 3
subTask 3 Future 3
Microtask 1
Task1 Future 4
Task1 Future 5
sub subTask 1 Future 4
sub subTask 2 Future 4

解释:

  • Event Loop 优先执行 main 方法同步任务,再执行微任务,最后执行 Event Queue 的异步任务。所以 normal Task 先执行
  • 同理微任务 Microtask 2 执行
  • 其次,Event Queue FIFO,Task1 Future 1 被执行
  • fx Future 内部为空,所以 then 里的内容被加到微任务队列中去,微任务优先级最高,所以 Task1 Future 2 被执行
  • 其次,Task1 Future 3 被执行。由于存在2个 then,先执行第一个 then 中的 subTask 1 Future 3,然后遇到微任务,所以 Microtask 1 被添加到微任务队列中去,等待下一次 Event Loop 到来时触发。接着执行第二个 then 中的 subTask 3 Future 3。随着下一次 Event Loop 到来,Microtask 1 被执行
  • 其次,Task1 Future 4 被执行。随后的第一个 then 中的任务又是被 Future 包装成一个异步任务,被添加到 Event Queue 中,第二个 then 中的内容也被添加到 Event Queue 中。
  • 接着,执行 Task1 Future 5。本次事件循环结束
  • 等下一轮事件循环到来,打印队列中的 sub subTask 1 Future 4、sub subTask 1 Future 5.

3. 异步函数

异步函数的结果在将来某个时刻才返回,所以需要返回一个 Future 对象,供调用者使用。调用者根据需求,判断是在 Future 对象上注册一个 then 等 Future 执行体结束后再进行异步处理,还是同步等到 Future 执行结束。Future 对象如果需要同步等待,则需要在调用处添加 await,且 Future 所在的函数需要使用 async 关键字。

await 并不是同步等待,而是异步等待。Event Loop 会将调用体所在的函数也当作异步函数,将等待语句的上下文整体添加到 Event Queue 中,一旦返回,Event Loop 会在 Event Queue 中取出上下文代码,等待的代码继续执行。

await 阻塞的是当前上下文的后续代码执行,并不能阻塞其调用栈上层的后续代码执行

void main() {
Future(() => print('Task1 Future 1'))
.then((_) async => await Future(() => print("subTask 1 Future 2")))
.then((_) => print("subTask 2 Future 2"));
Future(() => print('Task1 Future 2'));
}
lbp@MBP  ~/Desktop  dart index.dart
Task1 Future 1
Task1 Future 2
subTask 1 Future 2
subTask 2 Future 2

解析:

  • Future 中的 Task1 Future 1 被添加到 Event Queue 中。其次遇到第一个 then,then 里面是 Future 包装的异步任务,所以 Future(() => print("subTask 1 Future 2")) 被添加到 Event Queue 中,所在的 await 函数也被添加到了 Event Queue 中。第二个 then 也被添加到 Event Queue 中
  • 第二个 Future 中的 'Task1 Future 2 不会被 await 阻塞,因为 await 是异步等待(添加到 Event Queue)。所以执行 'Task1 Future 2。随后执行 "subTask 1 Future 2,接着取出 await 执行 subTask 2 Future 2

4. Isolate

Dart 为了利用多核 CPU,将 CPU 层面的密集型计算进行了隔离设计,提供了多线程机制,即 Isolate。每个 Isolate 资源隔离,都有自己的 Event Loop 和 Event Queue、Microtask Queue。Isolate 之间的资源共享通过消息机制通信(和进程一样)

使用很简单,创建时需要传递一个参数。

void coding(language) {
print("hello " + language);
}
void main() {
Isolate.spawn(coding, "Dart");
}
lbp@MBP  ~/Desktop  dart index.dart
hello Dart

大多数情况下,不仅仅需要并发执行。可能还需要某个 Isolate 运算结束后将结果告诉主 Isolate。可以通过 Isolate 的管道(SendPort)实现消息通信。可以在主 Isolate 中将管道作为参数传递给子 Isolate,当子 Isolate 运算结束后将结果利用这个管道传递给主 Isolate

void coding(SendPort port) {
const sum = 1 + 2;
// 给调用方发送结果
port.send(sum);
}

void main() {
testIsolate();
}

testIsolate() async {
ReceivePort receivePort = ReceivePort(); // 创建管道
Isolate isolate = await Isolate.spawn(coding, receivePort.sendPort); // 创建 Isolate,并传递发送管道作为参数
// 监听消息
receivePort.listen((message) {
print("data: $message");
receivePort.close();
isolate?.kill(priority: Isolate.immediate);
isolate = null;
});
}
lbp@MBP  ~/Desktop  dart index.dart
data: 3

此外 Flutter 中提供了执行并发计算任务的快捷方式-compute 函数。其内部对 Isolate 的创建和双向通信进行了封装。

实际上,业务开发中使用 compute 的场景很少,比如 JSON 的编解码可以用 compute。

计算阶乘:

int testCompute() async {
return await compute(syncCalcuateFactorial, 100);
}

int syncCalcuateFactorial(upperBounds) => upperBounds < 2
? upperBounds
: upperBounds * syncCalcuateFactorial(upperBounds - 1);

总结:

  • Dart 是单线程的,但通过事件循环可以实现异步
  • Future 是异步任务的封装,借助于 await 与 async,我们可以通过事件循环实现非阻塞的同步等待
  • Isolate 是 Dart 中的多线程,可以实现并发,有自己的事件循环与 Queue,独占资源。Isolate 之间可以通过消息机制进行单向通信,这些传递的消息通过对方的事件循环驱动对方进行异步处理。
  • flutter 提供了 CPU 密集运算的 compute 方法,内部封装了 Isolate 和 Isolate 之间的通信
  • 事件队列、事件循环的概念在 GUI 系统中非常重要,几乎在前端、Flutter、iOS、Android 甚至是 NodeJS 中都存在。
收起阅读 »

JavaScript中关于null的一切

JavaScript有2种类型:基本类型(string, booleans number, symbol)和对象。对象是复杂的数据结构,JS 中最简单的对象是普通对象:一组键和关联值:let myObject = { name...
继续阅读 »

JavaScript有2种类型:基本类型(stringbooleans numbersymbol)和对象。

对象是复杂的数据结构,JS 中最简单的对象是普通对象:一组键和关联值:

let myObject = {
name: '前端小智'
}

但是在某些情况下无法创建对象。 在这种情况下,JS 提供一个特殊值null —表示缺少对象。

let myObject = null

在本文中,我们将了解到有关JavaScript中null的所有知识:它的含义,如何检测它,nullundefined之间的区别以及为什么使用null造成代码维护困难。

1. null的概念

JS 规范说明了有关null的信息:

值 null 特指对象的值未设置,它是 JS 基本类型 之一,在布尔运算中被认为是falsy

例如,函数greetObject()创建对象,但是在无法创建对象时也可以返回null

function greetObject(who) {
if (!who) {
return null;
}
return { message: `Hello, ${who}!` };
}

greetObject('Eric'); // => { message: 'Hello, Eric!' }
greetObject(); // => null

但是,在不带参数的情况下调用函数greetObject() 时,该函数返回null。 返回null是合理的,因为who参数没有值。

2. 如何检查null

检查null值的好方法是使用严格相等运算符:

const missingObject = null;
const existingObject = { message: 'Hello!' };

missingObject === null; // => true
existingObject === null; // => false

missingObject === null的结果为true,因为missingObject变量包含一个null 值。

如果变量包含非空值(例如对象),则表达式existObject === null的计算结果为false

2.1 null 是虚值

nullfalse0''undefinedNaN都是虚值。如果在条件语句中遇到虚值,那么 JS 将把虚值强制为false

Boolean(null); // => false

if (null) {
console.log('null is truthy')
} else {
console.log('null is falsy')
}

2.2 typeof null

typeof value运算符确定值的类型。 例如,typeof 15是'number'typeof {prop:'Value'}的计算结果是'object'

有趣的是,type null的结果是什么

typeof null// => 'object'

为什么是'object'typoef nullobject是早期 JS 实现中的一个错误。

要使用typeof运算符检测null值。 如前所述,使用严格等于运算符myVar === null

如果我们想使用typeof运算符检查变量是否是对象,还需要排除null值:

function isObject(object) {
return typeof object === 'object' && object !== null;
}

isObject({ prop: 'Value' }); // => true
isObject(15); // => false
isObject(null); // => false

3. null 的陷阱

null经常会在我们认为该变量是对象的情况下意外出现。然后,如果从null中提取属性,JS 会抛出一个错误。

再次使用greetObject() 函数,并尝试从返回的对象访问message属性:

let who = '';

greetObject(who).message;
// throws "TypeError: greetObject() is null"

因为who变量是一个空字符串,所以该函数返回null。 从null访问message属性时,将引发TypeError错误。

可以通过使用带有空值合并的可选链接来处理null:

let who = ''

greetObject(who)?.message ?? 'Hello, Stranger!'
// => 'Hello, Stranger!'

4. null 的替代方法

当无法构造对象时,我们通常的做法是返回null,但是这种做法有缺点。在执行堆栈中出现null时,刚必须进行检查。

尝试避免返回 null 的做法:

  • 返回默认对象而不是null
  • 抛出错误而不是返回null

回到开始返回greeting对象的greetObject()函数。缺少参数时,可以返回一个默认对象,而不是返回null

function greetObject(who) {
if (!who) {
who = 'Stranger';
}
return { message: `Hello, ${who}!` };
}

greetObject('Eric'); // => { message: 'Hello, Eric!' }
greetObject(); // => { message: 'Hello, Stranger!' }

或者抛出一个错误:

function greetObject(who) {
if (!who) {
throw new Error('"who" argument is missing');
}
return { message: `Hello, ${who}!` };
}

greetObject('Eric'); // => { message: 'Hello, Eric!' }
greetObject(); // => throws an error

这两种做法可以避免使用 null

5. null vs undefined

undefined是未初始化的变量或对象属性的值,undefined是未初始化的变量或对象属性的值。

let myVariable;

myVariable; // => undefined

nullundefined之间的主要区别是,null表示丢失的对象,而undefined表示未初始化的状态。

严格的相等运算符===区分nullundefined :

null === undefined // => false

而双等运算符==则认为nullundefined 相等

null == undefined // => true

我使用双等相等运算符检查变量是否为null 或undefined:

function isEmpty(value) {
return value == null;
}

isEmpty(42); // => false
isEmpty({ prop: 'Value' }); // => false
isEmpty(null); // => true
isEmpty(undefined); // => true

6. 总结

null是JavaScript中的一个特殊值,表示丢失的对象,严格相等运算符确定变量是否为空:variable === null

typoef运算符对于确定变量的类型(numberstringboolean)很有用。 但是,如果为null,则typeof会产生误导:typeof null的值为'object'

nullundefined在某种程度上是等价的,但null表示缺少对象,而undefined未初始化状态。


原文:https://segmentfault.com/a/1190000040222768

收起阅读 »

Web 动画原则及技巧浅析

在 Web 动画方面,有一套非常经典的原则 -- Twelve basic principles of animation,也就是关于动画的 12 个基本原则(也称之为迪士尼动画原则),网上对它的解读延伸的文章也非常之多:Animation Prin...
继续阅读 »

在 Web 动画方面,有一套非常经典的原则 -- Twelve basic principles of animation,也就是关于动画的 12 个基本原则(也称之为迪士尼动画原则),网上对它的解读延伸的文章也非常之多:

其中使用的示例 DEMO 属于比较简单易懂,但是没有很好地体现在实际生产中应该如何灵活运用。今天本文将带大家再次复习复习,并且替换其中的最基本的 DEMO,换成一些到今天非常实用,非常酷炫的动画 DEMO 效果。

Squash and stretch -- 挤压和拉伸

挤压和拉伸的目的是为绘制的对象赋予重量感和灵活性。它可以应用于简单的物体,如弹跳球,或更复杂的结构,如人脸的肌肉组织。

应用在动画中,这一原则最重要的方面是对象的体积在被挤压或拉伸时不会改变。如果一个球的长度被垂直拉伸,它的宽度(三个维度,还有它的深度)需要相应地水平收缩。

看看上面这张图,很明显右边这个运动轨迹要比左边的真实很多。

原理动画如下:

类似的一些比较有意思的 Web 动画 DEMO:

CodePen Demo -- CSS Flippy Loader 🍳 By Jhey

仔细看上面这个 Loading 动画,每个块在跳起之前都会有一个压缩准备动作,在压缩的过程中高度变低,宽度变宽,这就是挤压和拉伸,让动画看上去更加真实。

OK,再看两个类似的效果,加深下印象:

CodePen Demo -- CSS Loading Animation

CodePen Demo -- CSS Animation Loader - Jelly Box

简单总结一下,挤压和拉伸的核心在于保持对象的体积一致,当拉伸元素时,它的宽度需要变薄,而当挤压元素时,它的宽度需要变宽。

Anticipation -- 预备动作

准备动作用于为主要的动画动作做好准备,并使动作看起来更逼真。

譬如从地板上跳下来的舞者必须先弯曲膝盖,挥杆的高尔夫球手必须先将球杆向后挥动。

原理动画如下,能够看到滚动之前的一些准备动作:

看看一些实际应用的chang场景,下面这个动画效果:

CodePen Demo -- Never-ending box By Pawel

小球向上滚动,但是仔细看的话,每次向上滚动的时候都会先向后摆一下,可以理解为是一个蓄力动作,也就是我们说的准备动作。

类似的,看看这个购物车动画,运用了非常多的小技巧,其中之一就是,车在向前冲之前会后退一点点进行一个蓄力动作,整个动画的感觉明显就不一样,它让动画看起来更加的自然:

Staging -- 演出布局

Staging 意为演出布局,它的目的是引导观众的注意力,并明确一个场景中什么是最重要的。

可以通过多种方式来完成,例如在画面中放置角色、使用光影,或相机的角度和位置。该原则的本质是关注核心内容,避免其他不必要的细节吸引走用户的注意力。

原理动画如下:

上述 Gif 原理图效果不太明显,看看示例效果:

CodePen Demo -- CSS Loading Animation

该技巧的核心就是在动画的过程中把主体凸显,把非主体元素通过模糊、变淡等方式弱化其效果,降低用户在其之上的注意力。

Straight-Ahead Action and Pose-to-Pose -- 连续运动和姿态对应

其实表示的就是逐帧动画和补间动画:

  • FrameAnimation(逐帧动画):将多张图片组合起来进行播放,可以利用 CSS Aniation 的 Steps,画面由一帧一帧构成,类似于漫画书
  • TweenAnimation(补间动画):补间动画是在时间帧上进行关键帧绘制,不同于逐帧动画的每一帧都是关键帧,补间动画可以在一个关键帧上绘制一个基础形状,然后在时间帧上对另一个关键帧进行形状转变或绘制另一个形状等,然后中间的动画过程是由计算机自动生成。

这个应该是属于最基础的了,在不同场景下有不同的妙用。我们在用 CSS 实现动画的过程中,使用的比较多的应该是补间动画,逐帧动画也很有意思,譬如设计师设计好的复杂动画,利用多张图片拼接成逐帧动画也非常不错。

逐帧动画和补间动画适用在不同的场合,没有谁更好,只有谁更合适,比较下面两个时钟动画,其中一个的秒针运用的是逐帧动画,另外一个则是补间动画:

  • 时钟秒针运用的是逐帧动画:

CodePen Demo -- CSS3 Working Clock By Ilia

  • 时钟秒针运用的是补间动画:

CodePen Demo -- CSS Rotary Clock By Jake Albaugh

有的时候一些复杂动画无法使用 CSS 直接实现的,也会利用逐帧的效果近似实现一个补间动画,像是苹果这个耳机动画,就是实际逐帧动画,但是看起来是连续的:

CodePen Demo -- Apple AirPods Pro Animation (final demo) By Blake Bowen

这里其实是多张图片的快速轮播,每张图片表示一个关键帧。

Follow through and overlapping action 跟随和重叠动作

跟随和重叠动作是两种密切相关的技术的总称,它们有助于更真实地渲染运动,并有助于给人一种印象,即运动的元素遵循物理定律,包括惯性原理。

  • 跟随意味着在角色停止后,身体松散连接的部分应该继续移动,并且这些部分应该继续移动到角色停止的点之外,然后才被拉回到重心或表现出不同的程度的振荡阻尼;
  • 重叠动作是元素各部分以不同速率移动的趋势(手臂将在头部的不同时间移动等等);
  • 第三种相关技术是拖动,元素开始移动,其中一部分需要几帧才能追上。

要创造一个重叠动作的感觉,我们可以让元件以稍微不同的速度移动到每处。这是一种在 iOS 系统的视窗过渡中被运用得很好的方法。一些按钮和元件以不同速率运动,整体效果会比全部东西以相同速率运动要更逼真,并留出时间让访客去适当理解变化。

原理示意图:

看看下面这个购物车动画,仔细看购物车,在移动到停止的过程中,有个很明显的刹车再拉回的感觉,这里运用到了跟随的效果,让动画更加生动真实:

Slow In and Slow Out -- 缓入缓出

现实世界中物体的运动,如人体、动物、车辆等,需要时间来加速和减速。

真实的运动效果,它的缓动函数一定不是 Linear。出于这个原因,运动往往是逐步加速并在停止前变慢,实现一个慢进和慢出的效果,以贴近更逼真的动作。

示意图:

这个还是很好理解的。真实世界中,很少有缓动函数是 Linear 的运动。

Arc -- 弧线运动

大多数自然动作倾向于遵循一个拱形轨迹,动画应该遵循这个原则,遵循隐含的弧形以获得更大的真实感。

原理示意图:

嗯哼,在很多动画中,使用弧线代替直线,能够让动画效果更佳的逼真。看看下面这个烟花粒子动画:

CodePen Demo -- Particles, humankind's only weakness By Rik Schennink

整个烟花粒子动画看上去非常的自然,因为每个粒子的下落都遵循了自由落体的规律,它们的运动轨迹都是弧线而不是直线。

Secondary Action -- 次要动作

将次要动作添加到主要动作可以使场景更加生动,并有助于支持主要动作。走路的人可以同时摆动手臂或将手臂放在口袋里,说话或吹口哨,或者通过面部表情来表达情绪。

原理示意图:

简单的一个应用实例,看看下面这个动画:

CodePen Demo -- Submarine Animation (Pure CSS) By Akhil Sai Ram

这里实现了一个潜艇向前游动的画面,动画本身还有很多可以优化的地方。但也有一些值得学习肯定的地方,动画使用了尾浆转动和气泡和海底景物移动。

同时,值得注意的是,窗口的反光也是一个很小的细节,表示船体在移动,这个就属于一个次要动作,衬托出主体的移动。

再看看下面这打印动画,键盘上按键的上上下下模拟了点击效果,其实也是个次要动作,衬托主体动画效果:

![Secondary Action - CodePen Home
CSS Typewriter](https://p3-juejin.byteimg.com...

CodePen Demo -- CSS Typewriter By Aaron Iker

Timing -- 时间节奏

时间是指给定动作的绘图或帧数,它转化为动画动作的速度。

在纯粹的物理层面上,正确的计时会使物体看起来遵守物理定律。例如,物体的重量决定了它对推动力的反应,因为重量轻的物体会比重量大的物体反应更快。

同一个动画,使用不同的速率展示,其效果往往相差很多。对于 Web 动画而言,可能只需要调整 animation-duration 或 transition-duration 的值。

原理示意图:

可以看出,同个动画,不同的缓动函数,或者赋予不同的时间,就能产生很不一样的效果。

当然,时间节奏可以运用在很多地方,譬如在一些 Loading 动画中:

CodePen Demo -- Only Css 3D Cube By Hisami Kurita

又或者是这样,同个动画,不同的速率:

CodePen Demo -- Rotating Circles Preloader

也可以是同样的延迟、同样的速率,但是不同的方向:

CodePen Demo -- 2020 SVG Animation By @keyframers

Exaggeration -- 夸张手法

夸张是一种对动画特别有用的效果,因为力求完美模仿现实的动画动作可能看起来是静态和沉闷的。

使用夸张时,一定程度的克制很重要。如果一个场景包含多个元素,则应平衡这些元素之间的关系,以避免混淆或吓倒观众。

原理示意图:

OK,不同程度的展现对效果的感官是不一样的,对比下面两个故障艺术动画:

轻微晃动故障:

严重晃动故障:

CodePen Demo -- Glitch Animation

可以看出,第二个动画明显能感受到比第一个更严重的故障。

过多的现实主义会毁掉动画,或者说让它缺乏吸引力,使其显得静态和乏味。相反,为元素对象添加一些夸张,使它们更具活力,能够让它们更吸引眼球。

Solid drawing -- 扎实的描绘

这个原则表示我们的动画需要尊重真实性,譬如一个 3D 立体绘图,就需要考虑元素在三维空间中的形式。

了解掌握三维形状、解剖学、重量、平衡、光影等的基础知识。有助于我们绘制出更为逼真的动画效果。

原理示意图:

再再看看下面这个动画,名为 Close the blinds -- 关上百叶窗:

CodePen Demo -- Close the blinds By Chance Squires

hover 的时候有一个关上动画,使用多块 div 模拟了百叶窗的落下,同时配合了背景色从明亮到黑暗的过程,很好的利用了色彩光影辅助动画的展示。

再看看这个摆锤小动画,也是非常好的使用了光影、视角元素:

CodePen Demo -- The Three-Body Problem By Vian Esterhuizen

最后这个 Demo,虽然是使用 CSS 实现的,但是也尽可能的还原模拟了现实中纸张飞舞的形态,并且对纸张下方阴影的变化也做了一定的变化:

CodePen Demo -- D CSS-only flying page animation tutorial By @keyframers

好的动画,细节是经得起推敲的。

Appeal -- 吸引力

一反往常,精美的细节往往能非常好的吸引用户的注意力。

吸引力是艺术作品的特质,而如何实现有吸引力的作品则需要不断的尝试。

原理示意图:

我觉得这一点可能是 Web 动画的核心,一个能够吸引人的动画,它肯定是有某些特质的,让我们一起来欣赏下。

CodePen Demo -- Download interaction By Milan Raring

通过一连串的动作,动画展开、箭头移动、进度条填满、数字变化,把一个下载动画展示的非常 Nice,让人在等待的过程并不觉得枯燥。

再来看看这个视频播放的效果:

CodePen Demo -- Video button animation - Only CSS

通过一个遮罩 hover 放大,再到点击全屏的变化,一下子就将用户的注意力给吸引了过来。

Web 动画的一些常见误区

当然,上述的一些技巧源自于迪士尼动画原则,我们可以将其中的一些思想贯穿于我们的 Web 动画的设计之中。

但是,必须指出的是,Web 动画本身在使用的时候,也有一些原则是我们需要注意的。主要有下面几点:

  • 增强动画与页面元素之间的关联性
  • 不要为了动画而动画,要有目的性
  • 动画不要过于缓慢,否则会阻碍交互

增强动画与页面元素之间的关联性

这个是一个常见的问题,经常会看到一些动画与主体之间没有关联性。关联性背后的逻辑,能帮助用户在界面布局中理解刚发生的变化,是什么导致了变化。

好的动画可以做到将页面的多个环节或者场景有效串联。

比较下面两个动画,第二个就比第一个更有关联性:

没有强关联性的:

有关联性的:

很明显,第二个动画比第一个动画更能让用户了解页面发生的变化。

不要为了动画而动画,要有目的性

这一点也很重要,不要为了动画而动画,要有目的性,很多时候很多页面的动画非常莫名其妙。

emm,简单一点来说就是单纯的为了炫技而存在的动画。这种动画可以存在于你的 Demo,你的个人网站中,但不太适合用于线上业务页面中。

使用动画应该有明确的目的性,譬如 Loading 动画能够让用户感知到页面正在发生变化,正在加载内容。

在我们的交互过程中,适当的增加过渡与动画,能够很好的让用户感知到页面的变化。类似的还有一些滚动动画。丝滑的滚动切换比突兀的内容明显是更好的体验。

动画不要过于缓慢,否则会阻碍交互

缓慢的动画,它产生了不必要的停顿。

一些用户会频繁看到它们的过渡动画,尽可能的保持简短。让动画持续时间保持在 300ms 或更短。

比较下面两个动画,第一次可能会让用户耳目一新,但是如果用户在浏览过程中频繁出现通过操作,过长的转场动画会消耗用户大量不必要的时间:

过长的转场动画:

缩短转场动画时间,保持恰当的时长:

结合产品及业务的创意交互动画

这一点是比较有意思的。我个人认为,Web 动画做得好用的妙,是能非常好的提升用户体验,提升品牌价值的。

结合产品及业务的创意动画,是需要挖掘,不断打磨的不断迭代的。譬如大家津津乐道的 BiliBili 官网,它的顶部 Banner,配合一些节日、活动,经常就会有出现一些有意思的创意交互动画。简单看两个:

以及这个:

我非常多次在不同地方看到有人讨论 Bilibili 的顶部 banner 动画,可见它这块的动画是成功的。很好的结合了一些节日、实事、热点,当成了一种比较固定的产品去不断推陈出新,在不同时候给与用户不同的体验。

考虑动画的性价比

最后一条,就是动画虽好,但是打磨一个精品动画是非常耗时的,尤其是在现在非常多的产品业务都是处于一种敏捷开发迭代之下。

一个好的 Web 动画从构思到落地,绝非前端一个人的工作,需要产品、设计、前端等等相关人员公共努力, 不断修改才能最终呈现比较好的效果。所以在项目初期,一定需要考虑好性价比,是否真的值得为了一个 Web 动画花费几天时间呢?当然这是一个非常见仁见智的问题。

参考文章

最后

想使用 Web 技术绘制生动有趣的动画并非易事,尤其在现在国内的大环境下,鲜有人会去研究动画原则,并运用于实践生产之中。但是它本身确实是个非常有意思有技术的事情。希望本文能给大伙对 Web 动画的认知带来一些提升和帮助,在后续的工作中多少运用一些。

原文:https://segmentfault.com/a/1190000040223372

收起阅读 »

这个vue3的应用框架你学习了吗?

vue
1.新项目初期当我们开始一个新项目的筹备的时候(这里特指中后台应用),项目初始化往往我们可能会考虑以下几个问题如何统一做权限管理?如何统一对请求库比如基于 Axios做封装(取消重复请求、请求节流、错误异常处理等统一处理)如何作为子应用嵌入到微前端体系(假设基...
继续阅读 »

1.新项目初期

当我们开始一个新项目的筹备的时候(这里特指中后台应用),项目初始化往往我们可能会考虑以下几个问题
  • 如何统一做权限管理?
  • 如何统一对请求库比如基于 Axios做封装(取消重复请求、请求节流、错误异常处理等统一处理)
  • 如何作为子应用嵌入到微前端体系(假设基于qiankun)
  • 如何共享响应式数据?
  • 配置信息如何管理?

1.1 你可能会这样做

如果每次新建一个项目得时候,我们都得手动去处理以上这些问题,那么将是一个重复性操作,而且还要确保团队一致,那么还得考虑约束能力

在没有看到这个Fes.js这个解决方案之前,对于上述问题,我的解决方式就是
  • 通过维护一个公共的工具库来封装,比如axios的二次封装
  • 开发一个简易的脚手架,把这些东西集成到一个模板中,再通过命令行去拉取
  • 直接通过vue-cli生成模板再进行自定义配置修改等等,简单就是用文档,工具,脚手架来赋能

    但其实有没有更好的解决方案?

图片引自文章《蚂蚁前端研发最佳实践》

1.2 其他解决方式 - 框架(插件化)

学习react的童鞋都知道,在react社区有个插件化的前端应用框架 UmiJS,而vue的世界中并不存在,而接下来我们要分享的 Fes.js就是vue中的 UmiJS, Fes.js 很多功能是借鉴 UmiJS 做的, UmiJS 内置了路由、构建、部署、测试等,还支持插件和插件集,以满足功能和垂直域的分层需求。

本质上是为了更便捷、更快速地开发中后台应用。框架的核心是插件管理,提供的内置插件封装了大量构建相关的逻辑,并且有着丰富的插件生态,业务中需要处理的脏活累活靠插件来解决,而用户只需要简单配置或者按照规范使用即可

甚至你还可以将插件做聚合成插件集,类似 babel 的 plugin 和 preset,或者 eslint 的 rule 和 config。通过插件和插件集来满足不同场合的业务

通过插件扩展 import from UmiJS 的能力,比如类似下图,是不是很像vue 3Composition API设计

拓展阅读:

  • UmiJS 插件体系的一些初步理解

    2. Fes.js

    官方介绍: Fes.js 是一个好用的前端应用解决方案。 Fes.js 2.0 以Vue 3.0和路由为基础,同时支持配置式路由和约定式路由,并以此进行功能扩展。匹配了覆盖编译时和运行时生命周期完善的插件体系,支持各种功能扩展和业务需求。

2.1 支持约定式路由

约定式路由是个啥? 约定式路由也叫文件路由,就是不需要手写配置,文件系统即路由,现在越来越多框架支持约定式路由,包括上文提到的 UmiJS,还有SSR的nuxt等等,节省我们手动配置路由的时间. 关于fes更多的路由配置看路由文档

2.2 插件化支持

本质上一个插件是就是一个npm包, 通过插件扩展Fes.js的功能,目前 Fes.js已经有多个插件开源。而且插件可以管理项目的编译时和运行时 插件文档

插件源码地址 链接。fesjs也支持开发者自定义插件,详情看插件化开发文档

彬彬同学: 那什么叫支持编译时和运行时?

可以这样理解: 如果是编译时的配置,就是打包的时候,就根据配置完成相应的代码构建,而运行时的配置,则是代码在浏览器执行时,才会根据读取的配置去做相应处理,如果感兴趣,可以深入理解下fesjs的插件源码,了解如何根据编译时和运行时做处理 fes-plugin-access 源码链接

2.3 Fes.js 如何使用

Fes.js 提供了命令行工具 create-fes-app, 全局安装后直接通过该命令创建项目模板,项目结构如下所示

然后运行 npm run dev 就可以开启你的fes之路, 如下图所示

2.4 为啥选择 Fes.js

像vue-cli 只能解决我们项目中开发,构建,打包等基本问题,而 Fes.js可以直接解决大部分常规中后台应用的业务场景的问题,包括如下
  • 配置化布局:解决布局、菜单 、导航等配置问题,类似low-code机制
  • 权限控制:通过内置的access插件实现站点复杂权限管理
  • 请求库封装:通过内置的request插件,内置请求防重、请求节流、错误处理等功能
  • 微前端集成:通过内置qiankun插件,快速集成到微前端中体系

期待更多的插件可以赋能中后台应用业务场景

3.回顾 vue 3

3.1 新特征

vue3.0 相对于 vue2.0变更几个比较大的点包括如下

  • 性能提升: 随着主流浏览器对es6的支持,es module成为可以真正落地的方案,也进一步优化了vue的性能
  • 支持typescript: 通过ts其类型检查机制,可避免我们在重构过程中引入意外的错误
  • 框架体积变小:框架体积优化后,一方面是因为引入Composition API的设计,同时支持tree-shaking树摇,按需引入模块API,将无用模块都会最终被摇掉,使得最终打包后的bundle的体积更小
  • 更优的虚拟Dom方案实现 : 添加了标记flag,Vue2的Virtual DOM不管变动多少整个模板会进行重新的比对, 而vue3对动态dom节点进行了标记PatchFlag ,只需要追踪带有PatchFlag的节点。并且当节点的嵌套层级多的情况,动态节点都是直接跟根节点直接绑定的,也就是说当diff算法走到了根dom节点的时候,就会直接定位动态变化的节点,并不会去遍历静态dom节点,以此提升了效率
  • 引入Proxy特性: 取代了vue2的Object.defineProperty来实现双向绑定,因为其本身的局限性,只能劫持对象的属性,如果对象属性值是对象,还需要进行深度遍历,才能做到劫持,并不能真正意义上的完整劫持整个对象,而proxy可以完整劫持整个对象

3.2 关于 Composition API

vue3 取代了原本vue2通过Options API来构建组件设计(强制我们进行代码分层),而采用了类似React Hooks的设计,通过可组组合式的、低侵入式的、函数式的 API,使得我们构建组件更加灵活。官方定义:一组基于功能的附加API,可以灵活地组合组件逻辑

通过上图的对比,我们可以看出Composition API 与 Options API在构建组件的差别,很明显基于Composition API构建会更加清晰明了。我们会发现vue3几个不同的点:

  • vue3提供了两种数据响应式监听APIrefreactive,这两者的区别在 reactive主要用于定义复杂的数据类型比如对象,而ref则用于定义基本类型比如字符串
  • vue3 提供了setup(props, context)方法,这是使用Composition API 的前提入口,相当于 vue2.x 在 生命周期beforeCreate 之后 created 之前执行,方法中的props参数是用来获取在组件中定义的props的,需要注意的是props是响应式的, 并不能使用es6解构(它会消除prop的响应性),如果需要监听响应还需要使用wacth。而context参数来用来获取attribute,获取插槽,或者发送事件,比如 context.emit,因为在setup里面没有this上下文,只能使用context来获取山下文

关于vue3的更多实践后期会继续更新,本期主要是简单回顾

你好,我是🌲 树酱,请你喝杯🍵 记得三连哦~

1.阅读完记得点个赞哦,有👍 有动力

2.关注公众号前端那些趣事,陪你聊聊前端的趣事

3.文章收录在Github frontendThings 感谢Star✨

原文:https://segmentfault.com/a/1190000040236420



收起阅读 »

Esbuild 为什么那么快

Esbuild 是什么Esbuild 是一个非常新的模块打包工具,它提供了与 Webpack、Rollup、Parcel 等工具相似的资源打包能力,却有着高的离谱的性能优势:下面展开细讲。为什么快语言优势大多数前端打包工具都是基于 JavaScript 实现的...
继续阅读 »

Esbuild 是什么

Esbuild 是一个非常新的模块打包工具,它提供了与 Webpack、Rollup、Parcel 等工具相似的资源打包能力,却有着高的离谱的性能优势:

从上到下,耗时逐步上升达到数百倍的差异,这个巨大的性能优势使得 Esbuild 在一众基于 Node 的构建工具中迅速蹿红,特别是 Vite 2.0 宣布使用 Esbuild 预构建依赖后,前端社区关于它的讨论热度逐渐上升。

那么问题来了,这是怎么做到的?我翻阅了很多资料后,总结了一些关键因素:

下面展开细讲。

为什么快

语言优势

大多数前端打包工具都是基于 JavaScript 实现的,而 Esbuild 则选择使用 Go 语言编写,两种语言各自有其擅长的场景,但是在资源打包这种 CPU 密集场景下,Go 更具性能优势,差距有多大呢?比如计算 50 次斐波那契数列,JS 版本:

function fibonacci(num) {
if (num < 2) {
return 1
}
return fibonacci(num - 1) + fibonacci(num - 2)
}

(() => {
let cursor = 0;
while (cursor < 50) {
fibonacci(cursor++)
}
})()

Go 版本:

package main

func fibonacci(num int) int{
if num<2{
return 1
}

return fibonacci(num-1) + fibonacci(num-2)
}

func main(){
for i := 0; i<50; i++{
fibonacci(i)
}
}

JavaScript 版本执行耗时大约为 332.58s,Go 版本执行耗时大约为 147.08s,两者相差约 1.25 倍,这个简单实验并不能精确定量两种语言的性能差别,但感官上还是能明显感知 Go 语言在 CPU 密集场景下会有更好的性能表现。

归根到底,虽然现代 JS 引擎与10年前相比有巨大的提升,但 JavaScript 本质上依然是一门解释型语言,JavaScript 程序每次执行都需要先由解释器一边将源码翻译成机器语言,一边调度执行;而 Go 是一种编译型语言,在编译阶段就已经将源码转译为机器码,启动时只需要直接执行这些机器码即可。也就意味着,Go 语言编写的程序比 JavaScript 少了一个动态解释的过程。

这种语言层面的差异在打包场景下特别突出,说的夸张一点,JavaScript 运行时还在解释代码的时候,Esbuild 已经在解析用户代码;JavaScript 运行时解释完代码刚准备启动的时候,Esbuild 可能已经打包完毕,退出进程了!

所以在编译运行层面,Go 前置了源码编译过程,相对 JavaScript 边解释边运行的方式有更高的执行性能。

多线程优势

Go 天生具有多线程运行能力,而 JavaScript 本质上是一门单线程语言,直到引入 WebWorker 规范之后才有可能在浏览器、Node 中实现多线程操作。

我曾经研读过 Rollup、Webpack 的代码,就我熟知的范围内两者均未使用 WebWorker 提供的多线程能力。反观 Esbuild,它最核心的卖点就是性能,它的实现算法经过非常精心的设计,尽可能饱和地使用各个 CPU 核,特别是打包过程的解析、代码生成阶段已经实现完全并行处理。

除了 CPU 指令运行层面的并行外,Go 语言多个线程之间还能共享相同的内存空间,而 JavaScript 的每个线程都有自己独有的内存堆。这意味着 Go 中多个处理单元,例如解析资源 A 的线程,可以直接读取资源 B 线程的运行结果,而在 JavaScript 中相同的操作需要调用通讯接口 woker.postMessage 在线程间复制数据。

所以在运行时层面,Go 拥有天然的多线程能力,更高效的内存使用率,也就意味着更高的运行性能。

节制

对,没错,节制!

Esbuild 并不是另一个 Webpack,它仅仅提供了构建一个现代 Web 应用所需的最小功能集合,未来也不会大规模加入我们业已熟悉的各类构建特性。最新版本 Esbuild 的主要功能特性有:

  • 支持 js、ts、jsx、css、json、文本、图片等资源
  • 增量更新
  • Sourcemap
  • 开发服务器支持
  • 代码压缩
  • Code split
  • Tree shaking
  • 插件支持

可以看到,这份列表中支持的资源类型、工程化特性非常少,甚至并不足以支撑一个大型项目的开发需求。在这之外,官网明确声明未来没有计划支持如下特性:

  • ElmSvelteVueAngular 等代码文件格式
  • Ts 类型检查
  • AST 相关操作 API
  • Hot Module Replace
  • Module Federation

而且,Esbuild 所设计的插件系统也无意覆盖以上这些场景,这就意味着第三方开发者无法通过插件这种无侵入的方式实现上述功能,emmm,可以预见未来可能会出现很多魔改版本。

Esbuild 只解决一部分问题,所以它的架构复杂度相对较小,相对地编码复杂度也会小很多,相对于 Webpack、Rollup 等大一统的工具,也自然更容易把性能做到极致。节制的功能设计还能带来另外一个好处:完全为性能定制的各种附加工具。

定制

回顾一下,在 Webpack、Rollup 这类工具中,我们不得不使用很多额外的第三方插件来解决各种工程需求,比如:

  • 使用 babel 实现 ES 版本转译
  • 使用 eslint 实现代码检查
  • 使用 TSC 实现 ts 代码转译与代码检查
  • 使用 less、stylus、sass 等 css 预处理工具

我们已经完全习惯了这种方式,甚至觉得事情就应该是这样的,大多数人可能根本没有意识到事情可以有另一种解决方案。Esbuild 起了个头,选择完全!完全重写整套编译流程所需要用到的所有工具!这意味着它需要重写 js、ts、jsx、json 等资源文件的加载、解析、链接、代码生成逻辑。

开发成本很高,而且可能被动陷入封闭的风险,但收益也是巨大的,它可以一路贯彻原则,以性能为最高优先级定制编译的各个阶段,比如说:

  • 重写 ts 转译工具,完全抛弃 ts 类型检查,只做代码转换
  • 大多数打包工具把词法分析、语法分析、符号声明等步骤拆解为多个高内聚低耦合的处理单元,各个模块职责分明,可读性、可维护性较高。而 Esbuild 则坚持性能第一原则,不惜采用反直觉的设计模式,将多个处理算法混合在一起降低编译过程数据流转所带来的性能损耗
  • 一致的数据结构,以及衍生出的高效缓存策略,下一节细讲

这种深度定制一方面降低了设计成本,能够保持编译链条的架构一致性;一方面能够贯彻性能第一的原则,确保每个环节以及环节之间交互性能的最优。虽然伴随着功能、可读性、可维护性层面的的牺牲,但在编译性能方面几乎做到了极致。

结构一致性

上一节我们讲到 Esbuild 选择重写包括 js、ts、jsx、css 等语言在内的转译工具,所以它更能保证源代码在编译步骤之间的结构一致性,比如在 Webpack 中使用 babel-loader 处理 JavaScript 代码时,可能需要经过多次数据转换:

  • Webpack 读入源码,此时为字符串形式
  • Babel 解析源码,转换为 AST 形式
  • Babel 将源码 AST 转换为低版本 AST
  • Babel 将低版本 AST generate 为低版本源码,字符串形式
  • Webpack 解析低版本源码
  • Webpack 将多个模块打包成最终产物

源码需要经历 string => AST => AST => string => AST => string ,在字符串与 AST 之间反复横跳。

而 Esbuild 重写大多数转译工具之后,能够在多个编译阶段共用相似的 AST 结构,尽可能减少字符串到 AST 的结构转换,提升内存使用效率。

总结

单纯从编译性能的维度看,Esbuild 确实完胜世面上所有打包框架,差距甚至能在百倍之大:

耗时性能差异速度产物大小
esbuild0.11s1x1198.5 kloc/s0.97mb
esbuild (1 thread)0.40s4x329.6 kloc/s0.97mb
webpack 419.14s174x6.9 kloc/s1.26mb
parcel 122.41s204x5.9 kloc/s1.56mb
webpack 525.61s233x5.1 kloc/s1.26mb
parcel 231.39s285x4.2 kloc/s0.97mb

但这是有代价的,刨除语言层面的天然优势外,在功能层面它直接放弃对 less、stylus、sass、vue、angular 等资源的支持,放弃 MF、HMR、TS 类型检查等功能,正如作者所说:

This will involve saying "no" to requests for adding major features to esbuild itself. I don't think esbuild should become an all-in-one solution for all frontend needs\!

在我看来,Esbuild 当下与未来都不能替代 Webpack,它不适合直接用于生产环境,而更适合作为一种偏底层的模块打包工具,需要在它的基础上二次封装,扩展出一套既兼顾性能又有完备工程化能力的工具链,例如 SnowpackViteSvelteKitRemix Run 等。

总的来说,Esbuild 提供了一种新的设计思路,值得学习了解,但对大多数业务场景还不适合直接投入生产使用。

原文:https://segmentfault.com/a/1190000040243093
收起阅读 »

萌新贴

安卓开发同学,初来匝道,谢谢关照

安卓开发同学,初来匝道,谢谢关照

Event Loop 和 JS 引擎、渲染引擎的关系

安卓就是这样的架构,在主线程里面完成 ui 的更新,事件的绑定,其他逻辑可以放到别的线程,然后完成以后在消息队列中放一个消息,主线程不断循环的取消息来执行。 electron ui 架构 开发过 electron 应用的同学会知道,electron 中分为了...
继续阅读 »


安卓就是这样的架构,在主线程里面完成 ui 的更新,事件的绑定,其他逻辑可以放到别的线程,然后完成以后在消息队列中放一个消息,主线程不断循环的取消息来执行。



electron ui 架构


开发过 electron 应用的同学会知道,electron 中分为了主进程和渲染进程,window 相关的操作只能在主线程,由渲染进程向主进程发消息。


image.png


从上面两个案例我们可以总结出,所有的 ui 系统的设计,如果使用了多线程(进程)的架构,基本都是 ui 只能在一个线程(进程)中操作,由别的线程(进程)来发消息到这边来更新,如果多个线程,会有一个消息队列和 looper。消息队列的生产者是各个子线程(进程),消费者是主线程(进程)。


而且,不只是 ui 架构是这样,后端也大量运用了消息队列的概念,


后端的消息队列



后端因为不同服务负载能力不一样,所以中间会加一个消息队列来异步处理消息,和前端客户端的 ui 架构不同的是,后端的消息队列中间件会有多个消费者、多个队列,而 ui 系统的消息队列只有一个队列,一个消费者(主线程、主进程)


在一个线程做 ui 操作,其他线程做逻辑计算的架构很普遍,会需要一个消息队列来做异步消息处理。 网页中后来有了 web worker,也是这种架构的实现,但是最开始并不是这样的。


单线程


因为 javascript 最开始只是被设计用来做表单处理,那么就不会有特别大的计算量,就没有采用多线程架构,而是在一个线程内进行 dom 操作和逻辑计算,渲染和 JS 执行相互阻塞。(后来加了 web worker,但不是主流)


我们知道,JS 引擎只知道执行 JS,渲染引擎只知道渲染,它们两个并不知道彼此,该怎么配合呢?


答案就是 event loop。


宿主环境


JS 引擎并不提供 event loop(可能很多同学以为 event loop 是 JS 引擎提供的,其实不是),它是宿主环境为了集合渲染和 JS 执行,也为了处理 JS 执行时的高优先级任务而设计的机制。


宿主环境有浏览器、node、跨端引擎等,不同的宿主环境有一些区别:


注入的全局 api 不同


  • node 会注入一些全局的 require api,同时提供 fs、os 等内置模块

  • 浏览器会注入 w3c 标准的 api

  • 跨端引擎会注入设备的 api,同时会注入一套操作 ui 的 api(可能是对标 w3c 的 api 也可能不是)


event loop 的实现不同

上文说过,event loop 是宿主环境提供了,不同的宿主环境有不同的需要调度的任务,所以也会有不同的设计:



  • 浏览器里面主要是调度渲染和 JS 执行,还有 worker

  • node 里面主要是调度各种 io

  • 跨端引擎也是调度渲染和 JS 执行


这里我们只关心浏览器里面的 event loop。


浏览器的 event loop


check

浏览器里面执行一个 JS 任务就是一个 event loop,每个 loop 结束会检查下是否需要渲染,是否需要处理 worker 的消息,通过这种每次 loop 结束都 check 的方式来综合渲染、JS 执行、worker 等,让它们都能在一个线程内得到执行(渲染其实是在别的线程,但是会和 JS 线程相互阻塞)。



这样就解决了渲染、JS 执行、worker 这三者的调度问题。


但是这样有没有问题?


我们会在任务队列中不断的放新的任务,这样如果有更高优的任务是不是要等所有任务都执行完才能被执行。如果是“急事”呢?


所以这样还不行,要给 event loop 加上“急事”处理的快速通道,这就是微任务 micro tasks。


micro tasks


任务还是每次取一个执行,执行完检查下要不要渲染,处理下 worker 消息,但是也给高优先级的“急事”加入了插队机制,会在执行完任务之后,把所有的急事(micro task)全部处理完。


这样,event loop 貌似就挺完美的了,每次都会检查是否要渲染,也能更快的处理 JS 的“急事”。


requestAnimationFrame


JS 执行完,开始渲染之前会有一个生命周期,就是 requestAnimationFrame,在这里面做一些计算最合适了,能保证一定是在渲染之前做的计算。


image.png


如果有人问 requestAnimationFrame 是宏任务还是微任务,就可以告诉他:requestAnimationFrame 是每次 loop 结束发现需要渲染,在渲染之前执行的一个回调函数,不是宏微任务。


event loop 的问题


上文聊过,虽然后面加入了 worker,但是主流的方式还是 JS 计算和渲染相互阻塞,这样就导致了一个问题:


每一帧的计算和渲染是有固定频率的,如果 JS 执行时间过长,超过了一帧的刷新时间,那么就会导致渲染延迟,甚至掉帧(因为上一帧的数据还没渲染到界面就被覆盖成新的数据了),给用户的感受就是“界面卡了”。


什么情况会导致帧刷新拖延甚至帧数据被覆盖(丢帧)呢?每个 loop 在 check 渲染之前的每一个阶段都有可能,也就是 task、microtask、requestAnimationFrame、requestIdleCallback 都有可能导致阻塞了 check,这样等到了 check 的时候发现要渲染了,再去渲染的时候就晚了。


所以主线程 JS 代码不要做太多的计算(不像安卓会很自然的起一个线程来做),要做拆分,这也是为啥 ui 框架要做计算的 fiber 化,就是因为处理交互的时候,不能让计算阻塞了渲染,要递归改循环,通过链表来做计算的暂停恢复。


除了 JS 代码本身要注意之外,如果浏览器能够提供 API 就是在每帧间隔来执行,那样岂不是就不会阻塞了,所以后来有了 requestIdeCallback。


requestIdleCallback


requestIdleCallback 会在每次 check 结束发现距离下一帧的刷新还有时间,就执行一下这个。如果时间不够,就下一帧再说。


如果每一帧都没时间呢,那也不行,所以提供了 timeout 的参数可以指定最长的等待时间,如果一直没时间执行这个逻辑,那么就算拖延了帧渲染也要执行。



这个 api 对于前端框架来说太需要了,框架就是希望计算不阻塞渲染,也就是在每一帧的间隔时间(idle时间)做计算,但是这个 api 毕竟是最近加的,有兼容问题,所以 react 自己实现了类似 idle callback 的fiber 机制,在执行逻辑之前判断一下离下一帧刷新还有多久,来判断是否执行逻辑。


总结


总之,浏览器里有 JS 引擎做 JS 代码的执行,利用注入的浏览器 API 完成功能,有渲染引擎做页面渲染,两者都比较纯粹,需要一个调度的方式,就是 event loop。


event loop 实现了 task 和 急事处理机制 microtask,而且每次 loop 结束会 check 是否要渲染,渲染之前会有 requestAnimationFrames 生命周期。


帧刷新不能被拖延否则会卡顿甚至掉帧,所以就需要 JS 代码里面不要做过多计算,于是有了 requestIdleCallback 的 api,希望在每次 check 完发现还有时间就执行,没时间就不执行(这个deadline的时间也作为参数让 js 代码自己判断),为了避免一直没时间,还提供了 timeout 参数强制执行。


防止计算时间过长导致渲染掉帧是 ui 框架一直关注的问题,就是怎么不阻塞渲染,让逻辑能够拆成帧间隔时间内能够执行完的小块。浏览器提供了 idelcallback 的 api,很多 ui 框架也通过递归改循环然后记录状态等方式实现了计算量的拆分,目的只有一个:loop 内的逻辑执行不能阻塞 check,也就是不能阻塞渲染引擎做帧刷新。所以不管是 JS 代码宏微任务、 requestAnimationCallback、requestIdleCallback 都不能计算时间太长。这个问题是前端开发的持续性阵痛。


链接:https://juejin.cn/post/6961349015346610184

收起阅读 »

浏览器原理 之 页面渲染的原理和性能优化篇

001 浏览器的底层渲染页面篇 浏览器中的5个进程 浏览器在获取服务器的资源后将 html 解析成 DOM 树,CSS 计算成 CSSOM 树,将两者合成 render tree。具体如下浏览器根据 render tree 布局生成一个页面。需要理解的...
继续阅读 »

001 浏览器的底层渲染页面篇



浏览器中的5个进程



浏览器进程.jpg



浏览器在获取服务器的资源后将 html 解析成 DOM 树,CSS 计算成 CSSOM 树,将两者合成 render tree。具体如下浏览器根据 render tree 布局生成一个页面。需要理解的是浏览器从服务器获取回来的资源是一个个的字节码3C 6F 6E 62 ....等,浏览器会按照一套规范W3C将字节码最后解析一个个的代码字符串才成为我们看到的代码



浏览器加载资源的机制



  • 浏览器会开辟一个 GUI 渲染线程,自上而下执行代码,专门用于渲染渲染页面的线程。


遇到 CSS 资源



  • 遇到 <style> 内联标签会交给 GUI 渲染线程解析,但是遇到 <link> 标签会异步处理,浏览器会开辟一个 HTTP 请求处理的线程,GUI 渲染线程继续往下执行

  • 如果遇到@import 时,也会开辟一个新的 HTTP 请求线程处理,由于 @import 是同步的 GUI 渲染线程会阻塞等待请求的结果。



需要注意 chrome 中,同一个源下,最多同时开辟 6-7 和 HTTP 请求线程。



遇到 JS 资源


GUI渲染遇到script.jpg



最底部的线表示 GUI 线程的过程,渲染线程遇到不同情况下的script资源,有不同的处理。




  • 遇到 <script></script> 资源,默认是同步的。 此时 GUI 渲染线程会阻塞。等待 JS 渲染线程渲染结束后,GUI 线程才会继续渲染。

  • 如果遇到 <script async></script> 那么资源是异步的 async,浏览器也会开辟一个 HTTP 请求线程加载资源,这时 GUI 渲染线程会继续向下渲染,请求的资源回来后 JS 渲染线程开始执行,GUI 线程再次被阻塞。

  • 如果遇到 <script defer></script> 和 async 类似都会开辟一个新的HTTP线程,GUI 继续渲染。和 async 不一样的地方在于,defer 请求回来的资源需要等待 GUI 同步的代码执行结束后才执行 defer 请求回来的代码。



async 不存在资源的依赖关系先请求回来的先执行。defer 需要等待所有的资源请求回来以后,按照导入的顺序/依赖关系依次执行。



图片或音频



  • 遇到 <img/> 异步,也会开辟一个新的 HTTP 线程请求资源。GUI 继续渲染,当 GUI 渲染结束后,才会处理请求的资源。


需要注意的是:假设某些资源加载很慢,浏览器会忽略这些资源接着渲染后面的代码,在chrome浏览器中会先使用预加载器html-repload-scanner先扫描节点中的 src,link等先进行预加载,避免了资源加载的时间


浏览解析资源的机制



  • 浏览器是怎样解析加载回来的资源文件的? 页面自上而下渲染时会确定一个 DOM树CSSOM树,最后 DOM树CSSOM树 会被合并成 render 树,这些所谓的树其实都是js对象,用js对象来表示节点,样式,节点和样式之间的关系。


DOM 树



所谓的 DOM 树是确定好节点之间的父子、兄弟关系。这是 GUI 渲染线程自上而下渲染结束后生成的,等到 CSS 资源请求回来以后会生成 CSSOM 样式树。



DOM树.jpg


CSSOM 树



CSSOM(CSS Object Model), CSS 资源加载回来以后会被 GUI 渲染成 样式树



样式树.jpg


Render tree 渲染树



浏览器根据 Render tree 渲染页面需要经历下面几个步骤。注意 display:node 的节点不会被渲染到 render tree 中



renderTree.jpg



  • layout 布局,根据渲染树 计算出节点在设备中的位置和大小

  • 分层处理。按照层级定位分层处理

  • painting 绘制页面


layout2.jpg



上面的图形就是浏览器分成处理后的显示效果



002 浏览器的性能优化篇



前端浏览器的性能优化,可以从CRP: 关键渲染路径入手



DOM Tree



  • 减少 DOM 的层级嵌套

  • 不要使用被标准标签


CSSOM



  • 尽量不要使用 @import,会阻碍GUI渲染线程。

  • CSS 代码量少可以使用内嵌式的style标签,减少请求。

  • 减少使用link,可以减少 HTTP 的请求数量。

  • CSS 选择器链尽可能短,因为CSS选择器的渲染时从右到左的。

  • 将写入的 link 请求放入到<head></head> 内部,一开始就可以请求资源,GUI同时渲染。


其他资源



  • <script></script> 中的同步 js 资源尽可能的放入到页面的末尾,防止阻碍GUI的渲染。如果遇到 <script async/defer></script> 的异步资源,GUI 渲染不会中断,但是JS资源请求回来以后会中断 GUI 的渲染。

  • <img /> 资源使用懒加载,懒加载:第一次加载页面时不要加载图片,因为图片也会占据 HTTP 的数量。还可以使用图片 base64,代表图片。


003 回流和重绘篇



layout 阶段就是页面的回流期,painting 就是重绘阶段。第一次加载页面时必有一次回流和重绘。




  • 浏览器渲染页面的流程



浏览器会先把 HTML 解析成 DOM树 计算 DOM 结构;然后加载 CSS 解析成 CSSOM;最后将 DOM 和 CSSOM 合并生成渲染树 Render Tree,浏览器根据页面计算 layout(重排阶段);最后浏览器按照 render tree 绘制(painting,重绘阶段)页面。



重排(DOM 回流)



重排是指 render tree 某些 DOM 大小和位置发生了变化(页面的布局和几何信息发生了变化),浏览器重新渲染 DOM 的这个过程就是重排(DOM 回流),重排会消耗页面很大的性能,这也是虚拟 DOM 被引入的原因。



发生重排的情况



  • 第一次页面计算 layout 的阶段

  • 添加或删除DOM节点,改变了 render tree

  • 元素的位置,元素的字体大小等也会导致 DOM 的回流

  • 节点的几何属性改变,比如width, height, border, padding,margin等被改变

  • 查找盒子属性的 offsetWidth、offsetHeight、client、scroll等,浏览器为了得到这些属性会重排操作。

  • 框架中 v-if 操作也会导致回流的发生。

  • 等等


一道小题,问下面的代码浏览器重排了几次(chrome新版浏览器为主)?


box.style.width = "100px";
box.style.width = "100px";
box.style.position = "relative";
复制代码


你可能会觉得是3次,但是在当代浏览器中,浏览器会为上面的样式代码开辟一个渲染队列,将所有的渲染代码放入到队列里面,最后一次更新,所以重排的次数是1次。 问下面的代码会导致几次重排



box.style.width = "100px";
box.style.width = "100px";
box.offsetWidth;
box.style.position = "relative";
复制代码


答案是2次,因为 offsetWidth 会导致渲染队列的刷新,才可以获取准确的 offsetWidth 值。最后 position 导致元素的位子发生改变也会触发一次回流。所以总共有2次。



重绘



重绘是指 页面的样式发生了改变但是 DOM 结构/布局没有发生改变。比如颜色发生了变化,浏览器就会对需要的颜色进行重新绘制。



发生重绘的情况



  • 第一次页面 painting 绘制的阶段

  • 元素颜色的 color 发生改变


直接合成



如果我们更改了一个不影响布局和绘制的属性,浏览器的渲染引擎会跳过重排和重绘的阶段,直接合成




  • 比如我们使用了CSS 的 transform 属性,浏览器的可以师姐合成动画效果。


重排一定会引发重绘,但是重绘不一定会导致重排


重排 (DOM回流)和重绘吗?说一下区别



思路:先讲述浏览器的渲染机制->重排和重绘的概念->怎么减少重排和重绘。。。



区别



重排会导致 DOM结构 发生改变,浏览器需要重新渲染布局生成页面,但是重绘不会引发 DOM 的改变只是样式上的改变,前者的会消耗很大的性能。



如何减少重排和重绘





    1. 避免使用 table 布局,因为 table 布局计算的时间比较长耗性能;





    1. 样式集中改变,避免频繁使用 style,而是采用修改 class 的方式。





    1. 避免频繁操作 DOM,使用vue/react。





    1. 样式的分离读写。设置样式style和读取样式的offset等分离开,也可以减少回流次数。





    1. 将动画效果设计在文档流之上即 position 属性的 absolutefixed 上。使用 GPU 加速合成。




参考


《浏览器工作原理与实践》


Render Tree页面渲染


结束


浏览器原理篇:本地存储和浏览器缓存


Vue 原理篇:Vue高频原理详细解答


webpack原理篇: 编写loader和plugin


链接:https://juejin.cn/post/6976783503870410765

收起阅读 »

这些node开源工具你值得拥有

前言:文章的灵感来源于,社群中某大佬分享一个自己耗时数月维护的github项目 awesome-nodejs 。或许你跟我一样会有一个疑惑,github上其实已经有个同类型的awesome-nodejs库且还高达41k⭐,重新维护一个新的意义何在? 当你深入对...
继续阅读 »

前言:文章的灵感来源于,社群中某大佬分享一个自己耗时数月维护的github项目 awesome-nodejs 。或许你跟我一样会有一个疑惑,github上其实已经有个同类型的awesome-nodejs库且还高达41k⭐,重新维护一个新的意义何在? 当你深入对比后,本质上还是有差别的,一个是分类体系粒度更细,其次是对中文更友好的翻译维护,也包括了对国内一些优秀的开源库的收录。最后我个人认为通过自己梳理,也能更好地做复盘和总结



image.png


通过阅读 awesome-nodejs 库的收录,我抽取其中一些应用场景比较多的分类,通过分类涉及的应用场景跟大家分享工具


1.Git


1.1 应用场景1: 要实现git提交前 eslint 校验和 commit 信息的规范校验?


可以使用以下工具:



  • husky - 现代化的本地Git钩子使操作更加轻松

  • pre-commit - 自动在您的git储存库中安装git pre-commit脚本,该脚本在pre-commit上运行您的npm test。

  • yorkie 尤大改写的yorkie,yorkie实际是fork husky,让 Git 钩子变得简单(在 vue-cli 3x 中使用)


1.2 应用场景2: 如何通过node拉取git仓库?(可用于开发脚手架)


可以使用以下工具:



1.3 应用场景3: 如何在终端看git 流程图?


可以使用以下工具:



  • gitgraph - 在 Terminal 绘制 git 流程图(支持浏览器、React)。


1.4 其他



2.环境


2.1 应用场景1: 如何根据不同环境写入不同环境变量?


可以使用以下工具:



  • cross-env - 跨平台环境脚本的设置,你可以通过一个简单的命令(设置环境变量)而不用担心设置或者使用环境变量的平台。

  • dotenv - 从 .env文件 加载用于nodejs项目的环境变量。

  • vue-cli --mode - 可以通过传递 --mode 选项参数为命令行覆写默认的模式


3.NPM


3.1 应用场景1: 如何切换不同npm源?


可以使用以下工具:



  • nrm - 快速切换npm注册服务商,如npm、cnpm、nj、taobao等,也可以切换到内部的npm源

  • pnpm - 可比yarn,npm 更节省了大量与项目和依赖成比例的硬盘空间


3.2 应用场景2: 如何读取package.json信息?


可以使用以下工具:



3.3 应用场景3:如何查看当前package.json依赖允许的更新的版本


可以使用以下工具:



image.png


3.4 应用场景4:如何同时运行多个npm脚本



通常我们要运行多脚本或许会是这样npm run build:css && npm run build:js ,设置会更长通过&来拼接



可以使用以下工具:



  • npm-run-all - 命令行工具,同时运行多个npm脚本(并行或串行)


npm-run-all提供了三个命令,分别是 npm-run-all run-s run-p,后两者是 npm-run-all 带参数的简写,分别对应串行和并行。而且还支持匹配分隔符,可以简化script配置


或者使用



  • concurrently - 并行执行命令,类似 npm run watch-js & npm run watch-less但更优。(不过它只能并行)


3.5 应用场景5:如何检查NPM模块未使用的依赖。


可以使用以下工具:



  • depcheck - 检查你的NPM模块未使用的依赖。


image.png


3.6 其他:



  • npminstall - 使 npm install 更快更容易,cnpm默认使用

  • semver - NPM使用的JavaScript语义化版本号解析器。


关于npm包在线查询,推荐一个利器 npm.devtool.tech


image.png


4.文档生成


4.1 应用场景1:如何自动生成api文档?



  • docsify - API文档生成器。

  • jsdoc - API文档生成器,类似于JavaDoc或PHPDoc。


5.日志工具


5.1 应用场景1:如何实现日志分类?



  • log4js-nodey - 不同于Java log4j的日志记录库。

  • consola - 优雅的Node.js和浏览器日志记录库。

  • winston - 多传输异步日志记录库(古老)


6.命令行工具


6.1 应用场景1: 如何解析命令行输入?



我们第一印象会想到的是process.argv,那么还有什么工具可以解析吗?



可以使用以下工具:



  • minimist - 命令行参数解析引擎

  • arg - 简单的参数解析

  • nopt - Node/npm 参数解析


6.2 应用场景2:如何让用户能与命令行进行交互?


image.png


可以使用以下工具:



  • Inquirer.js - 通用可交互命令行工具集合。

  • prompts - 轻量、美观、用户友好的交互式命令行提示。

  • Enquirer - 用户友好、直观且易于创建的时尚CLI提示。


6.3 应用场景3: 如何在命令行中显示进度条?


image.png
可以使用以下工具:



6.4 应用场景4: 如何在命令行执行多任务?


image.png


可以使用以下工具:



  • listr - 命令行任务列表。


6.5 应用场景5: 如何给命令行“锦上添花”?


image.png


可以使用以下工具:



  • chalk - 命令行字符串样式美化工具。

  • ora - 优雅的命令行loading效果。

  • colors.js - 获取Node.js控制台的颜色。

  • qrcode-terminal - 命令行中显示二维码。

  • treeify - 将javascript对象漂亮地打印为树。

  • kleur - 最快的Node.js库,使用ANSI颜色格式化命令行文本。



感兴趣的童鞋可以参考树酱的从0到1开发简易脚手架,其中有实践部分工具



7.加解密



一般为了项目安全性考虑,我们通常会对账号密码进行加密,一般会通过MD5、AES、SHA1、SM,那开源社区有哪些库可以方便我们使用?



可以使用以下工具:



  • crypto-js - JavaScript加密标准库。支持算法最多

  • node-rsa - Node.js版Bcrypt。

  • node-md5 - 一个JavaScript函数,用于使用MD5对消息进行哈希处理。

  • aes-js - AES的纯JavaScript实现。

  • sm-crypto - 国密sm2, sm3, sm4的JavaScript实现。

  • sha.js - 使用纯JavaScript中的流式SHA哈希。


8.静态网站生成 & 博客



一键生成网站不香吗~ 基于node体系快速搭建自己的博客网站,你值得拥有,也可以作为组件库文档展示



image.png


可以使用以下工具:



  • hexo - 使用Node.js的快速,简单,强大的博客框架。

  • vuepress - 极简的Vue静态网站生成工具。(基于nuxt SSR)

  • netlify-cms - 基于Git的静态网站生成工具。

  • vitepress - Vite & Vue.js静态网站生成工具。


9.数据校验工具



数据校验,离我们最近的就是表单数据的校验,在平时使用的组件库比如element、iview等我们会看到使用了一个开源的校验工具async-validator , 那还有其他吗?



可以使用以下工具:



  • validator.js - 字符串校验库。

  • joi - 基于JavaScript对象的对象模式描述语言和验证器。

  • async-validator - 异步校验。

  • ajv - 最快的JSON Schema验证器

  • superstruct - 用简单和可组合的方式在JavaScript和TypeScript中校验数据。


10.解析工具


10.1应用场景1: 如何解析markdown?


可以使用以下工具:



  • marked - Markdown解析器和编译器,专为提高速度而设计。

  • remark - Markdown处理工具。

  • markdown-it -支持100%通用Markdown标签解析的扩展&语法插件。


10.2应用场景2: 如何解析csv?


可以使用以下工具:



  • PapaParse - 快速而强大的 CSV(分隔文本)解析器,可以优雅地处理大文件和格式错误的输入。

  • node-csv - 具有简单api的全功能CSV解析器,并针对大型数据集进行了测试。

  • csv-parser -旨在比其他任何人都快的流式CSV解析器。


10.3应用场景3: 如何解析xml?


可以使用以下工具:



最后



如果你喜欢这个库,也给作者huaize2020 一个star 仓库地址:awesome-nodejs



昨天看到一段话想分享给大家


对于一个研发测的日常:



  • 1.开始工作的第一件事,规划今日的工作内容安排 (建议有清晰的ToDolist,且按优先级排序)

  • 2.确认工作量与上下游关联风险(如依赖他人的,能否按时提供出来);有任何风险,尽早暴露

  • 3.注意时间成本、不是任何事情都是值得你用尽所有时间去做的,分清主次关系

  • 4.协作任务,明确边界责任,不要出现谁都不管,完成任务后及时同步给相关人

  • 5.及时总结经验,沉淀技术产出实现能力复用,同类型任务,不用从零开始,避免重复工作


往期热门文章📖:



  • 链接:https://juejin.cn/post/6972124481053523999

收起阅读 »

NodeJS使用Koa框架开发对接QQ登陆功能

开发准备 注册开发者账号 首先我们需要先去腾讯开发者平台认证注册成为个人开发者 输入网址:https://open.tencent.com/ 然后 点击 QQ开放平台——然后点击顶部的 应用管理会提示你登陆,使用自己的QQ账号登陆后,如果是新用户会提示你注...
继续阅读 »

开发准备



  • 注册开发者账号


首先我们需要先去腾讯开发者平台认证注册成为个人开发者 输入网址:https://open.tencent.com/ 然后 点击 QQ开放平台——然后点击顶部的 应用管理会提示你登陆,使用自己的QQ账号登陆后,如果是新用户会提示你注册成为开发者,这里我已经注册并认证成功了,所以我就可以直接创建应用了,我这里是网站使用的,所以我就创建的网站Web'应用,APP小程序申请移动端的进行了 下面看我的截图
image.png


image.png


image.png


image.png


image.png


到这一步基本上就创建完成了一个应用,会有7天的等待,官方会审核检查你填写的信息是否准确,如果都是真实有效的用不了几天审核通过了,就申请到了appid和appkey的。



  • 接入QQ登录时,网站需要不停的和Qzone进行交互,发送请求和接受响应。



    1. 对于PC网站:请在你的服务器上ping graph.qq.com ,保证连接畅通。



  • 2.移动应用无需此步骤


放置“QQ登录”按钮_OAuth2.0


image.png


这里说一下我碰到的几个坑



  1. 网站名称我没有填写我到时候域名备案写的网站名称,于是出了一次错误被驳回

  2. 网站的备案号格式:(地区)蜀ICP备XXXXX号 我填写的格式不正确又一次被驳回

  3. 就是大家可能都比较容易犯错误的,回调地址的填写,刚开始我一直卡这里,总共的填写后面我也会反复给大家强调,在这里就是Api接口地址可以这样去理解,(目前我这样理解,有更好意见的欢迎反馈评论给我) 如我的网址是:lovehaha.cn 我的api接口是 lovehaha.cn/test 那么我在后端写了一个专门处理腾讯qq返回的数据的路由,是 /qqauthor 那么我的回调地址就是: lovehaha/test/qqauthor

  4. 审核的时候,网站需要可以访问,同时需要查看QQ图标的位置是否正确,应在登陆页或首页,同时回调地址的路由可以正常收到腾讯返回的数据。


代码部署


前面都顺顺利利成功了后,需要到开发者平台应用管理哪里先填写个QQ调试账号然后就开始我们的代码配置部署吧!


后端使用的是Node的Koa框架 框架的安装配置很简单(首先肯定需要大家有node环境 我这里是v14.16.1版本的,安装了Node 版本大于10还是几就自带npm了)


命令:



  • npm install koa-generator -g (全局安装koa-generator是koa框架的生成器)

  • koa 文件名称 创建项目

  • npm install 安装依赖包

  • npm run dev 就可以运行了默认应该是3000端口访问


在这里我就简单介绍一下,下面介绍我的后端代码处理逻辑


整体逻辑:



  • 获取Authorization Code

  • 通过Authorization Code 获取 Access Token (Code ————> 换 Token)

  • 通过Access Token 获取 用户的Openid

  • 最后通过获取的 Token 和 Openid 获取用户的信息


PS:(可选)权限自动续期,获取Access Token
Access_Token的有效期默认是3个月,过期后需要用户重新授权才能获得新的Access_Token。本步骤可以实现授权自动续期,避免要求用户再次授权的操作,提升用户体验。(官网文档有教程,我这里没用)

/**
* QQ登陆授权判断
* code 是前端点击QQ登陆按钮图标然后请求,然后请求这个回调地址 返回的
* 我这里就可以取到了
*/
router.get('/qqauthor', async (ctx, next) => {
const { code } = ctx.request.query
console.log("code", code) // 打印查看是否获取到
let userinfo
let openid
let item
if (code) {

let token = await QQgetAccessToken(code) // 获取token 函数 返回 token 并存储
console.log('返回的token',token)
openid = await getOpenID(token) // 获取 Openid 函数 返回 Openid 并存储
console.log('返回的openid', openid)
if (openid && token) {
userinfo = await QQgetUserInfO(token, openid) // 如果都获取到了,获取用户信息
console.log("返回的结果", userinfo)
}

}

// 封装:
if (userinfo) {
let obj = {
nickname: userinfo.nickname,
openid: openid,
gender: userinfo.gender === '男' ? 1 : 2,
province: userinfo.province,
city: userinfo.city,
year: userinfo.year,
avatar: userinfo.figureurl_qq_2 ? userinfo.figureurl_qq_2 : userinfo.figureurl_qq_1
}
console.log('封装的obj', obj)
item = await register({ userInfo: obj, way: 'qq' })
/** 从这里到封装 都是改变我获取的用户信息存储到数据库里面,根据数据库的存储,创建新用户,如果有
* 用户我就查询并获取用户的id 然后返回给前端 用户的 id
*/
ctx.state = {
id: item.data.id
}
await ctx.render('login', ctx.state) // 如果获取到用户 id 返回 前端一个页面并携带参数 用户ID
}
})


/**
*
* @param {string} code
* @param {string} appId 密钥
* @param {string} appKey key
* @param {string} state client端的状态值。用于第三方应用防止CSRF攻击,成功授权后回调时会原样带回
* @param {string} redirectUrl (回调地址)
* @returns
*/
async function QQgetAccessToken(code) {
let result
let appId = '申请成功就有了'
let appKey = '申请成功就有了'
let state = '自定义'
let redirectUrl = 'https://xxxxx/qqauthor' // 回调地址是一样的 我这里就是我的获取登陆接口的地址

// 安装了 axios 请求 接口 获取返回的token
await axios({
url:`https://graph.qq.com/oauth2.0/token?grant_type=authorization_code&client_id=${appId}&client_secret=${appKey}&code=${code}&state=${state}&redirect_uri=${redirectUrl}&fmt=json`,
method:'GET'
}).then(res =>{
console.log(res.data)
result = res.data.access_token
// res.data.access_token
}).catch(err => {
console.log(err)
result = err
})

return result
}


/**
* 根据Token获取Openid
* @param {string} accessToken token 令牌
* @returns
*/
async function getOpenID(accessToken) {
let result

// 跟上面差不多就不解释了
await axios({
url: `https://graph.qq.com/oauth2.0/me?access_token=${accessToken}&fmt=json`,
method: 'GET'
}).then(res => {
// 获取到了OpenID
result = res.data.openid
}).catch(err => {
result = err
})

return result
}


/**
* 根据Openid 和 Token 获取用户的信息
* @param {string} accessToken
* @param {string} openid
* @returns
*/
async function QQgetUserInfO (token, openid) {
let result
await axios({
url: `https://graph.qq.com/user/get_user_info?access_token=${token}&oauth_consumer_key=101907569&openid=${openid}`,
method: 'GET'
}).then(res => {
result = res.data
}).catch(err => {
console.log(err)
result = err
})

return result
}

前后端调试

前端我这里使用的是Vue2.0的语法去写的上login.vue 页面代码

<template>
<div class="icon" @click="qqAuth">
<img src="@/static/img/qq48-48.png" alt="" />
<span>QQ账号登陆</span>
</div>
</template>

// 这里我就直接写
<script>
export default {
methods: {
// 简单粗暴
qqAuth () {
const appId = 申请就有了
const redirectUrl = 'https://xxx/qqauthor' // 回调地址 我这里路由是/qqauthor 你的是什么填什么
const state = 'ahh' // 可自定义
const display = '' // 可不传仅PC网站接入时使用。用于展示的样式。
const scope = '' // 请求用户授权时向用户显示的可进行授权的列表。 可不填
const url = `
https://graph.qq.com/oauth2.0/authorize?
response_type=code&
client_id=${appId}&
redirect_uri=${redirectUrl}
&state=${state}
&scope=${scope}
`
window.open(url, '_blank') // 开始访问请求 ,这个时候用户点击登陆,就会跳转到qq登陆界面,
登陆后会返回code 到最开始我们写好的后端接口也就是回调地址哪里,开始操作
},
}
</script>

这个时候用户点击登陆触发qqAuth事件,就会跳转到qq登陆界面,登陆成功后会返回code到最开始我们写好的后端接口也就是回调地址哪里,我们把获取Code操作最后获取用户信息存储并返回一个登陆成功的页面携带用户的ID,这个返回的页面,我写了一个 a 标签 携带着 返回的 用户ID


image.png


我这里的href地址是我自己可以访问并且在线上真实的地址,跳转到了首页,我在这个页面的Mounth 写了一个事件
页面加载的时候获取当前页面的URL如果,并且分割URL字符串,判断是否存在ID,存在ID证明是用户登陆成功返回的,获取当前用户的ID,然后再通过ID请求后端,查找到了用户的数据,缓存,完成整个QQ登陆逻辑功能
image.png


完成开发


开发完成了就上线了,但肯定我的这个是存在更优的解决办法,我记录下来,供大家提供一种思路,希望大家可以喜欢,返回页面是使用的Koa的njk框架,比较方便。


链接:https://juejin.cn/post/6977399909532041247
收起阅读 »

Docker 快速部署 Node express 项目

前言 本文章讲解如何简单快速部署 node API 项目。可作为docker入门学习。 Node 项目基于 express+sequelize 框架。 数据库使用 mysql。 Docker 安装 Docker 官方下载地址:docs.docker.com/g...
继续阅读 »

前言


本文章讲解如何简单快速部署 node API 项目。可作为docker入门学习。


Node 项目基于 express+sequelize 框架。


数据库使用 mysql。


Docker 安装


Docker 官方下载地址:docs.docker.com/get-docker


检查 Docker 安装版本:$ docker --version


Dockerfile



Dockerfile 是一个用来构建镜像的文本文件,文本内容包含了一条条构建镜像所需的指令和说明。

Dockerfile 学习地址:http://www.runoob.com/docker/dock…



在项目根目录下编写 Dockerfile 文件:


7231624506430_.pic.jpg


FROM node:12.1    :基于 node:12.1 的定制镜像
LABEL maintainer="kingwyh1993@163.com" :镜像作者
COPY . /home/funnyService :制文件到容器里指定路径
WORKDIR /home/funnyService :指定工作目录为,RUN/CMD 在工作目录运行
ENV NODE_ENV=production :指定环境变量 NODE_ENV 为 production
RUN npm install yarn -g :安装 yarn
RUN yarn install :初始化项目
EXPOSE 3000 :声明端口
CMD [ "node", "src/app.js" ] :运行 node 项目 `$ node src/app.js`

注:CMD 在docker run 时运行。RUN 是在 docker build。
复制代码

docker-compose



Compose 是用于定义和运行多容器 Docker 应用程序的工具。通过 Compose,您可以使用 YML 文件来配置应用程序需要的所有服务。然后,使用一个命令,就可以从 YML 文件配置中创建并启动所有服务。

docker-compose 学习地址:http://www.runoob.com/docker/dock…



在根目录下编写 docker-compose.yml 文件:


7241624516284_.pic.jpg


container_name: 'funny-app'  :指定容器名称 funny-app
build: . :指定构建镜像上下文路径,依据 ./Dockerfile 构建镜像
image: 'funny-node:2.0' :指定容器运行的镜像,名称设置为 funny-node:2.0
ports: :映射端口的标签,格式为 '宿主机端口:容器端口'
- '3000:3000' :这里 node 项目监听3000端口,映射到宿主机3000端口

复制代码

本地调试


项目根目录下执行 $ docker-compose up -d


查看构建的镜像 $ docker images 检查有上述 node、funny-node 镜像则构建成功


查看运行的容器 $ docker ps 检查有 funny-app 容器则启动成功


调试接口 http://127.0.0.1:3000/test/demo 成功:


image.png


服务器部署运行


在服务器 git pull 该项目


执行 $ docker-compose up -d


使用 $ docker images $ docker ps 检查是否构建和启动成功


调试接口 http://服务器ip:3000/test/demo



链接:https://juejin.cn/post/6977256058725072932

收起阅读 »

[react-native]JSX和RN样式以及和web的不同之处

全屏状态栏 import { View, Text, Image, StatusBar } from 'react-native' <StatusBar backgroundColor="transparent" translucent={ true }...
继续阅读 »

全屏状态栏


import { View, Text, Image, StatusBar } from 'react-native'
<StatusBar backgroundColor="transparent" translucent={ true } />


JSX:React中写组件的代码格式, 全称是JavaScript xml


import React from 'react'
import { View, Text } from 'react-native'

const App = () => <View>
<Text>JSX Hello World</Text>
</View>

export default App


RN样式(主要讲解和web开发的不同之处)


image.png


#屏幕宽度和高度
import { Dimensions } from 'react-native'
const screenWidth = Math.round(Dimensions.set('window').width)
const screenHeight = Math.round(Dimensions.get('window').height)

#变换
<Text style={{ transform: [{translateY: 300}, {scale: 2}] }}>变换</Text>


标签



  1. View

  2. Text

  3. TouchableOpacity

  4. Image

  5. ImageBackground

  6. TextInput

  7. 其他 =>

    1. button

    2. FlatList

    3. ScrollView

    4. StatusBar

    5. TextInput




View



  1. 相当于以前web中的div

  2. 不支持设置字体大小, 字体颜色等

  3. 不能直接放文本内容

  4. 不支持直接绑定点击事件(一般使用TouchableOpactiy 来代替)


Text



  1. 文本标签,可以设置字体颜色、大小等

  2. 支持绑定点击事件


TouchableOpacity (onpress => 按下事件 onclick=> 点击事件)


可以绑定点击事件的块级标签



  1. 相当于块级的容器

  2. 支持绑定点击事件 onPress

  3. 可以设置点击时的透明度


import React from 'react'
import {TouchableOpacity, Text} from 'react-native'

const handlePress = () => {
alert('111')
}

const App = () =>
<TouchableOpacity activeOpacity={0} onPress={ handlePress }>
<Text>点击事件</Text>
</TouchableOpacity>

export default App


Image图片渲染


1.渲染本地图片时


<Image source={ require("../gril.png") } />


2.渲染网络图片时, 必须加入宽度和高度


<Image source={{ uri: 'https://timgsa.baidu.com/xxx.png }} style={{ width: 200, height: 300 }} />


3.在android上支持GIF和WebP格式图片


默认情况下Android是不支持gif和webp格式的, 只需要在 android/app/build.gradle 文件中根据需要手动添加


以下模块:


dependencies {
// 如果你需要支持android4.0(api level 14)之前的版本
implementation 'com.facebook.fresco:animated-base-support:1.3.0'

// 如果你需要支持GIF动画
implementation 'com.facebook.fresco:animated-gif:2.0.0'

// 如果你需要支持webp格式,包括webp动图
implementation 'com.facebook.fresco:animated-webp:2.1.0'
implementation 'com.facebook.fresco:webpsupport:2.0.0'

// 如果只需要支持webp格式而不需要动图
implementation 'com.facebook.fresco:websupport:2.0.0'
}


ImageBackground


一个可以使用图片当做背景的容器,相当于以前的 div + 背景图片


import React from 'react'
import { Text, ImageBackground } from 'react-native'

const App = () =>
<ImageBackground source={require('./assets/logo.png')} style={{ width: 200, height: 200 }}>
<Text>Inside</Text>
</ImageBackground>

export default App


TextInput输入框组件


可以通过 onChangeText 事件来获取输入框的值
语法:



  1. 组件

  2. 插值表达式

  3. 状态state

  4. 属性props

  5. 调试

  6. 事件

  7. 生命周期


import React from 'react'
import { TextInput } from 'react-native'

const handleChangeText = (text) => {
alert(text)
}

#onChangeText => 获取输入的值
const App = () => <TextInput onChangeText={ handleChangeText } />

export default App


花括号{}里面可以直接添加JS代码的


组件: 函数组件, 类组件


函数组件



  1. 没有state(通过hooks可以有)

  2. 没有生命周期(通过hooks可以有)

  3. 适合简单的场景


类组件



  1. 适合复杂的场景

  2. 有state

  3. 有生命周期


属性props (父子组件的传递)和插槽slot


import React from 'react'
import { View, Text } from 'react-native'

const App = () => (
<View>
<Text>==========</Text>
<Sub color="red">
<View><Text>1234</Text></View>
</Sub>
<Text>==========</Text>
</View>
)

// 子组件 props
const Sub = (props) =>
(<View><Text style={{ color: props.color }}>{ props.children }</Text></View>)

// 插槽类似于 vue中的slot
export default App



人懒,不想配图,都是自己的博客内容(干货),望能帮到大家




链接:https://juejin.cn/post/6977283223499833358

收起阅读 »

学习一下Electron,据说很简单

Electron怎么玩 真的很简单的,面向百度编程,找寻前辈的足迹,真的很容易的。😄 直接点,开整 首先安装Electron,但是有个坑 坑就是安装卡住了,没事有办法: npm config set registry=https://registry.npm....
继续阅读 »

Electron怎么玩


真的很简单的,面向百度编程,找寻前辈的足迹,真的很容易的。😄


直接点,开整


首先安装Electron,但是有个坑


坑就是安装卡住了,没事有办法:


npm config set registry=https://registry.npm.taobao.org/
npm config set ELECTRON_MIRROR=http://npm.taobao.org/mirrors/electron/


第一行相信大家都做了。


第二行很关键,如果不设置的话,他会在最后卡住,一直在加载,也不知道搞什么呢。🤦‍


然后在项目的根目录下创建main.js

/* main.js */
const { app, BrowserWindow } = require('electron')
const path = require('path')
const ipc = require('electron').ipcMain
const http = require('http');
const qs = require("qs")
const os = require('os');

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow;
let server;

const initServer = () => {
server = http.createServer(function (request, response) {
// 定义了一个post变量,用于暂存请求体的信息
let post = '';
// 通过req的data事件监听函数,每当接受到请求体的数据,就累加到post变量中
//当有数据请求时触发
request.on('data', function (data) {
post += data;
});
// 在end事件触发后,通过querystring.parse将post解析为真正的POST请求格式,然后向客户端返回。
request.on('end', function () {
//解析为post对象
post = JSON.parse(post);
//将对象转化为字符串
response.writeHead(200, { 'Content-Type': 'text-plain' });
response.end('{"status":200}\n');
mainWindow.webContents.send("flightdata", post)
});
}).listen(8124);
}


const createWindow = () => {
// Create the browser window.
mainWindow = new BrowserWindow({
fullscreen: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});

// and load the index.html of the app.
mainWindow.loadFile("./build/index.html");

// mainWindow.maximize()
mainWindow.removeMenu()
// mainWindow.webContents.openDevTools()
mainWindow.webContents.openDevTools({mode:'right'});
// Emitted when the window is closed.
mainWindow.on('closed', () => {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
mainWindow = null;
});
};

const initApp = () => {
createWindow();
initServer();
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', initApp);

// Quit when all windows are closed.
app.on('window-all-closed', () => {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit();
}
});

app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) {
createWindow();
}
});

这里面大部分逻辑你不用考虑,我以后的文章会讲到,而你只需要知道一点就行。

那就是我在这个mian中指定了一个静态网页,巧了!位置就在我们打包文件夹build下🤭。

// and load the index.html of the app.
mainWindow.loadFile("./build/index.html");

然后配置package.json

{
...
"main": "main.js",
"homepage": "./",
...
}

分析:

main:配置刚才我们创建的Electron的入口文件main.js homepage:如果不配置的话,就会。。,em~~~~就会。。算了贴代码吧

...
const publicUrlOrPath = getPublicUrlOrPath(
process.env.NODE_ENV === 'development',
require(resolveApp('package.json')).homepage,
process.env.PUBLIC_URL
);
...

这几句代码就说明webpack会通过package中配置的homepage来设置PUBLIC_URL,so,那么配置homepage就很有必要。
否则,会白屏的!!!


对了还有个大坑,一定注意


如果你用的是react-router提供的BrowserRouter,那你会蒙圈的,因为什么都不会显示,顶多有个你事先安排好的“404”页面,就好像在用浏览器直接访问地址为https://****/index.htmlhistory模式根本不起作用,我猜这是浏览器独门绝技,electron还没支持,我猜的,不一定对。


所以一定要用hash模式

<HashRouter getUserConfirmation={this.getConfirmation}>
...
</HashRouter>

最后我们再配置一下启动脚本

/* package.json */
"scripts": {
...
"electron": "electron ."
...
},

看下效果吧

结语

这么一来,“中用”的Moderate就初步集成了Electron,直接一行命令就能打包成一个pc和mac端都能用的应用,美滋滋,但请掘友们相信,这只是第一部分🤭,接下来还有很多东西要补上。


原文:https://juejin.cn/post/6977349336044666917 收起阅读 »

Vue基操会了,还有必要学React么?

React前言 很高兴你能来到这里学习React.js技术,这是本专栏的第一节,主要介绍一下小伙伴们常问的一些问题,虽然废话很多,但是还是建议你可以花几分钟看一下。 React简介 首先不能否认React.js是全球最火的前端框架(Facebook推出的前端框...
继续阅读 »

React前言


很高兴你能来到这里学习React.js技术,这是本专栏的第一节,主要介绍一下小伙伴们常问的一些问题,虽然废话很多,但是还是建议你可以花几分钟看一下。


React简介


首先不能否认React.js是全球最火的前端框架(Facebook推出的前端框架),国内的一二线互联网公司大部分都在使用React进行开发,比如阿里美团百度去哪儿网易知乎这样的一线互联网公司都把React作为前端主要技术栈。
React的社区也是非常强大的,随着React的普及也衍生出了更多有用的框架,比如ReactNative和React VR。React从13年开始推广,现在已经推出18.x.x版本,性能和易用度上,都有很大的提升。


React优点总结




  • 生态强大:现在没有哪个框架比React的生态体系好的,几乎所有开发需求都有成熟的解决方案。




  • 上手简单: 你甚至可以在几个小时内就可以上手React技术,但是他的知识很广,你可能需要更多的时间来完全驾驭它。




  • 社区强大:你可以很容易的找到志同道合的人一起学习,因为使用它的人真的是太多了。




React和Vue的对比


这是前端最火的两个框架,虽然说React是世界使用人数最多的框架,但是就在国内而言Vue的使用者很有可能超过React。两个框架都是非常优秀的,所以他们在技术和先进性上不相上下。


那个人而言在接到一个项目时,我是如何选择的那?React.js相对于Vue.js它的灵活性和协作性更好一点,所以我在处理复杂项目或公司核心项目时,React都是我的第一选择。而Vue.js有着丰富的API,实现起来更简单快速,所以当团队不大,沟通紧密时,我会选择Vue,因为它更快速更易用。(需要说明的是,其实Vue也完全胜任于大型项目,这要根据自己对框架的掌握程度来决定,以上只是站在我的知识程度基础上的个人总结)


我们将学到什么?


我们将学习所有 React 的基础概念,其中又分为三个部分:



  • 编写组件相关:包括 JSX 语法、Component、Props

  • 组件的交互:包括 State 和生命周期

  • 组件的渲染:包括列表和 Key、条件渲染

  • 和 DOM & HTML 相关:包括事件处理、表单。


前提条件


我们假设你熟系 HTML 和 JavaScript,但即使你是从其他编程语言转过来的,你也能看懂这篇教程。我们还假设你对一些编程语言的概念比较熟悉,比如函数、对象、数组,如果对类了解就更好了。


环境准备


首先准备 Node 开发环境,访问 Node 官方网站下载并安装。打开终端输入如下命令检测 Node 是否安装成功:


node -v # v10.16.0


npm -v # 6.9.0


注意


Windows 用户需要打开 cmd 工具,Mac 和 Linux 是终端。


如果上面的命令有输出且无报错,那么代表 Node 环境安装成功。接下来我们将使用 React 脚手架 -- Create React App(简称 CRA)来初始化项目,同时这也是官方推荐初始化 React 项目的最佳方式。


在终端中输入如下命令:



npx create-react-app my-todolist



等待命令运行完成,接着输入如下命令开启项目:



cd my-todolist && npm start



CRA 会自动开启项目并打开浏览器


🎉🎉🎉 恭喜你!成功创建了第一个 React 应用!


现在 CRA 初始化的项目里有很多无关的内容,为了开始接下来的学习,我们还需要做一点清理工作。首先在终端中按 ctrl + c 关闭刚刚运行的开发环境,然后在终端中依次输入如下的命令:


进入 src 目录

cd src


如果你在使用 Mac 或者 Linux:

rm -f *


或者,你在使用 Windows:

del *


然后,创建我们将学习用的 JS 文件

如果你在使用 Mac 或者 Linux:

touch index.js


或者,你在使用 Windows

type nul > index.js


最后,切回到项目目录文件夹下

cd ..
此时如果在终端项目目录下运行 npm start 会报错,因为我们的 index.js 还没有内容,我们在终端中使用 ctrl +c 关闭开发服务器,然后使用编辑器打开项目,在刚刚创建的 index.js 文件中加入如下代码:


import React from "react";
import ReactDOM from "react-dom";

class App extends React.Component {
render() {
return <div>Hello, World</div>;
}
}

ReactDOM.render(<App />, document.getElementById("root"));
复制代码

我们看到 index.js 里面的代码分为三个部分。


首先是一系列导包,我们导入了 react 包,并命名为 React,导入了 react-dom 包并命名为 ReactDOM。对于包含 React 组件(我们将在之后讲解)的文件都必须在文件开头导入 React。


然后我们定义了一个 React 组件,命名为 App,继承自 React.Component,组件的内容我们将会在后面进行讲解。


接着我们使用 ReactDOM 的 render 方法来渲染刚刚定义的 App 组件,render方法接收两个参数,第一个参数为我们的 React 根级组件,第二个参数接收一个 DOM 节点,代表我们将把和 React 应用挂载到这个 DOM 节点下,进而渲染到浏览器中。


注意


上面代码的三个部分中,第一部分和第三部分在整篇教程中是不会修改的,同时在编写任意 React 应用,这两个部分都是必须的。后面所有涉及到的代码修改都是关于第二部分代码的修改,或者是在第一部分到第三部分之间插入或删除代码。


JSX 语法


首先我们来看一下 React 引以为傲的特性之一 -- JSX。它允许我们在 JS 代码中使用 XML 语法来编写用户界面,使得我们可以充分的利用 JS 的强大特性来操作用户界面。


一个 React 组件的 render 方法中 return 的内容就为这个组件所将渲染的内容。比如我们现在的代码:


render() {
return <div>Hello, World</div>;
}


这里的 <div>Hello, World</div> 是一段 JSX 代码,它最终会被 Babel转译成下面这段 JS 代码:


React.createElement(
'div',
null,
'Hello, World'
)


React.createElement() 接收三个参数:



  • 第一个参数代表 JSX 元素标签。

  • 第二个参数代表这个 JSX 元素接收的属性,它是一个对象,这里因为我们的 div 没有接收任何属性,所以它是 null。

  • 第三个参数代表 JSX 元素包裹的内容。


React.createElement() 会对参数做一些检查确保你写的代码不会产生 BUG,它最终会创建一个类似下面的对象:


{
type: 'div',
props: {
children: 'Hello, World'
}
};


这些对象被称之为 “React Element”。你可以认为它们描述了你想要在屏幕上看到的内容。React 将会接收这些对象,使用它们来构建 DOM,并且对它们进行更新。


App 组件最终返回这段 JSX 代码,所以我们使用 ReactDOM 的 render 方法渲染 App 组件,最终显示在屏幕上的就是 Hello, World" 内容。


JSX 作为变量使用


因为 JSX 最终会被编译成一个 JS 对象,所以我们可以把它当做一个 JS 对象使用,它享有和一个 JS 对象同等的地位,比如可以将其赋值给一个变量,我们修改上面代码中的 render 方法如下:


render() {
const element = <div>Hello, World</div>;
return element;
}


保存代码,我们发现浏览器中渲染的内容和我们之前类似。


在 JSX 中使用变量


我们可以使用大括号 {} 在 JSX 中动态的插入变量值,比如我们修改 render 方法如下:


render() {
const content = "World";
const element = <div>Hello, {content}</div>;
return element;
}


JSX 中使用 JSX


我们可以在 JSX 中再包含 JSX,这样我们编写任意层次的 HTML 结构:


render() {
const element = <li>Hello, World</li>
return (
<div>
<ul>
{element}
</ul>
</div>
)
}


JSX 中添加节点属性
我们可以像在 HTML 中一样,给元素标签加上属性,只不过我们需要遵守驼峰式命名法则,比如在 HTML 上的属性 data-index 在 JSX 节点上要写成 dataIndex。


const element = <div dataIndex="0">Hello, World</div>;


注意


在 JSX 中所有的属性都要更换成驼峰式命名,比如 onclick 要改成 onClick,唯一比较特殊的就是 class,因为在 JS 中 class 是保留字,我们要把 class 改成 className 。


const element = <div className="app">Hello, World</div>;


实战


在编辑器中打开 src/index.js ,对 App 组件做如下改变:


class App extends React.Component {
render() {
const todoList = ["给npy的前端秘籍", "fyj", "天天的小迷弟", "仰望毛毛大佬"];
return (
<ul>
<li>Hello, {todoList[0]}</li>
<li>Hello, {todoList[1]}</li>
<li>Hello, {todoList[2]}</li>
<li>Hello, {todoList[3]}</li>
</ul>
);
}
}


可以看到,我们使用 const 定义了一个 todoList 数组常量,并且在 JSX 中使用 {} 进行动态插值,插入了数组的四个元素。


提示


无需关闭刚才使用 npm start 开启的开发服务器,修改代码后,浏览器中的内容将会自动刷新!


你可能注意到了我们手动获取了数组的四个值,然后逐一的用 {} 语法插入到 JSX 中并最终渲染,这样做还比较原始,我们将在后面列表和 Key小节中简化这种写法。


在这一小节中,我们了解了 JSX 的概念,并且实践了相关的知识。我们还提出了组件的概念,但是并没有深入讲解它,在下一小节中我们将详细地讲解组件的知识。


总结


专栏第一篇与大家一起学习了React基本知识、后续还会有更精彩的哇、一起加油哇~



作者:给npy的前端秘籍
链接:https://juejin.cn/post/6974651532637634568

收起阅读 »

React 毁了 Web 开发(转载)

本文并不是为了引发争论或者让大家非要争一个好坏。我仔细阅读该文章之后,发现里面提出的问题是我们常常忽视并且对于构建可持续项目发展的非常重要的问题。 不论是小兵还是leader都应该持续的去注重这些东西。 以下是正文翻译:原文来自:medium.com/buil...
继续阅读 »

本文并不是为了引发争论或者让大家非要争一个好坏。我仔细阅读该文章之后,发现里面提出的问题是我们常常忽视并且对于构建可持续项目发展的非常重要的问题。


不论是小兵还是leader都应该持续的去注重这些东西。


以下是正文翻译:原文来自:medium.com/building-pr…


以下为译文:


React 是一个很好的库,对于 Web 开发很重要,因为它引入了声明式与反应式模板,这在当时是每个人都需要的范式转变。当时(也就是 6~7 年前),我们面临着需要的范式转变的问题,而 React 很好地解决了这个问题。


另外提一句,在 React 之前,Ember 也解决了同样的问题。然而,它的性能并不那么好,而且该框架规定了太多东西,远不如 React。



然而,React 在开始流行之后,发展变得一团糟。React 社区中开启了一种新趋势,一切都围绕着炒作、新奇和创造新范式的转变。每隔几个月就会涌现一些新的库,为我们应该如何编写 React Web 应用程序设定新标准,同时还会解决大部分已经解决的问题。


下面,我们以 " 状态管理 " 为例来说明。由于 React 缺少传统的依赖注入系统(DI 是通过组件组合实现的),所以社区不得不自己解决这个问题。然而,后来就变成了一遍又一遍地解决这个问题,每年都会带来一套新的标准。



React 只是一个渲染引擎,在常见的 Web 应用程序中,你需要使用很多库来构建项目的框架,例如数据层、状态管理、路由、资产捆绑器等。


React 背后的生态系统给了你太多这样的选择,而这个技术栈也因此而变得支离破碎,并引发了著名的 "Javascript 疲劳 "。


此外,还涌现了一种趋势:" 框架比较热潮 "。各个 JS 框架之间经常会展开渲染速度以及内存占用等属性的比较。其实,这些因素在大多数情况下根本无关紧要,因为应用的速度缓慢并不是由于 JS 框架的速度过慢而引起的,而是因为糟糕的代码。


然而,就像世界上所有的趋势一样,这个趋势有点过,甚至危及了新一代的 Web 开发人员。我就在想,为什么一个库能成为 Web 开发人员简历中最耀眼的技术?更糟糕的是,它甚至算不上一个库,只不过是库中的一个模块。人们常常将 React hook 视为一项 " 技术 ",甚至可以与代码重构或代码审查等实际技术相提并论。


认真地说,我们什么时候才能停止吹捧这种技术?


比如说,你为什么不告诉我,你知道:


如何编写简单易读的代码


不要向我炫耀你掌握了某个 GitHub 上获得星星数最多的库;而是给我展示一两个优秀的代码片段。


如何管理状态


不要讨论某个流行的状态管理库,而是告诉我为什么 " 数据应该下降而动作应该上升 "。或者说,为什么应该在创建的地方修改状态,而不是组件层次结构中更深的地方。


如何测试代码


不要告诉我你知道 Jest 或 QUnit,而是解释一下为什么很难自动化端到端的测试,以及为什么最低程度的渲染测试只需付出 10% 的努力,却能带来 90% 的好处。


如何发布代码


不要告诉我你使用 CI/CD(因为如今每个项目里的成员都不止一个人),而是解释为什么部署和发布应该分离,这样新功能就不会影响到已有功能,而且还可以远程启动新功能。


如何编写可审查的代码


不要说你是一名 " 团队成员 ",而是告诉我代码审查对审查者来说同样困难,而且你知道如何优化 PR 才能提高可读性和清晰度。


如何建立稳固的项目标准


除非团队中只有你一个人,否则你就必须遵守项目中的标准和惯例。你应该告诉我命名很难,而且变量的范围越广,投入到命名中的时间就应该越多。


如何审核别人的代码


因为代码审查可确保产品质量、减少 bug 和技术债务、共同建立团队知识等等,但前提是将代码审核贯彻到底。代码审查不应该只是自上而下的活动。对于经验不足的团队成员来说,这是一个很好的学习机制。


如何在 JS 框架中找到自己的方式


这与 GitHub 上的星星数量无关,你应该学习如今大多数 JS 框架都拥有的共同原则。了解其他框架的优缺点可以让你更好地了解自己选择的框架。


如何建立最小化可行产品


技术只是制造产品的工具,而不是流程。与其将时间浪费在技术争论上,还不如花点时间优化流程。


如何优化:不要太早,也不要太晚


因为在大多数情况下根本不需要优化。


如何结对编程


因为结对编程与代码审查一样,这是最重要的共享知识和建立团队凝聚力的实践。而且也很有意思!


如何持续重构


因为每个项目都有技术债务,你应该停止抱怨,并开始重构。每次开发新功能之前都应该进行小型代码重构。大规模的重构和重写永远不会有好结果。


以上就是我认为 React 毁了 Web 开发的原因。很多人对这一说法很感兴趣,并热切地加入了辩论。


链接:https://juejin.cn/post/6977944684437962788

收起阅读 »

WebKit的使用

Web view 用于加载和显示丰富的网络内容。例如,嵌入 HTML 和网站。Mail app 使用 web view 显示邮件中的 HTML 内容。iOS 8 和 macOS 10.10 中引入了WebKit framework,用以取代UIWebView和...
继续阅读 »

Web view 用于加载和显示丰富的网络内容。例如,嵌入 HTML 和网站。Mail app 使用 web view 显示邮件中的 HTML 内容。


iOS 8 和 macOS 10.10 中引入了WebKit framework,用以取代UIWebView和WebView。同时,在两个平台上提供同一API。与UIWebView相比,WebKit 有以下优势:使用了与 Safari 一样的 JavaScript engine,在运行脚本前,将脚本编译为机器代码,速度更快;支持多进程架构,Web 内容在单独的线程中运行,WKWebView崩溃不会影响 app运行;能够以60fps滑动。另外,在 iOS 12 和 macOS 10.14中,UIWebView和WebView已经被正式弃用。

WebKit framework 提供了多个类和协议,用于在窗口中显示网络内容,并实现类似浏览器功能。例如,点击链接时显示链接内容,维护前进、后退列表,维护最近访问列表。加载网页内容时,异步从 HTTP 服务器请求内容,其响应 (response) 可能以增量、随机顺序到达,也可能因网络原因部分到达,而 WebKit 极大简化了这些过程。WebKit 框架还简化了显示各种 MIME 类型内容过程,以及管理视图中各元素滚动条。

WebKit 框架中的方法、函数只能在主线程或主队列中调用。

1. WKWebView
WKWebView是 WebKit framework 的核心。在 app 内使用WKWebView插入网络内容步骤如下:

1、创建WKWebView对象。
2、将WKWebView设置为要显示的视图。
3、向WKWebView发送加载 Web 内容的请求。

使用initWithFrame:configuration:方法创建WKWebView,使用loadHTMLString:baseURL:加载本地HTML文件,或使用loadRequest:方法加载网络内容。如下:

// Local HTMLs
WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:self.webConfiguration];
self.view = webView;
NSURL *htmlURL = [[NSBundle mainBundle] URLForResource:@"WKWebView - NSHipster" withExtension:@"htm"];
NSURL *baseURL = [htmlURL URLByDeletingLastPathComponent];
NSString *htmlString = [NSString stringWithContentsOfURL:htmlURL
encoding:NSUTF8StringEncoding
error:NULL];
[webView loadHTMLString:htmlString baseURL:baseURL];

// Web Content
WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:self.webConfiguration];
self.view = webView;
NSURL *myURL = [NSURL URLWithString:@"https://github.com/pro648/tips/wiki"];
NSURLRequest *request = [NSURLRequest requestWithURL:myURL
cachePolicy:NSURLRequestUseProtocolCachePolicy
timeoutInterval:30];
[webView loadRequest:request];

本地HTML文件可以在demo源码中获取:https://github.com/pro648/BasicDemos-iOS/tree/master/WebKit

如图所示:



设置allowsBackForwardNavigationGestures属性可以开启、关闭横向滑动触发前进、后退导航功能:

self.webView.allowsBackForwardNavigationGestures = YES;

1.1 KVO

WKWebView中的title、URL、estimatedProgress、hasOnlySecureContent和loading

属性支持键值观察,可以通过添加观察者,获得当前网页标题、加载进度等。

根据文档serverTrust属性也支持KVO,但截至目前,在iOS 12.1 (16B91)中使用观察者观察该属性,运行时会抛出this class is not key value coding-compliant for the key serverTrust的异常。

将网页标题显示出来可以帮助用户了解当前所在位置,显示当前导航进度能够能够让用户感受到加载速度,另外,还可以观察hasOnlySecureContent查看当前网页所有资源是否均通过加密连接传输。在viewDidLoad中添加以下代码:

[self.webView addObserver:self forKeyPath:@"hasOnlySecureContent" options:NSKeyValueObservingOptionNew context:webViewContext];
[self.webView addObserver:self forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:webViewContext];
[self.webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:webViewContext];

实现observerValueForKeyPath:ofObject:change:context:方法,在观察到值变化时进行对应操作:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"hasOnlySecureContent"]) {
BOOL onlySecureContent = [[change objectForKey:NSKeyValueChangeNewKey] boolValue];
NSLog(@"onlySecureContent:%@",onlySecureContent ? @"YES" : @"NO");
} else if ([keyPath isEqualToString:@"title"]) {
self.navigationItem.title = change[NSKeyValueChangeNewKey];
} else if ([keyPath isEqualToString:@"estimatedProgress"]) {
self.progressView.hidden = [change[NSKeyValueChangeNewKey] isEqualToNumber:@1];

CGFloat progress = [change[NSKeyValueChangeNewKey] floatValue];
self.progressView.progress = progress;
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

如果你对键值观察、键值编码还不熟悉,可以查看我的另一篇文章:KVC和KVO学习笔记

运行demo,如下所示:


控制台会输出如下内容:

onlySecureContent:YES

WKWebView调用reload、stopLoading、goBack、goForward可以实现刷新、返回、前进等功能:

- (void)refreshButtonTapped:(id)sender {
[self.webView reload];
}

- (void)stopLoadingButtonTapped:(id)sender {
[self.webView stopLoading];
}

- (IBAction)backButtonTapped:(id)sender {
[self.webView goBack];
}

- (IBAction)forwardButtonTapped:(id)sender {
[self.webView goForward];
}

还可以通过观察loading属性,在视图加载完成时,更新后退、前进按钮状态:

- (void)viewDidLoad {
...

[self.webView addObserver:self forKeyPath:@"loading" options:NSKeyValueObservingOptionNew context:webViewContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
...
if (context == webViewContext && [keyPath isEqualToString:@"loading"]) {
BOOL loading = [change[NSKeyValueChangeNewKey] boolValue];
// 加载完成后,右侧为刷新按钮;加载过程中,右侧为暂停按钮。
self.navigationItem.rightBarButtonItem = loading ? self.stopLoadingButton : self.refreshButton;

self.backButton.enabled = self.webView.canGoBack;
self.forwardButton.enabled = self.webView.canGoForward;
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

1.2 截取网页视图

在iOS 11和macOS High Sierra中,WebKit framework增加了takeSnapshotWithConfiguration:completionHandler: API用于截取网页视图。截取网页可见部分视图方法如下:

- (IBAction)takeSnapShot:(UIBarButtonItem *)sender {
WKSnapshotConfiguration *shotConfiguration = [[WKSnapshotConfiguration alloc] init];
shotConfiguration.rect = CGRectMake(0, 0, self.webView.bounds.size.width, self.webView.bounds.size.height);

[self.webView takeSnapshotWithConfiguration:shotConfiguration
completionHandler:^(UIImage * _Nullable snapshotImage, NSError * _Nullable error) {
// 保存截图至相册,需要在info.plist中添加NSPhotoLibraryAddUsageDescription key和描述。
UIImageWriteToSavedPhotosAlbum(snapshotImage, NULL, NULL, NULL);
}];
}

此前,截取网页视图需要结合图层和graphics context。现在,只需要调用单一API。

1.3 执行JavaScript

可以使用evaluateJavaScript:completionHandler:方法触发web view JavaScript。下面方法触发输出web view userAgent:

[self.webView evaluateJavaScript:@"navigator.userAgent" completionHandler:^(id _Nullable userAgent, NSError * _Nullable error) {
NSLog(@"%@",userAgent);
}];

在iOS 12.1.2 (16C101) 中,输出如下:

Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16C101

2. WKWebViewConfiguration

WKWebViewConfiguration是用于初始化Web视图属性的集合。通过WKWebViewConfiguration类,可以设置网页渲染速度,视频是否自动播放,HTML5 视频是否一帧一帧播放,如何与本地代码通信等。

WKWebViewConfiguration属性有偏好设置preference、线程池processPool和用户内容控制器userContentController等。

Web view 初始化时才需要WKWebViewConfiguration对象,WKWebView创建后无法修改其configuration。多个WKWebView可以使用同一个configuration。


例如,设置网页中最小字体为30,自动检测电话号码:

- (WKWebViewConfiguration *)webConfiguration {
if (!_webConfiguration) {
_webConfiguration = [[WKWebViewConfiguration alloc] init];

// 偏好设置 设置最小字体
WKPreferences *preferences = [[WKPreferences alloc] init];
preferences.minimumFontSize = 30;
_webConfiguration.preferences = preferences;

// 识别网页中的电话号码
_webConfiguration.dataDetectorTypes = WKDataDetectorTypePhoneNumber;

// Web视图内容完全加载到内存之前,禁止呈现。
_webConfiguration.suppressesIncrementalRendering = YES;
}
return _webConfiguration;
}

suppressesIncrementalRendering属性是布尔值,决定Web视图内容在完全加载到内存前是否显示,默认为NO,即边加载边显示。例如,Web视图中有文字和图片,会先显示文字后显示图片。

3. Scripts

用户脚本 (User Scripts) 是文档开始加载或加载完成后注入 Web 页面的 JS。User Scripts非常强大,其能够通过客户端设置网页,允许注入事件监听器,甚至可以注入脚本,这些脚本又可以回调 native app 。

3.1 WKUserScript

WKUserScript对象表示可以注入网页的脚本。initWithSource:injectionTime:forMainFrameOnly:方法返回可以添加到userContentController控制器的脚本。其中,source 参数为 script 源码;injectionTime为WKUserScriptInjectionTimeAtDocumentStart、WKUserScriptInjectionTimeAtDocumentEnd,

其参数如下:

source: script 源码。
injectionTime: user script注入网页时间,为WKUserScriptInjectionTime枚举常量。WKUserScriptInjectionTimeAtDocumentStart在创建文档元素之后,加载任何其他内容之前注入。WKUserScriptInjectionTimeAtDocumentEnd在加载文档后,但在加载其他子资源之前注入。
forMainFrameOnly: 布尔值,YES时只注入main frame,NO时注入所有 frame。
下面代码将隐藏 Wikipedia toc、mw-panel 脚本注入网页,同时使用 JS 提取网页 toc 表格内容:

// 隐藏wikipedia左边缘和contents表格
NSURL *hideTableOfContentsScriptURL = [[NSBundle mainBundle] URLForResource:@"hide" withExtension:@"js"];
NSString *hideTableOfContentsScriptString = [NSString stringWithContentsOfURL:hideTableOfContentsScriptURL
encoding:NSUTF8StringEncoding error:NULL];
WKUserScript *hideTableOfContentsScript = [[WKUserScript alloc] initWithSource:hideTableOfContentsScriptString
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];

// 获取contents表格内容
NSString *fetchTableOfContentsScriptString = [NSString stringWithContentsOfURL:[[NSBundle mainBundle] URLForResource:@"fetch" withExtension:@"js"] encoding:NSUTF8StringEncoding error:NULL];
WKUserScript *fetchTableOfContentsScript = [[WKUserScript alloc] initWithSource:fetchTableOfContentsScriptString
injectionTime:WKUserScriptInjectionTimeAtDocumentEnd
forMainFrameOnly:YES];

本文中的 js 和 HTML 均可通过文章底部源码链接获取。要使用 JavaScript 提取内容的网页为:https://en.wikipedia.org/w/index.php?title=San_Francisco&mobileaction=toggle_view_desktop

3.2 WKUserContentController
WKUserContentController对象为 JavaScript 提供了发送消息至 native app,将 user scripts 注入 Web 视图方法。

将 user script 添加到userContentController才可以注入网页中:

WKUserContentController *userContentController = [[WKUserContentController alloc] init];
[userContentController addUserScript:hideTableOfContentsScript];
[userContentController addUserScript:fetchTableOfContentsScript];

要监听 JavaScript 消息,需要先注册要监听消息名称。添加监听事件方法为addScriptMessageHandler:name:,参数如下:

scriptMessageHandler: 处理监听消息,该类需要遵守WKMessageHandler协议。
name: 要监听消息名称。
使用该方法添加监听事件后,JavaScript 的 window.webkit.messageHandlers.name.postMessage(messageBody) 函数将被定义在使用了该userContentController网页视图的所有frame。

监听fetch.js中 didFetchTableOfContents 消息:

[userContentController addScriptMessageHandler:self name:@"didFetchTableOfContents"];

// 最后,将userContentController添加到WKWebViewConfiguration
_webConfiguration.userContentController = userContentController;

3.3 WKScriptMessageHandler

监听 script message 的类必须遵守WKMessageHandler协议,实现该协议唯一且必须实现的userContentController:didReceiveScriptMessage:方法。Webpage 接收到脚本消息时会调用该方法。

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"didFetchTableOfContents"]) {
id body = message.body;
if ([body isKindOfClass:NSArray.class]) {
NSLog(@"messageBody:%@",body);
}
}
}

如下所示:


JavaScript 消息是WKScriptMessage对象,该对象属性如下:

body:消息内容,可以是NSNumber、NSString、NSDate、NSArray、NSDictionary、NSNull类型。
frameInfo:发送该消息的frame。
name:接收消息对象名称。
webView:发送该消息的网页视图。
最终,我们成功的将事件从 iOS 转发到 JavaScript,并将 JavaScript 转发回 iOS。

4. WKNavigationDelegate

用户点击链接,使用前进、后退手势,JavaScript 代码(例如,window.location = ' https://github.com/pro648 '),使用代码调用loadRequest:等均会让网页加载内容,即action引起网页加载;随后,web view 会向服务器发送request,接收response,可能会是positive response,也可能请求失败;之后接收数据。我们的应用可以在action后、request前,或者response后、data前自定义网页加载,决定继续加载,或取消加载。


WKNavigationDelegate协议内方法可以自定义Web视图接收、加载和完成导航请求过程的行为。

首先,声明遵守WKNavigationDelegate协议:

@interface ViewController () <WKNavigationDelegate>

其次,指定遵守WKNavigationDelegate协议的类为 web view 代理:

self.webView.navigationDelegate = self;

最后,根据需要实现所需WKNavigationDelegate方法。

webView:decidePolicyForNavigationAction:decisionHandler:方法在action后响应,webView:decidePolicyForNavigationResponse:decisionHandler:方法在response后响应。

根据前面的配置,WKWebView会自动识别网页中电话号码。截至目前,电话号码只能被识别,无法点击。可以通过实现webView:decidePolicyForNavigationAction:decisionHandler:方法,调用系统Phone app拨打电话

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
if (navigationAction.navigationType == WKNavigationTypeLinkActivated && [navigationAction.request.URL.scheme isEqualToString:@"tel"]) {
[UIApplication.sharedApplication openURL:navigationAction.request.URL options:@{} completionHandler:^(BOOL success) {
NSLog(@"Successfully open url:%@",navigationAction.request.URL);
}];
decisionHandler(WKNavigationActionPolicyCancel);
} else {
decisionHandler(WKNavigationActionPolicyAllow);
}
}

实现了该方法后,必须调用decisionHandler块。该块参数为WKNavigationAction枚举常量。WKNavigationActionPolicyCancel取消导航,WKNavigationActionPolicyAllow继续导航。

WKNavigationAction对象包含引起本次导航的信息,用于决定是否允许本次导航。其属性如下:

request:本次导航的request。
sourceFrame:WKFrameInfo类型,请求本次导航frame信息。
targetFrame:目标frame。如果导航至新窗口,则targetFrame为nil。
navigationType:WKNavigationType枚举类型,为以下常量:
WKNavigationTypeLinkActivated:用户点击href链接。
WKNavigationTypeFormSubmitted:提交表格。
WKNavigationTypeBackForward:请求前进、后退列表中item。
WKNavigationTypeReload:刷新网页。
WKNavigationTypeFormResubmitted:因后退、前进、刷新等重新提交表格。
WKNavigationTypeOther:其他原因。
WKFrameInfo对象包含了一个网页中的frame信息,其只是一个描述瞬时状态 (transient) 的纯数据 (data-only) 对象,不能在多次消息调用中唯一标志某个frame。

如果需要在response后操作导航,需要实现webView:decidePolicyForNavigationResponse:decisionHandler:方法。WKNavigationResponse对象包含navigation response信息,用于决定是否接收response。其属性如下:

canShowMIMEType:布尔类型值,指示WebKit是否显示MIME类型内容。
forMainFrame:布尔类型值,指示即将导航至的frame是否为main frame。
response:NSURLResponse类型。
实现了该方法后,必须调用decisionHandler块,否则会在运行时抛出异常。decisionHandler块参数为WKNavigationResponsePolicy枚举类型。WKNavigationResponseCancel取消导航,WKNavigationResponseAllow继续导航。

Navigation action 和 navigation response 既可以在处理完毕后立即调用decisionHandler,也可以异步调用。

5. WKUIDelegate

WKWebView与 Safari 类似,尽管前者在一个窗口显示内容。如果需要打开多个窗口、监控打开、关闭窗口,修改用户点击元素时显示哪些选项,需要使用WKUIDelegate协议。

首先,声明遵守WKUIDelegate协议:

@interface ViewController () <WKUIDelegate>

其次,指定遵守WKUIDelegate协议的类为 web view 代理:

self.webView.uiDelegate = self;

最后,根据需要实现WKUIDelegate协议内方法。

5.1 新窗口打开

如何响应 JavaScript 的打开新窗口函数、或target="_blank"标签?有以下三种方法:

创建新的WKWebView,并在新的页面打开。
在 Safari 浏览器打开。
捕捉 JS ,在同一个WKWebView加载。
当 URL 为 mail、tel、sms 和 iTunes链接时交由系统处理。此时,系统会交由对应 app 处理。其他情况在当前 web view 加载。

5.2 响应 JavaScript 弹窗
在响应 JavaScript 时,可以通过WKUIDelegate协议使用 native UI呈现,有以下三种方法:

1、webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler::为了用户安全,在该方法的实现中需要需要标志出提供当前内容的网址,最为简便的方法便是frame.request.URL.host,响应面板应只包含一个OK按钮。当 alert panel 消失后,调用completionHandler。
2、webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler::为了用户安全,在该方法的实现中需要需要标志出提供当前内容的网址,最为简便的方法便是frame.request.URL.host,响应面板包括两个按钮,一般为OK和Cancel。当 alert panel 消失后,调用completionHandler。如果用户点击的是OK按钮,为completionHandler传入YES;如果用户点击的是Cancel按钮,为completionHandler传入NO。
3、webView:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler::为了用户安全,在该方法的实现中需要需要标志出提供当前内容的网址,最为简便的方法便是frame.request.URL.host,响应面板包括两个按钮(一个OK按钮,一个Cancel按钮)和一个输入框。当面板消失时调用completionHandler,如果用户点击的是OK按钮,传入文本框文本;否则,传入nil。

例如,输入账号、密码前点击登陆按钮,大部分网页会弹出警告框。在 JavaScript 中,会弹出 alert 或 confirm box。

JavaScript alert 会堵塞当前进程,调用completionHandler后 JavaScript 才会继续执行。

6. WKURLSchemeHandler

UIWebView支持自定义NSURLProtocol协议。如果想要加载自定义 URL 内容,可以通过创建、注册NSURLProtocol子类实现。此后,任何调用自定义 scheme (例如,hello world://) 的方法,都会调用NSURLProtocol子类,在NSURLProtocol子类处理自定义 scheme ,这将非常实用。例如,在 book 中加载图片、视频等。

WKWebView不支持NSURLProtocol协议,因此不能加载自定义 URL Scheme。在 iOS 11 中,Apple 为 WebKit framework 增加了WKURLSchemeHandler协议,用于加载自定义 URL Scheme。

WebKit遇到无法识别的 URL时,会调用WKURLSchemeHandler协议。该协议包括以下两个必须实现的方法:

webView:startURLSchemeTask::加载资源时调用。
webView:stopURLSchemeTask::WebKit 调用该方法以终止 (stop) 任务。调用该方法后,不得调用WKURLSchemeTask协议的任何方法,否则会抛出异常。
使用WKURLSchemeHandler协议处理完毕任务后,调用WKURLSchemeTask协议内方法加载资源。WKURLSchemeTask协议包括request属性,该属性为NSURLRequest类型对象。还包含以下方法:

didReceiveResponse::设置当前任务的 response。每个 task 至少调用一次该方法。如果尝试在任务终止或完成后调用该方法,则会抛出异常。
didReceiveData::设置接收到的数据。当接收到任务最后的 response 后,使用该方法发送数据。每次调用该方法时,新数据会拼接到先前收到的数据中。如果尝试在发送 response 前,或任务完成、终止后调用该方法,则会引发异常。
didFinish:将任务标记为成功完成。如果尝试在发送 response 前,或将已完成、终止的任务标记为完成,则会引发异常。
didFailWithError::将任务标记为失败。如果尝试将已完成、失败,终止的任务标记为失败,则会引发异常。
在WKURLSchemeHandler协议方法内,可以获取到请求的request。因此,可以提取 URL 中任何内容,并将数据转发给WKWebView进行加载。

下面的方法分别使用 url 、custom URL Scheme加载网络图片和相册图片:

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
NSURL *url = urlSchemeTask.request.URL;
if ([url.absoluteString containsString:@"custom-scheme"]) {
NSArray<NSURLQueryItem *> *queryItems = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES].queryItems;
for (NSURLQueryItem *item in queryItems) {

// example: custom-scheme://path?type=remote&url=https://placehold.it/120x120&text=image1
if ([item.name isEqualToString:@"type"] && [item.value isEqualToString:@"remote"]) {
for (NSURLQueryItem *queryParams in queryItems) {
if ([queryParams.name isEqualToString:@"url"]) {
NSString *path = queryParams.value;
path = [path stringByReplacingOccurrencesOfString:@"\\" withString:@""];

// 获取图片
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDataTask *task = [session dataTaskWithURL:[NSURL URLWithString:path] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
}];
[task resume];
}
}
} else if ([item.name isEqualToString:@"type"] && [item.value isEqualToString:@"photos"]) { // example: custom-scheme://path?type=photos
dispatch_async(dispatch_get_main_queue(), ^{
self.imagePicker = [[ImagePicker alloc] init];
[self.imagePicker showGallery:^(BOOL flag, NSURLResponse * _Nonnull response, NSData * _Nonnull data) {
if (flag) {
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
} else {
NSError *error = [NSError errorWithDomain:urlSchemeTask.request.URL.absoluteString code:0 userInfo:NULL];
[urlSchemeTask didFailWithError:error];
}
}];
});
}
}
}
});
}

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
NSError *error = [NSError errorWithDomain:urlSchemeTask.request.URL.absoluteString code:0 userInfo:NULL];
[urlSchemeTask didFailWithError:error];
}

实现上述方法的类必须遵守WKURLSchemeHandler协议。另外,必须在WKWebView的配置中注册所支持的 URL Scheme:

// 添加要自定义的url scheme
[_webConfiguration setURLSchemeHandler:self forURLScheme:@"custom-scheme"];

运行如下:


总结

WebKit为 iOS 、macOS 开发人员提供了一套强大的开发工具,可以直接在 app 网页视图中操作 JavaScript,使用 user script 将 JavaScript 注入网页,使用WKScriptMessageHandler协议接收 JavaScript 消息。使用WKNavigationDelegate协议自定义网页导航,使用WKUIDelegate在网页上呈现 native UI,使用WKURLSchemeHandler加载自定义 URL Scheme 内容。

如果只是简单呈现网页视图,推荐使用 iOS 9 推出的SFSafariViewController,几行代码就可实现与 Safari 一样的体验。SFSafariViewController还提供了自动填充、欺诈网站监测等功能。

Demo名称:WebKit
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/WebKit

链接:https://www.jianshu.com/p/65c66f924d56

收起阅读 »

pthread多线程(C语言) + Socket

pthread多线程(C语言) + Socketpthread是使用使用C语言编写的多线程的API, 简称Pthreads ,是线程的POSIX标准,可以在Unix / Linux / Windows 等系统跨平台使用。在类Unix操作系统(Unix、Linu...
继续阅读 »

pthread多线程(C语言) + Socket

pthread是使用使用C语言编写的多线程的API, 简称Pthreads ,是线程的POSIX标准,可以在Unix / Linux / Windows 等系统跨平台使用。在类Unix操作系统(Unix、Linux、Mac OS X等)中,都使用Pthreads作为操作系统的线程。

GitHub项目FanSocket(纯C语言socket+线程队列)+其他demo客户端

1.线程创建

//子线程1
void test1(int *a){
printf("线程test1");
//修改自己的子线程系统释放,注释打开后,线程不能用pthread_join方法
//pthread_detach(pthread_self());
}
//子线程2
void test2(int *a){
printf("线程test2");
}

/*
int pthread_create(pthread_t * thread, //新线程标识符
pthread_attr_t * attr, //新线程的运行属性
void * (*start_routine)(void *), //线程将会执行的函数
void * arg);//执行函数的传入参数,可以为结构体
*/

//创建线程方法一 (手动释放线程)
int a=10;
pthread_t pid;
pthread_create(&pid, NULL, (void *)test1, (void *)&a);


//线程退出或返回时,才执行回调,可以释放线程占用的堆栈资源(有串行的作用)
if(pthread_join(pid, NULL)==0){
//线程执行完成
printf("线程执行完成:%d\n",threadIndex);
if (message!=NULL) {
printf("线程执行完成了\n");
}
}


//创建线程方法二 (自动释放线程)
//设置线程属性
pthread_attr_t attr;
pthread_attr_init (&attr);
//线程默认是PTHREAD_CREATE_JOINABLE,需要pthread_join来释放线程的
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
//线程并发
int rc=pthread_create(&pid, &attr, (void *)test2, (void *)a);
pthread_attr_destroy (&attr);
if (rc!=0) {
printf("创建线程失败\n");
return;
}

2.线程退出和其他

pthread_exit (tes1) //退出当前线程
pthread_main_np () // 获取主线程

//主线程和子线程
if(pthread_main_np()){
//main thread
}else{
//others thread
}

int pthread_cancel(pthread_t thread);//发送终止信号给thread线程,如果成功则返回0
int pthread_setcancelstate(int state, int *oldstate);//设置本线程对Cancel信号的反应
int pthread_setcanceltype(int type, int *oldtype);//设置本线程取消动作的执行时机
void pthread_testcancel(void);//检查本线程是否处于Canceld状态,如果是,则进行取消动作,否则直接返回

3 线程互斥锁(量)与条件变量

3.1 互斥锁(量)

//静态创建
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//动态创建
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
//注销互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

//lock 和unlock要成对出现,不然会出现死锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//判断是否可以加锁,如果可以加锁并返回0,否则返回非0
int pthread_mutex_trylock(pthread_mutex_t *mutex);

3.2 条件变量

1、条件变量是利用线程间共享的全局变量进行同步的一种机制,
2、一个线程等待”条件变量的条件成立”而挂起;
3、另一个线程使”条件成立”(给出条件成立信号)。
4、为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。

//静态创建
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
//动态创建
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);
//注销条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
//条件等待,和超时等待
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex,
const struct timespec *abstime);
//开启条件,启动所有等待线程
int pthread_cond_broadcast(pthread_cond_t *cond);
//开启一个等待信号量
int pthread_cond_signal(pthread_cond_t *cond);

4.线程同步:互斥锁(量)与条件变量(具体封装实现)

/*全局的队列互斥条件*/
extern pthread_cond_t fan_cond;
extern pthread_cond_t fan_cond_wait;
/*全局的队列互斥锁*/
extern pthread_mutex_t fan_mutex;
//extern pthread_mutex_t fan_mutex_wait;

extern int fan_thread_status;//0=等待 1=执行 -1=清空所有
extern int fan_thread_clean_status;//0=默认 1=清空所有

//开启线程等待 return=-2一定要处理
extern int fan_thread_start_wait(void);
//正常的超时后继续打开下一个信号量 return=-2一定要处理
int fan_thread_start_timedwait(int sec);
//启动线程,启动信号量
extern int fan_thread_start_signal(void);
//启动等待信号量
extern int fan_thread_start_signal_wait(void);
//暂停线程
extern int fan_thread_end_signal(void);
//初始化互斥锁
extern int fan_thread_queue_init(void);
//释放互斥锁信号量
extern int fan_thread_free(void);

//让队列里面全部执行完毕,而不是关闭线程;
extern int fan_thread_clean_queue(void);
//每次关闭清空后,等待1-2秒,要恢复状态,不然线程添加
extern int fan_thread_init_queue(void);
//设置线程的优先级,必须在子线程
extern int fan_thread_setpriority(int priority);

线程队列互斥,并且按入队顺序,一个一个按照外部条件,触发信号量,主要是等待队列,

/*全局的队列互斥条件*/
pthread_cond_t fan_cond=PTHREAD_COND_INITIALIZER;
pthread_cond_t fan_cond_wait=PTHREAD_COND_INITIALIZER;

/*全局的队列互斥锁*/
pthread_mutex_t fan_mutex = PTHREAD_MUTEX_INITIALIZER;
//pthread_mutex_t fan_mutex_wait = PTHREAD_MUTEX_INITIALIZER;
int fan_thread_status=1;//0=等待 1=执行
int fan_thread_clean_status;//0=默认 1=清空所有

//开启线程等待
int fan_thread_start_wait(void){
pthread_mutex_lock(&fan_mutex);
fan_thread_clean_status=0;
while (fan_thread_status==0) {
pthread_cond_wait(&fan_cond, &fan_mutex);
if (fan_thread_clean_status==1) {
break;
}
}
if (fan_thread_clean_status==1) {
pthread_mutex_unlock(&fan_mutex);
return -2;
}
if (fan_thread_status==1) {
fan_thread_status=0;
pthread_mutex_unlock(&fan_mutex);
}else{
pthread_mutex_unlock(&fan_mutex);
}
return 0;
}
//正常的超时后继续打开下一个信号量
int fan_thread_start_timedwait(int sec){
int rt=0;
pthread_mutex_lock(&fan_mutex);
struct timeval now;
struct timespec outtime;
gettimeofday(&now, NULL);
outtime.tv_sec = now.tv_sec + sec;
outtime.tv_nsec = now.tv_usec * 1000;

int result = pthread_cond_timedwait(&fan_cond_wait, &fan_mutex, &outtime);
if (result!=0) {
//线程等待超时
rt=-1;
}
if (fan_thread_clean_status==1) {
rt = -2;
}
pthread_mutex_unlock(&fan_mutex);
return rt;
}
//启动线程,启动信号量
int fan_thread_start_signal(void){
int rs=pthread_mutex_trylock(&fan_mutex);
if(rs!=0){
pthread_mutex_unlock(&fan_mutex);
}
fan_thread_status=1;
pthread_cond_signal(&fan_cond);
// pthread_cond_broadcast(&fan_cond);//全部线程
pthread_mutex_unlock(&fan_mutex);
return 0;
}
//开启等待时间的互斥信号量
int fan_thread_start_signal_wait(void){
int rs=pthread_mutex_trylock(&fan_mutex);
if(rs!=0){
pthread_mutex_unlock(&fan_mutex);
}
// fan_thread_status=1;
pthread_cond_signal(&fan_cond_wait);
// pthread_cond_broadcast(&fan_cond);//全部线程
pthread_mutex_unlock(&fan_mutex);
return 0;
}
//暂停下一个线程
int fan_thread_end_signal(void){
pthread_mutex_lock(&fan_mutex);
fan_thread_status=0;
pthread_cond_signal(&fan_cond);
pthread_mutex_unlock(&fan_mutex);
return 0;
}
//初始化互斥锁(动态创建)
int fan_thread_queue_init(void){
pthread_mutex_init(&fan_mutex, NULL);
pthread_cond_init(&fan_cond, NULL);
return 0;
}
//释放互斥锁和信号量
int fan_thread_free(void)
{
pthread_mutex_destroy(&fan_mutex);
pthread_cond_destroy(&fan_cond);
return 0;
}

//清空所有的队列
int fan_thread_clean_queue(void){
pthread_mutex_lock(&fan_mutex);
fan_thread_clean_status=1;
pthread_cond_broadcast(&fan_cond);
pthread_cond_broadcast(&fan_cond_wait);
pthread_mutex_unlock(&fan_mutex);
return 0;
}
//恢复队列
int fan_thread_init_queue(void){
pthread_mutex_lock(&fan_mutex);
fan_thread_clean_status=0;
fan_thread_status=1;
pthread_cond_signal(&fan_cond);
pthread_mutex_unlock(&fan_mutex);
return 0;
}
//设置线程的优先级,必须在子线程
int fan_thread_setpriority(int priority){
struct sched_param sched;
bzero((void*)&sched, sizeof(sched));
// const int priority1 = (sched_get_priority_max(SCHED_RR) + sched_get_priority_min(SCHED_RR)) / 2;
sched.sched_priority=priority;
//SCHED_OTHER(正常,非实时)SCHED_FIFO(实时,先进先出)SCHED_RR(实时、轮转法)
pthread_setschedparam(pthread_self(), SCHED_RR, &sched);
return 0;
}

5 其他线程方法

//return=0:线程存活。ESRCH:线程不存在。EINVAL:信号不合法。
int kill_ret=pthread_kill(pid, 0);//测试线程是否存在
printf("线程状态:%d\n",kill_ret);
if(kill_ret==0){
//关闭线程
pthread_cancel(pid);
}


pthread_equal(pid, pid1);//比较两个线程ID是否相同


//函数执行一次
pthread_once_t once = PTHREAD_ONCE_INIT;
pthread_once(&once, test1);

转自:https://www.jianshu.com/p/6fcd478635e2

收起阅读 »

FIL升级对矿工有哪些利好?现在是参与挖FIL币好时机

据财经媒体报道:FIL将在6月底进行系统升级,FIL升级对矿工有哪些利好?那对与刚了解FIL挖矿的朋友们现在正是参与的好时机+slf81818,为什么呢?一起来了解下:Filecoin’s v13 HyperDrive网络升级的重要意义在于:一:人类宝贵信息的...
继续阅读 »

据财经媒体报道:FIL将在6月底进行系统升级,FIL升级对矿工有哪些利好?那对与刚了解FIL挖矿的朋友们现在正是参与的好时机+slf81818,为什么呢?一起来了解下:

Filecoin’s v13 HyperDrive网络升级的重要意义在于:

一:人类宝贵信息的可验证存储容量将增长10—25倍。

二:GAS费大幅降低,无限期限接近于0。

三:质押费快速下降,新进场的投入成本明显降低,更有利于生态发展。

四:参与存储商激增,将实现更快的网络增长。

五:三大运营商之一(移动电信联通)智慧城市的数据将接入储存IPFS系统,进一步促进IPFS的应用落地,或将成为IPFS历史级重大标志性事件。

Filecoin V13版本的更新将是颠覆性的,也是突破共识的一次更新。其目的也不仅仅是降低Gas费,而是释放带宽,为Filecoin添加智能合约功能做准备。

目前FIL市场的具体情况具体分析,大多数人都在观望,主要还是带着想要一夜暴富的想法去炒币。这完全是两个概念,炒币没有哪个不伤筋动骨,这还是要轻的,可以考虑一下它的恐怖程度!为什么说矿工总是食物链的顶端?合理的投资方式是看其长期收益,不需要过多地去看目前的价格高低,手中的矿机每天都能产出 FIL,不用管它涨跌,相反,炒币就是你买了多少就有多少,性质不同。

例如,买一只鸡来给你每天生蛋,头七天价格比以前低,把鸡蛋存起来不卖,第八天它的价格达到了你想要的市场价就全部卖掉,与此相反,你直接买鸡蛋来倒买倒卖,风险成本是显而易见的。炒币看运气,屯币看心态,矿机相当于永动机。













现在币价低,加入挖矿成本也会很低,最重要的是其日产币并未降低,反而还在增加产量,这也大大缩短了回本周期,未来币价上涨,矿机也将随之涨价,人多挖矿效率肯定不如现在人少,回本周期更是大幅拉长,挖矿最大优势在于自身持币增多,有“粮”就能度过寒冬,躲过熊市就是迎来资产爆发的喜悦。感谢大家关注芳姐+slf81818,了解更多币圈最新资讯。

收起阅读 »

Android 布局打气筒 (一):玩转 LayoutInflater

前言很高兴遇见你~今天准备和大家分享的是 LayoutInflater,我给它取名:布局打气筒,很形象,其实就是根据英文翻译过来的😂。我们知道气球打气筒可以给气球打气从而改变它的形状。而布局打气筒的作用就是给我们的 Xml 布局打气让它变成一个个 View 对...
继续阅读 »

前言

很高兴遇见你~

今天准备和大家分享的是 LayoutInflater,我给它取名:布局打气筒,很形象,其实就是根据英文翻译过来的😂。我们知道气球打气筒可以给气球打气从而改变它的形状。而布局打气筒的作用就是给我们的 Xml 布局打气让它变成一个个 View 对象。在我们的日常工作中,经常会接触到他,因为只要你写了 Xml 布局,你就要使用 LayoutInflater,下面我们就来好好讲讲它。

注意:本文所展示的系统源码都是基于Android-30 ,并提取核心部分进行分析

一、基本使用

1、LayoutInflater 实例获取

1)、通过 LayoutInflater 的静态方法 from 获取

2)、通过系统服务 getSystemService 方法获取

3)、如果是在 Activity 或 Fragment 可直接获取到实例

//1、通过 LayoutInflater 的静态方法 from 获取
val layoutInflater: LayoutInflater = LayoutInflater.from(this)

//2、通过系统服务 getSystemService 方法获取
val layoutInflater: LayoutInflater = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater

//3、如果是在 Activity 或 Fragment 可直接获取到实例
layoutInflater //相当于调用 getLayoutInflater()

实际上,1 是 2 的简单写法,只是 Android 给我们做了一下封装。拿到 LayoutInflater 实例后,我们就可以调用它的 inflate 系列方法了,这几个方法是本篇文章的一个重点,如下:

image-20210622163719911

从 Xml 布局到创建 View 对象,这几个方法扮演着至关重要的作用,其中我们用的最多就是第一个和第三个重载方法,现在我们就来使用一下

二、例子

1、创建一个新项目,MainActivity 对应的布局如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/cons_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"/>

2、创建一个新的布局取名 item_main.xml,如下图:

image-20210622174620878

3、修改 MainActivity 中的代码

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val consMain = findViewById<ConstraintLayout>(R.id.cons_main)
val itemMain = layoutInflater.inflate(R.layout.item_main, null)
consMain.addView(itemMain)
}
}

上述代码我们使用了两个参数的 inflate 重载方法,第二个参数 root 传了一个 null ,然后把当前布局添加到 Activity 中,运行看下效果:

image-20210622175552693

啥情况?怎么和预想的不一样呢?我的背景颜色怎么不见了?把这个问题 1 先记着

接下来,我们修改一下 MainActivity 中的代码,如下:

val itemMain = layoutInflater.inflate(R.layout.item_main, consMain)
//等同下面这行代码
val itemMain = layoutInflater.inflate(R.layout.item_main, consMain,true)

实际上上面这句代码就相当于调用了三个参数的重载方法,且第三个参数为 true,我们看下它两个参数的源码:

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
return inflate(resource, root, root != null);
}

现在在运行看下结果:

image-20210622190018488

报错了,提示我们当前 child 已经有了一个父 View,你必须先调用父 View 的 removeView 方法移除当前 child 才行。是不是疑问更多了呢?把这个问题 2 也先记着

我们在修改一下 MainActivity 中的代码,如下:

val itemMain = layoutInflater.inflate(R.layout.item_main, consMain,false)

在运行看下结果:

image-20210622190835239

嗯,现在达到了我们预期的效果

现在回到上面那两个问题,分析发现是 LayoutInflater inflate 方法传了不同的参数导致的,那这些参数到底有什么玄乎的地方呢?接下来跟着我的脚步分析下源码,或许你就豁然开朗了

三、LayoutInflater inflate 系列方法源码分析

在分析源码之前,我们需要明白一些基础知识:

我们一般都会使用 layout_width 和 layout_height 来设置 View 的大小,实际上是要满足一个条件,那就是这个 View 必须存在于一个容器或布局中,否则没有意义,之后如果将 layout_width 设置成 match_parent 表示让 View 的宽度填充满布局,如果设置成 wrap_content 表示让 View 的宽度刚好可以包含其内容,如果设置成具体的数值则 View 的宽度会变成相应的数值。这也是为什么这两个属性叫作 layout_width 和layout_height,而不是 width 和 height 。

明白了上面这些知识,我们继续往下看

实际上,我们调用 LayoutInflater inflate 系列方法,最终都会走到上述截图的第 4 个重载方法,看下它的源码,仅贴出关键代码:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
//...
//获取布局 Xml 里面的属性集合
AttributeSet attrs = Xml.asAttributeSet(parser);
// 将传入的 root 赋值 给 result
View result = root;

// 创建根 View 赋值给 temp
final View temp = createViewFromTag(root, name, inflaterContext, attrs);

ViewGroup.LayoutParams params = null;

if (root != null) {
//...
//如果传入的 root 不为空,通过 root 和布局属性生成布局参数
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
// 如果传入的 attachToRoot 为 false 则给当前创建的根 View 设置布局参数
temp.setLayoutParams(params);
}
}

//递归创建子 View 并添加到父布局中
rInflateChildren(parser, temp, attrs, true);

if (root != null && attachToRoot) {
//如果 root 不为空且 attachToRoot 为 true,添加当前创建的根 View 到 root
root.addView(temp, params);
}

if (root == null || !attachToRoot) {
//如果 root 为空或者 attachToRoot 为 false, 将当前创建的根 View 赋值给 result
result = temp;
}

//...
//返回当前 result
return result;
}
}

上述代码我们可以得到一些结论:

1、如果传入的 root 不为 null 且 attachToRoot 为 false,此时会给 Xml 布局生成的根 View 设置布局参数

注意:Xml 布局生成的根 View 并没有被添加到任何其他 View 中,此时根 View 的布局属性不会生效,但是我们给它设置了布局参数,那么它就会生效,只是没有被添加到任何其他 View 中

2、如果传入的 root 不为 null 且 attachToRoot 为 true,此时会将 Xml 布局生成的根 View 通过 addView 方法携带布局参数添加到 root 中

注意:此时 Xml 布局生成的根 View 已经被添加到其他 View 中,注意避免重复添加而报错

3、如果传入的 root 为 null ,此时会将 Xml 布局生成的根 View 对象直接返回

注意:此时 Xml 布局生成的根 View 既没有被添加到其他 View 中,也没有设置布局参数,那么它的布局参数将会失效

明白了上面这些知识点,我们在看下为啥为会出现之前那些问题

四、问题分析

1、问题 1

上述问题 1 实际上我们是调用了 LayoutInflater 两个参数的 inflate 重载方法:

inflate(@LayoutRes int resource, @Nullable ViewGroup root)

传入的实参: resouce 传入了一个 Xml 布局,root 传入了 null

根据我们上面源码得到的结论,当传入的 root 为 null ,此时会将 Xml 布局生成的根 View 对象直接返回

那么此时这个布局根 View 不在任何 View 中,因此它的布局属性失效了,但是 TextView 在一个布局中,它的布局属性会生效,因此就出现了上述截图中的效果

2、问题 2

上述问题 2 我们调用的还是 LayoutInflater 两个参数的构造方法

传入的实参: resouce 传入了一个 Xml 布局,root 传入了 consMain

实际又会调用 LayoutInflater 三个参数的 inflate 重载方法:

inflate(@LayoutRes int resource, @Nullable ViewGroup root,boolean attachToRoot)

此时传入实参变为:resouce 传入了一个 Xml 布局,root 传入了 consMain,attachToRoot 传入了 true

根据我们上面源码得到的结论:当传入的 root 不为 null 且 attachToRoot 为 true,此时会将 Xml 布局生成的根 View 通过 addView 方法携带布局参数添加到 root 中

此时我们在 MainActivity 中又重复调用了 addView 方法,因此就报那个错了。如果想不报错,把 MainActivity 中的那行 addView 去掉就可以了

3、预期效果

上述预期效果,我们调用的是 LayoutInflater 三个参数的 inflate 重载方法

传入的实参:resouce 传入了一个 Xml 布局,root 传入了 consMain,attachToRoot 传入了 false

根据我们上面源码得到的结论:当传入的 root 不为 null 且 attachToRoot 为 false,此时会给 Xml 布局生成的根 View 对象设置布局参数

此时根 View 的布局属性会生效,只不过没有被添加到任何 View 中,而又因为 MainActivity 中调用了 addView 方法,把当前根 View 添加了进去,所以达到了我们预期的效果

到这里,你是否明白了 LayoutInflater inflate 方法的应用了呢?

如果还有疑问,欢迎评论区给我提问,我们一起讨论

五、为啥 Activity 中布局根 View 的布局属性会生效?

看下面这张图:

注意:Android 版本号和应用主题会影响到 Activity 页面组成,这里以常见页面为例

image-20210622210219600

我们的页面中有一个顶级 View 叫 DecorView,DecorView 中包含一个竖直方向的 LinearLayout,LinearLayout 由两部分组成,第一部分是标题栏,第二部分是内容栏,内容栏是一个FrameLayout,我们在 Activity 中调用 setContentView 就是将 View 添加到这个FrameLayout 中。

看到这里你应该也明白了:Activity 中布局根 View 的布局属性之所以能生效,是因为 Android 会自动在布局文件的最外层再嵌套一个FrameLayout

六、总结

本篇文章重点内容:

1、 LayoutInflater inflate 方法参数的应用,记住下面这个规律:

  • 当传入的 root 不为 null 且 attachToRoot 为 false,此时会给 Xml 布局生成的根 View 设置布局参数
  • 当传入的 root 不为 null 且 attachToRoot 为 true,此时会将 Xml 布局生成的根 View 通过 addView 方法携带布局参数添加到 root 中
  • 当传入的 root 为 null ,此时会将 Xml 布局生成的根 View 对象直接返回

2、Activity 中布局根 View 的布局属性会生效是因为 Android 会自动在布局文件的最外层再嵌套一个 FrameLayout

收起阅读 »

通俗易懂的Android屏幕刷新机制

前言我们买手机的时候经常听说这个手机多少多少HZ刷新率。目前手机大多都是60HZ,现在有的手机都到144HZ的高刷新率了。这个刷新率指标是干什么的呢?屏幕又是如何将数据显示到Android手机屏幕上的呢?玩游戏时的卡顿是怎么形成的? 基于对这些问题的好奇,小研...
继续阅读 »

前言

我们买手机的时候经常听说这个手机多少多少HZ刷新率。目前手机大多都是60HZ,现在有的手机都到144HZ的高刷新率了。这个刷新率指标是干什么的呢?屏幕又是如何将数据显示到Android手机屏幕上的呢?玩游戏时的卡顿是怎么形成的? 基于对这些问题的好奇,小研究了一番,就有了以下这些内容:

Android屏幕刷新机制导图.png

相关基础概念

人眼视觉残留

当物体在快速运动时, 当人眼所看到的影像消失后,人眼仍能继续保留其影像1/24秒左右的图像,这种现象被称为视觉暂留现象,是人眼具有的一种性质。

这是因为:人眼观看物体时,成像于视网膜上,并由视神经输入人脑,感觉到物体的像。但当物体移去时,视神经对物体的印象不会立即消失,而要延续1/24秒左右的时间。

逐行扫描

显示器并不是一次性将画面显示到屏幕上,而是从左到右边,从上到下逐行扫描,顺序显示整屏的一个个像素点。

帧、帧率(数)、刷新率

在视频领域,是指每一张画面。

需要注意帧率和刷新率不是一个概念:

  • 帧率(frame rate)指的是显卡1秒钟渲染好并发送给显示器多少张画面。

  • 刷新率指的是显示器逐行扫描刷新的速度。以 60 Hz 刷新率的屏幕为例,就是1s会刷60帧,一帧需要1000 / 60 ,约等于16ms,这个速度快到普通人眼感受不到屏幕在扫描。

画面撕裂

画面撕裂的形成,简单点说就是显示器把两帧或两帧以上的数据同时显示在同一个画面的现象。就像这样:

图像撕裂.png

屏幕刷新频率是固定的,通常是60Hz。比如在60Hz的屏幕下,每16.6ms从Buffer取一帧数据并显示。理想情况下,GPU绘制完一帧,显示器显示一帧。

但现在显卡性能大幅提高,帧率太高出现画面撕裂。屏幕刷新频率是固定的,通常是60Hz,如果显卡的输出高于60fps,两者不同步,画面便会显示撕裂的效果。其实,帧率太低也是会出现画面撕裂。

所以背后的本质问题就是,当刷新率和帧率不一致就会出现,就很容易出现画面撕裂现象

拓展知识点:显卡与数据流动到显示屏过程

显卡主要负责把主机向显示器发出的显示信号转化为一般电器信号(数模转换),使得显示器能明白个人电脑在让它做什么。显卡的主要芯片叫“显示芯片”(Video chipset,也叫GPUVPU,图形处理器或视觉处理器),是显卡的主要处理单元。显卡上也有和电脑存储器相似的存储器,称为“显示存储器”,简称显存。

数据离开CPU到达显示屏,中间经历比较关键的步骤:

1.从总线进入GPU:将CPU送来的数据送到北桥(简单理解成连接显卡等高速设备的),再送到GPU里面进行处理

2.将芯片处理完的数据送到显存。

3.从显存读取出数据再送到随机读写存储,数模转换器进行数模转换的工作(但是如果是DVI接口类型的显卡,直接输出数字信号)

4.从DAC进入显示器:将转换完的模拟信号送到显示屏

所以显卡很关键的作用是起数据处理和数模转换。

那么等显示器显示完再去绘制下一帧数据不就没有这个问题了吗?

这么简单一想好像是没问题。但问题关键就出在图像绘制和屏幕读取这一帧数据使用的是一块Buffer。屏幕读取数据过程是无法确保这个Buffer不会被修改。由于屏幕是逐行扫描,它不会被打断仍然会继续上一行的位置扫描,当出现Buffer里有些数据根本没被显示器显示完就被重写了(即Buffer里的数据是来自不同帧的混合),这样就出现了画面撕裂的现象。

双缓存

针对上面的问题关键:图像绘制和屏幕读取这一帧数据使用的是一块Buffer

可以想到的一种解决方案是:不让它们使用同一块Buffer,用两块让它们各自为战不就好了,这么想的思路确实是对的。分析下这个具体过程:

当图像绘制和屏幕显示有各自的Buffer后,GPU将绘制完的一帧图像写入到后缓存(Back Buffer),显示器显示的时候只会去扫描前缓存的数据(Frame Buffer),在显示器未扫描完一帧前,前缓存区内数据不改变,屏幕就只会显示一帧的数据,避免了撕裂。

双缓存.png

但这样做的最关键一步是,什么时候去交换两块Buffer的数据?

Back Buffer准备完一帧数据就进行?这很明显是不可以的,这样就和只有一个缓存区的效果一样了,还是会出现撕裂现象。

根据逐行扫描的特性,当扫描完一个屏幕后,显示器会重新回到第一行进行下次的扫描,在这个间隙过程,屏幕没有在刷新,此时就是进行缓存区交换比较好的时机。

VBlank阶段和帧传递:

显示器扫描完一帧重新回到第一行的过程称为显示器的VBlank阶段。

缓存区交换被称为BufferSwap,帧传递。

Andrid屏幕刷新机制的演变

VSync

那是谁控制这个缓冲区交换时机,或者说专业点,什么时机进行帧传递呢?

这里就要提到VSync了,它翻译过来叫垂直同步,它会强制帧传递发生在显示器的VBlank阶段

需要注意的是:开启垂直同步后,就算显卡准备好了Back Buffer的数据,但显示器没有逐行扫描完前缓冲区的,就不允许发生帧传递。显卡就空载着,等待显示器扫描完毕后的VBlank阶段

这就解释了在玩游戏的时候,如果开启了垂直功能,游戏中显示的帧率一直处于一个帧率之下,这个显示帧率值就是屏幕刷新率。

那这个过程具体是怎么样的,真的就可以解决问题了?上面看着说的很有道理,但抽象到还是似懂非懂...

别急,下面就用几张图带你分析下具体的过程。

Jank

在下面的图中,你将会经常看到Jank一词语,它术语翻译,叫做卡顿。卡顿很容易理解了,比如我们在打游戏时,经常会遇到同一帧画面在那显示很久没有变化,这就是所谓的Jank

场景1

先看下最原始的,只有双缓冲,没有VSync影响下,它会发生什么:

vsync1.png

图中Display 为显示屏, VSync 仅仅指双缓冲的交换。

(1)Display显示第0帧,此时 CPU/GPU 渲染第1帧画面,并且在 Display 显示下一帧前完成。

(2)Display 正常渲染第一帧

(3)出于某种原因,如 CPU 资源被占用,系统没有及时处理第2帧数据,当 Display 显示下一帧时,由于数据没处理完,所以依然显示第1帧,即发生“Jank” ,

Jank术语翻译为卡顿,就是我们打游戏感受到的延迟。

上图出现的情况就是第2帧没有在显示前及时处理,导致屏幕多显示第一帧一次,导致后面的帧都延时了。根本原因是因为第2帧的数据没能在VBlank时(即本次完成到下次扫描开始前的时间间隙)完成。

上图可以看到的是由于CPU资源被抢,导致第2帧的数据处理时机太晚,假设在双缓存交换完成后,CPU资源可以立刻为处理第二帧所用,就可以处理完成该帧的数据(当前前提是该帧的处理数据不超过刷新一帧的时间),也就避免了Jank的出现。

场景2

在双缓冲下,有了VSync会怎么样呢?

vsync2.png

如图,当且仅当收到VSync通知(比如16ms触发一次),CPUGPU 立刻开始计算然后把数据写入BufferVSync同步信号的出现让绘制速度和屏幕刷新速度保持一致,使CPUGPU 充分利用了这16.6 ms的时间,减少了jank。

场景3

但是如果界面比较复杂,CPU/GPU处理时间真的超过16.6ms的话,就会发生:

vsync3.png

图中可以看出当第1个 VSync 到来时GPU还在处理数据,这时缓冲区在处理数据B,被占用了,此时的VBlank阶段就无法进行缓冲区交换,屏幕依然显示前缓冲区的数据A,发生了jank。当下一个信号到来时,此时 GPU 已经处理完了,那么就可以交换缓冲区,此时屏幕就会显示交互后缓冲区的数据B了。

由于硬件性能限制,我们无法改变 CPU/GPU 渲染的时间,所以第一次的Jank是无法避免的,但是在第二次信号来的时候,由于GPU占用了后缓冲区,没能实现缓冲区交换,导致屏幕依然显示上一帧A。由于此时,后缓冲区被占用了,就算此时CPU是空闲的也不能处理下一帧数据。增大了后期Jank的概率,比如图中第二个Jank的出现。

出现该问题本质的原因是,两个缓冲区各自被GPU/CPU、屏幕显示所占用。导致下一帧的数据不能被处理

三缓存

找到问题的本质了,那很容易想到,再加一个Buffer(这里叫它中Buffer)参与,让添加的这个中Buffer后Buffer交换,这样既不会影响到显示器读取前Buffer,又可以在后Buffer缓冲区不能处理时,让中Buffer来处理。像下图这样:

vsync4.png

当第一个信号到来时,前缓冲区在显示A、后缓冲区在处理B,它们都被占用。此时 CPU 就可以使用中缓冲区,来处理下一帧数据C。这样的话,C数据可以提前处理完成,之前第二次发生的Jank就不存在了,有效的降低了Jank出现的几率。

到这里,可以看出,不管是双缓冲和三缓冲,都会有卡顿、延时问题,只是三缓冲下,减少了卡顿的次数。

那是不是 Buffer 越多越好呢?

答案是否定的,Buffer存储的缓存数据是占有内存的,Buffer越多,缓存数据就越多,内存占用就会增大,所以Buffer只要3个就足够了。

Choreographer

那么在Android App层面,呈现在我们眼前的视觉效果(比如动画)是怎么出来的?是否和上述介绍的屏幕刷新机制呼应?或者说,它是怎么基于这个刷新机制原理实现的UI刷新?

对UI绘制流程熟悉的都知道,UI绘制会先走到ViewRootImpl#scheduleTraversals(),之后才会执行UI绘制。

#ViewRootImpl
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
//重点关注这里:绘制的操作封装在mTraversalRunnable里,交给`Choreographer`类处理
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
//...
}
}

重点关注mChoreographer.postCallback(..),UI绘制的操作被封装在mTraversalRunnable里,交由mChoreographerpostCallback方法处理。

mChoreographerChoreographer对象。那Choreographer类是做啥的呢,翻译为编舞者。这个类的命名很有意思,直接意思感觉和绘制毫无关联。但一只舞蹈的节奏控制是由编舞者掌控,就像绘制的过程的时机也需要类似这样一个角色控制一般。可见这个类的作者应该很喜欢舞蹈吧~

走入mChoreographer.postCallback看看做了什么

#Choreographer
public void postCallback(int callbackType, Runnable action, Object token) {
postCallbackDelayed(callbackType, action, token, 0);
}
public void postCallbackDelayed(int callbackType,
Runnable action, Object token, long delayMillis) {
//...
postCallbackDelayedInternal(callbackType, action, token, delayMillis);
}

真正做事的是postCallbackDelayedInternal

#Choreographer
private void postCallbackDelayedInternal(int callbackType,
Object action, Object token, long delayMillis) {
synchronized (mLock) {
//把当前的runnable加入到callback队列中
mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);
//达到期限时间
if (dueTime <= now) {
scheduleFrameLocked(now);
} else {
Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
msg.arg1 = callbackType;
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, dueTime);
}
}
}

如果这个任务达到约定的延时时间,那么就会直接执行scheduleFrameLocked方法,如果没有达到就通过Handler发送一个延时异步消息,最终也会走到scheduleFrameLocked方法:

#Choreographer
//默认使用VSync同步机制
private static final boolean USE_VSYNC = SystemProperties.getBoolean(
"debug.choreographer.vsync", true);
private void scheduleFrameLocked(long now) {
if (!mFrameScheduled) {
mFrameScheduled = true;
//是否使用VSync同步机制
if (USE_VSYNC) {
//是否在主线程
if (isRunningOnLooperThreadLocked()) {
scheduleVsyncLocked();
} else {
Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSYNC);
msg.setAsynchronous(true);
mHandler.sendMessageAtFrontOfQueue(msg);
}
} else {
final long nextFrameTime = Math.max(
mLastFrameTimeNanos / TimeUtils.NANOS_PER_MS + sFrameDelay, now);
if (DEBUG_FRAMES) {
Log.d(TAG, "Scheduling next frame in " + (nextFrameTime - now) + " ms.");
}
Message msg = mHandler.obtainMessage(MSG_DO_FRAME);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, nextFrameTime);
}
}
}

scheduleFrameLocked()会根据是否是使用VSync同步机制,来执行不同的逻辑。下面顺着使用同步的情况分析:

判断当前线程的Looper是否是创建Choreographer时的线程Looper,由于是在ViewRootImpl中传入的,正常情况它是在主线程,所以就等价于判断当前线程是否在主线程。

如果不是就把这个消息加入到主线程,不管如何,最后都会走到scheduleVsyncLocked方法:

#Choreographer
private final FrameDisplayEventReceiver mDisplayEventReceiver;
private void scheduleVsyncLocked() {
//调用DisplayEventReceiver的scheduleVsync
mDisplayEventReceiver.scheduleVsync();
}

mDisplayEventReceiverFrameDisplayEventReceiver的对象。而FrameDisplayEventReceiver继承了DisplayEventReceiver这个抽象类。

DisplayEventReceiver如它的命名一样直观,显示事件的接收者。在DisplayEventReceiver的构造方法里面,会调用native方法nativeInit初始化一个接收者。在scheduleVsync方法里面,会调用native方法nativeScheduleVsync,把初始化的接收者对象传进去。

#DisplayEventReceiver
public abstract class DisplayEventReceiver {
public DisplayEventReceiver(Looper looper, int vsyncSource) {
//初始化一个接收者
mReceiverPtr = nativeInit(new WeakReference<DisplayEventReceiver>(this), mMessageQueue,
vsyncSource);
}

public void scheduleVsync() {
//初始化的接收者对象mReceiverPtr传进去
nativeScheduleVsync(mReceiverPtr);
}
}

FrameDisplayEventReceiverDisplayEventReceiver更具体一点,叫做帧显示的事件接收者。在前面介绍过,当收到同步信号过来后,就希望显示下一帧数据。那是怎么接收同步信号的呢?魔法就在上述那两个native方法里面,调用这两个方法之后。就会接收到`onVsync'方法的回调。这就是同步信号到来的时机。

#Choreographer.FrameDisplayEventReceiver
private final class FrameDisplayEventReceiver extends DisplayEventReceiver
implements Runnable {
public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
super(looper, vsyncSource);
}

@Override
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
//...
long now = System.nanoTime();
mTimestampNanos = timestampNanos;
mFrame = frame;
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
//timestampNanos / TimeUtils.NANOS_PER_MS 时间后走run方法
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}

@Override
public void run() {
mHavePendingVsync = false;
//接收到同步信号后执行
doFrame(mTimestampNanos, mFrame);
}
}

onVsync里,主要做的一件事就是在发送一个延时消息,时间是同步信号的时间戳,因为这个类是一个Runnable,这个消息会在run方法里面处理,之后就会执行doFrame()方法。

doFrame()从它的命名,十有八九就是我们一直提的接收到VSync同步信号后,处理帧数据的地方了:

void doFrame(long frameTimeNanos, int frame) {
final long startNanos;
synchronized (mLock) {
if (!mFrameScheduled) {
return; // no work to do
}
long intendedFrameTimeNanos = frameTimeNanos;
startNanos = System.nanoTime();
//抖动时间: 当前时间 - 同步信号通知的时间
final long jitterNanos = startNanos - frameTimeNanos;
//mFrameIntervalNanos = (long)(1000000000 / getRefreshRate()) 类似 1s/60hz = 16.6ms,不过这里是纳秒为单位
//抖动时间超过了一帧刷新的时间,即发生了Jank
if (jitterNanos >= mFrameIntervalNanos) {
final long skippedFrames = jitterNanos / mFrameIntervalNanos;
//计算调帧数,超过一定限制(默认30),就表示应用在主线程做了大量工作,影响了绘制,打印提示
if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
Log.i(TAG, "Skipped " + skippedFrames + " frames! "
+ "The application may be doing too much work on its main thread.");
}
final long lastFrameOffset = jitterNanos % mFrameIntervalNanos;
frameTimeNanos = startNanos - lastFrameOffset;
}
//...
}

try {
//按顺序执行任务(这里只留了核心代码)
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
} finally {}
}

在doFrame的最后,按顺序根据CallBack的类型执行任务,和我们在本节最开始的ViewRootImpl的这部分代码,关连起来了。我们post的这个类型是 Choreographer.CALLBACK_TRAVERSAL

 mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

终于快结束的节奏了,看看doCallbacks是做什么的

void doCallbacks(int callbackType, long frameTimeNanos) {
CallbackRecord callbacks;
synchronized (mLock) {
final long now = System.nanoTime();
callbacks = mCallbackQueues[callbackType].extractDueCallbacksLocked(
now / TimeUtils.NANOS_PER_MS);
if (callbacks == null) {
return;
}
mCallbacksRunning = true;
try {
for (CallbackRecord c = callbacks; c != null; c = c.next) {
//注意这里:执行CallbackRecord的run方法
c.run(frameTimeNanos);
}
} finally {
synchronized (mLock) {
mCallbacksRunning = false;
do {
final CallbackRecord next = callbacks.next;
//回收处理完的CallbackRecord
recycleCallbackLocked(callbacks);
callbacks = next;
} while (callbacks != null);
}
}
}

CallbackRecord是记录callBack信息的类,它是个链表结构,具有next指针。它记录了callback所要执行任务或者说行为,比如Runnbable或者FrameCallback

private static final class CallbackRecord {
public CallbackRecord next;
public long dueTime;
public Object action; // Runnable or FrameCallback
public Object token;

public void run(long frameTimeNanos) {
if (token == FRAME_CALLBACK_TOKEN) {
((FrameCallback)action).doFrame(frameTimeNanos);
} else {
//执行我们最初post的Runnable
((Runnable)action).run();
}
}
}

对应我们最开始的postCallback方法,这个action也就是我们的mTraversalRunnable

 mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

到这里,postCallback的操作形成一个完整的闭环。关于Choreographer的介绍也就算完了。

总结

最后,我想分享一下本文的构思过程:

1.以屏幕显示的基础概念谈起,了解屏幕上的像素点是怎么显示出来的,对后面屏幕刷新的理解会变得更容易。

2.分析Android屏幕刷新机制的演变过程,更轻松的理解目前的刷新机制是怎么出来的,为什么要有双缓冲、三缓冲。

3.从ViewRootImpl的触发绘制为开始,到ChoreographerdoCallbacks结束,形成了完整的闭环。通过对这部分源码的分析,看到Choreographer这个编舞者是如何利用VSync同步机制,来掌控整个UI的刷新过程。

收起阅读 »

OpenGL ES 文字渲染

在音视频或 OpenGL 开发中,文字渲染是一个高频使用的功能,比如制作一些酷炫的字幕、为视频添加水印、设置特殊字体等等。实际上 OpenGL 并没有定义渲染文字的方式,所以我们最能想到的办法是:将带有文字的图像上传到纹理,然后进行纹理贴图。本文分别介绍下在应...
继续阅读 »

在音视频或 OpenGL 开发中,文字渲染是一个高频使用的功能,比如制作一些酷炫的字幕、为视频添加水印、设置特殊字体等等。

实际上 OpenGL 并没有定义渲染文字的方式,所以我们最能想到的办法是:将带有文字的图像上传到纹理,然后进行纹理贴图。

本文分别介绍下在应用层和 C++ 层常用的文字渲染方式。

OpenGL ES 文字渲染

基于 Canvas 绘制生成 Bitmap

在应用层实现文字渲染主要是利用 Canvas 将文本绘制成 Bitmap ,然后生成一张小图,然后在渲染的时候进行贴图。

在实际的生产环境中,一般会将这张小图转换成灰度图,减少不必要的数据拷贝和内存占用,然后在渲染的时候可以为灰度图上色,作为字体的颜色。

// 创建一个 bitmap 
Bitmap bitmap = Bitmap.createBitmap(width, hight, Bitmap.Config.ARGB_8888);
// 初始化画布绘制的图像到 bitmap 上
Canvas canvas = new Canvas(bitmap);
// 建立画笔
Paint paint = new Paint();
// 获取更清晰的图像采样,防抖动
paint.setDither(true);
paint.setFilterBitmap(true);
// 绘制文字到 bitmap
canvas.drawText text, x, y,paint);

然后生成纹理,将 bitmap 上传到纹理。

int[] textureIds = new int[1];
//创建纹理
GLES20.glGenTextures(1, textureIds, 0);
mTexId = textureIds[0];
//绑定纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTexId);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

ByteBuffer bitmapBuffer = ByteBuffer.allocate(bitmap.getHeight() * bitmap.getWidth() * 4);//RGBA
bitmap.copyPixelsToBuffer(bitmapBuffer);
bitmapBuffer.flip();

//设置内存大小绑定内存地址
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, mWatermarkBitmap.getWidth(), mWatermarkBitmap.getHeight(),
0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, bitmapBuffer);

//解绑纹理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

最后将带有文字的纹理映射到对应的位置(纹理贴图)。

FreeType

FreeType 是一个基于 C 语言实现的用于文字渲染的开源库,它小巧、高效、高度可定制,主要用于加载字体并将其渲染到位图,支持多种字体的相关操作。

FreeType 也是一个非常受欢迎的跨平台字体库,支持 Android、 iOS、 Linux 等操作系统。TrueType 字体不采用像素或其他不可缩放的方式来定义,而是一些通过数学公式(曲线的组合)。这些字形,类似于矢量图像,可以根据你需要的字体大小来生成像素图像。

FreeType 官网地址:

https://www.freetype.org/

FreeType 编译

本小节主要介绍使用 NDK 编译 Android 平台使用的 FreeType 库。首先在官网上下载最新版的 FreeType 源码,然后新建一个 jni 文件夹,将源码放到 jni 文件夹里,目录结构如下所示:

FreeType 目录结构

新建构建文件 Android.mk 和 Application.mk。

Android.mk 参考 Google 的构建脚本:

LOCAL_PATH:= $(call my-dir)

include $(CLEAR_VARS)


LOCAL_SRC_FILES := \
./src/autofit/autofit.c \
./src/base/ftbase.c \
./src/base/ftbbox.c \
./src/base/ftbdf.c \
./src/base/ftbitmap.c \
./src/base/ftcid.c \
./src/base/ftdebug.c \
./src/base/ftfstype.c \
./src/base/ftgasp.c \
./src/base/ftglyph.c \
./src/base/ftgxval.c \
./src/base/ftinit.c \
./src/base/ftlcdfil.c \
./src/base/ftmm.c \
./src/base/ftotval.c \
./src/base/ftpatent.c \
./src/base/ftpfr.c \
./src/base/ftstroke.c \
./src/base/ftsynth.c \
./src/base/ftsystem.c \
./src/base/fttype1.c \
./src/base/ftwinfnt.c \
./src/bdf/bdf.c \
./src/bzip2/ftbzip2.c \
./src/cache/ftcache.c \
./src/cff/cff.c \
./src/cid/type1cid.c \
./src/gzip/ftgzip.c \
./src/lzw/ftlzw.c \
./src/pcf/pcf.c \
./src/pfr/pfr.c \
./src/psaux/psaux.c \
./src/pshinter/pshinter.c \
./src/psnames/psmodule.c \
./src/raster/raster.c \
./src/sfnt/sfnt.c \
./src/smooth/smooth.c \
./src/tools/apinames.c \
./src/truetype/truetype.c \
./src/type1/type1.c \
./src/type42/type42.c \
./src/winfonts/winfnt.c



LOCAL_C_INCLUDES += $(LOCAL_PATH)/include

LOCAL_CFLAGS += -W -Wall
LOCAL_CFLAGS += -fPIC -DPIC
LOCAL_CFLAGS += "-DDARWIN_NO_CARBON"
LOCAL_CFLAGS += "-DFT2_BUILD_LIBRARY"

LOCAL_CFLAGS += -O2

LOCAL_MODULE:= freetype

include $(BUILD_STATIC_LIBRARY)
#https://android.googlesource.com/platform/external/freetype/+/android-6.0.1_r28/Android.mk

Application.mk:

APP_OPTIM := release
APP_CPPFLAGS := -std=c++14 -frtti
NDK_TOOLCHAIN_VERSION := clang
APP_PLATFORM := android-28
APP_STL := c++_static
APP_ABI := arm64-v8a,armeabi-v7a

最后 jni 目录下命令行执行 ndk-build 指令即可,如果不想编译,也可以直接到下面项目取现成的静态库:

https://github.com/githubhaohao/NDK_OpenGLES_3_0

OpenGL 使用 FreeType 渲染文字

FreeType 的使用

引入头文件:

#include "ft2build.h"
#include

然后要加载一个字体,我们需要做的是初始化 FreeType 并且将这个字体加载为 FreeType 称之为面 Face 的东西。这里我在 Windows 下找了个字体文件 Antonio-Regular.ttf ,放到 sdcard 下面供 FreeType 加载。


FT_Library ft;

if (FT_Init_FreeType(&ft))
LOGCATE("TextRenderSample::LoadFacesByASCII FREETYPE: Could not init FreeType Library");


FT_Face face;
if (FT_New_Face(ft, "/sdcard/fonts/Antonio-Regular.ttf", 0, &face))
LOGCATE("TextRenderSample::LoadFacesByASCII FREETYPE: Failed to load font");


FT_Set_Pixel_Sizes(face, 0, 96);

代码片段中,FT_Set_Pixel_Sizes 用于设置文字的大小,此函数设置了字体面的宽度和高度,将宽度值设为0表示我们要从字体面通过给出的高度中动态计算出字形的宽度。

一个字体面中 Face 包含了所有字形的集合,我们可以通过调用 FT_Load_Char 函数来激活当前要表示的字形。这里我们选在加载字母字形 'A':

if (FT_Load_Char(face, 'A', FT_LOAD_RENDER))
std::cout << "ERROR::FREETYTPE: Failed to load Glyph" << std::endl;

通过将 FT_LOAD_RENDER 设为一个加载标识,我们告诉 FreeType 去创建一个 8 位的灰度位图,我们可以通过face->glyph->bitmap 来取得这个位图。

使用 FreeType 加载的字形位图并不像我们使用位图字体那样持有相同的尺寸大小。使用FreeType生产的字形位图的大小是恰好能包含这个字形的尺寸。例如生产用于表示 '.' 的位图的尺寸要比表示 'A' 的小得多。

因此,FreeType在加载字形的时候还生产了几个度量值来描述生成的字形位图的大小和位置。下图展示了 FreeType 的所有度量值的涵义。

glyph.png

那么多属性其实不用刻意取记住,这里只是作为概念性了解。最后,使用完 FreeType 记得释放相关资源:

FT_Done_Face(face);
FT_Done_FreeType(ft);

OpenGL 文字渲染

按照前面的思路,使用 FreeType 加载字形的位图然后生成纹理,然后进行纹理贴图。

然而每次渲染的时候都去重新加载位图显然不是高效的,我们应该将这些生成的数据储存在应用程序中,在渲染过程中再去取,重复利用。

方便起见,我们需要定义一个用来储存这些属性的结构体,并创建一个字符表来存储这些字形属性。

struct Character {
GLuint textureID; // ID handle of the glyph texture
glm::ivec2 size; // Size of glyph
glm::ivec2 bearing; // Offset from baseline to left/top of glyph
GLuint advance; // Horizontal offset to advance to next glyph
};

std::map m_Characters;

简单起见,我们只生成表示 128 个 ASCII 字符的字符表,并为每一个字符储存纹理和一些度量值。这样,所有需要的字符就被存下来备用了。

void TextRenderSample::LoadFacesByASCII() {
// FreeType
FT_Library ft;
// All functions return a value different than 0 whenever an error occurred
if (FT_Init_FreeType(&ft))
LOGCATE("TextRenderSample::LoadFacesByASCII FREETYPE: Could not init FreeType Library");

// Load font as face
FT_Face face;
if (FT_New_Face(ft, "/sdcard/fonts/Antonio-Regular.ttf", 0, &face))
LOGCATE("TextRenderSample::LoadFacesByASCII FREETYPE: Failed to load font");

// Set size to load glyphs as
FT_Set_Pixel_Sizes(face, 0, 96);

// Disable byte-alignment restriction
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

// Load first 128 characters of ASCII set
for (unsigned char c = 0; c < 128; c++)
{
// Load character glyph
if (FT_Load_Char(face, c, FT_LOAD_RENDER))
{
LOGCATE("TextRenderSample::LoadFacesByASCII FREETYTPE: Failed to load Glyph");
continue;
}
// Generate texture
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_LUMINANCE,
face->glyph->bitmap.width,
face->glyph->bitmap.rows,
0,
GL_LUMINANCE,
GL_UNSIGNED_BYTE,
face->glyph->bitmap.buffer
);

// Set texture options
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// Now store character for later use
Character character = {
texture,
glm::ivec2(face->glyph->bitmap.width, face->glyph->bitmap.rows),
glm::ivec2(face->glyph->bitmap_left, face->glyph->bitmap_top),
static_cast(face->glyph->advance.x)
};
m_Characters.insert(std::pair(c, character));
}
glBindTexture(GL_TEXTURE_2D, 0);
// Destroy FreeType once we're finished
FT_Done_Face(face);
FT_Done_FreeType(ft);

}

针对 OpenGL ES 灰度图要使用的纹理格式是 GL_LUMINANCE 而不是 GL_RED 。

OpenGL 纹理对应的图像默认要求 4 字节对齐,这里需要设置为 1 ,确保宽度不是 4 倍数的位图(灰度图)能够正常渲染。

渲染文字使用的 shader :

//vertex shader
#version 300 es
layout(location = 0) in vec4 a_position;//
uniform mat4 u_MVPMatrix;
out vec2 v_texCoord;
void main()
{
gl_Position = u_MVPMatrix * vec4(a_position.xy, 0.0, 1.0);;
v_texCoord = a_position.zw;
}

//fragment shader
#version 300 es
precision mediump float;
in vec2 v_texCoord;
layout(location = 0) out vec4 outColor;
uniform sampler2D s_textTexture;
uniform vec3 u_textColor;

void main()
{
vec4 color = vec4(1.0, 1.0, 1.0, texture(s_textTexture, v_texCoord).r);
outColor = vec4(u_textColor, 1.0) * color;
}

片段着色器有两个 uniform 变量:一个是单颜色通道的字形位图纹理,另一个是文字的颜色,我们可以同调整它来改变最终输出的字体颜色。

开启混合,去掉文字背景。

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

生成一个 VAO 和一个 VBO ,用于管理的存储顶点、纹理坐标数据,GL_DYNAMIC_DRAW 表示我们后面要使用 glBufferSubData 不断刷新 VBO 的缓存。


glGenVertexArrays(1, &m_VaoId);
glGenBuffers(1, &m_VboId);

glBindVertexArray(m_VaoId);
glBindBuffer(GL_ARRAY_BUFFER, m_VboId);
glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * 6 * 4, nullptr, GL_DYNAMIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), 0);
glBindBuffer(GL_ARRAY_BUFFER, GL_NONE);
glBindVertexArray(GL_NONE);

每个 2D 方块需要 6 个顶点,每个顶点又是由一个 4 维向量(一个纹理坐标和一个顶点坐标)组成,因此我们将VBO 的内存分配为 6*4 个 float 的大小。

最后进行文字渲染,其中传入 viewport 主要是针对屏幕坐标进行归一化:

void TextRenderSample::RenderText(std::string text, GLfloat x, GLfloat y, GLfloat scale,
glm::vec3 color, glm::vec2 viewport) {
// 激活合适的渲染状态
glUseProgram(m_ProgramObj);
glUniform3f(glGetUniformLocation(m_ProgramObj, "u_textColor"), color.x, color.y, color.z);
glBindVertexArray(m_VaoId);
GO_CHECK_GL_ERROR();
// 对文本中的所有字符迭代
std::string::const_iterator c;
x *= viewport.x;
y *= viewport.y;
for (c = text.begin(); c != text.end(); c++)
{
Character ch = m_Characters[*c];

GLfloat xpos = x + ch.bearing.x * scale;
GLfloat ypos = y - (ch.size.y - ch.bearing.y) * scale;

xpos /= viewport.x;
ypos /= viewport.y;

GLfloat w = ch.size.x * scale;
GLfloat h = ch.size.y * scale;

w /= viewport.x;
h /= viewport.y;

LOGCATE("TextRenderSample::RenderText [xpos,ypos,w,h]=[%f, %f, %f, %f]", xpos, ypos, w, h);

// 当前字符的VBO
GLfloat vertices[6][4] = {
{ xpos, ypos + h, 0.0, 0.0 },
{ xpos, ypos, 0.0, 1.0 },
{ xpos + w, ypos, 1.0, 1.0 },

{ xpos, ypos + h, 0.0, 0.0 },
{ xpos + w, ypos, 1.0, 1.0 },
{ xpos + w, ypos + h, 1.0, 0.0 }
};

// 在方块上绘制字形纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, ch.textureID);
glUniform1i(m_SamplerLoc, 0);
GO_CHECK_GL_ERROR();
// 更新当前字符的VBO
glBindBuffer(GL_ARRAY_BUFFER, m_VboId);
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices);
GO_CHECK_GL_ERROR();
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 绘制方块
glDrawArrays(GL_TRIANGLES, 0, 6);
GO_CHECK_GL_ERROR();
// 更新位置到下一个字形的原点,注意单位是1/64像素
x += (ch.advance >> 6) * scale; //(2^6 = 64)
}
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
}

使用 RenderText 渲染 2 个文本:

	// (x,y)为屏幕坐标系的位置,即原点位于屏幕中心,x(-1.0,1.0), y(-1.0,1.0)
RenderText("My WeChat ID is Byte-Flow.", -0.9f, 0.2f, 1.0f, glm::vec3(0.8, 0.1f, 0.1f), viewport);
RenderText("Welcome to add my WeChat.", -0.9f, 0.0f, 2.0f, glm::vec3(0.2, 0.4f, 0.7f), viewport);

完整实现代码见项目: github.com/githubhaoha…

收起阅读 »

Android 第三方RoundedImageView设置各种圆形、方形头像

Android 自定义CoolImageView实现QQ首页背景图片动画效果一.第三方RoundedImageView1.在Android Studio中,可进入模块设置中添加库依赖。 进入Module结构设置添加库依赖 ,输入Rounde...
继续阅读 »

Android 自定义CoolImageView实现QQ首页背景图片动画效果




一.第三方RoundedImageView

1.在Android Studio中,可进入模块设置中添加库依赖。 进入Module结构设置添加库依赖 ,输入RoundedImageView然后搜索添加

2.在Moudle的build.gradle中添加如下代码,添加完之后在Build中进行下Make Module操作(编译下Module),使自己添加的依赖生效

repositories {

mavenCentral()

}

dependencies {

compile 'com.makeramen:roundedimageview:2.2.1'

}

3.添加相关属性:

控件属性: 

riv_border_width: 边框宽度

riv_border_color: 边框颜色

riv_oval: 是否圆形

riv_corner_radius: 圆角弧度

riv_corner_radius_top_left:左上角弧度

riv_corner_radius_top_right: 右上角弧度

riv_corner_radius_bottom_left:左下角弧度

riv_corner_radius_bottom_right:右下角弧度

4.示例布局:

 <com.makeramen.roundedimageview.RoundedImageView

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:src="@mipmap/avatar"

app:riv_border_color="#333333"

app:riv_border_width="2dp"

app:riv_oval="true" />

<com.makeramen.roundedimageview.RoundedImageView

xmlns:app="http://schemas.android.com/apk/res-auto"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:scaleType="fitCenter"

android:src="@mipmap/avatar"

app:riv_border_color="#333333"

app:riv_border_width="2dp"

app:riv_corner_radius="10dp"

app:riv_mutate_background="true"

app:riv_oval="false"

app:riv_tile_mode="repeat" />

<com.makeramen.roundedimageview.RoundedImageView

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:scaleType="fitCenter"

android:src="@mipmap/avatar"

app:riv_border_color="#333333"

app:riv_border_width="2dp"

app:riv_corner_radius_top_left="25dp"

app:riv_corner_radius_bottom_right="25dp"

app:riv_mutate_background="true"

app:riv_oval="false"

app:riv_tile_mode="repeat" />

<com.makeramen.roundedimageview.RoundedImageView

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:scaleType="fitCenter"

android:src="@mipmap/avatar"

app:riv_border_color="#333333"

app:riv_border_width="2dp"

app:riv_corner_radius_top_right="25dp"

app:riv_corner_radius_bottom_left="25dp"

app:riv_mutate_background="true"

app:riv_oval="false"

app:riv_tile_mode="repeat" />

<com.makeramen.roundedimageview.RoundedImageView

android:layout_width="96dp"

android:layout_height="72dp"

android:scaleType="center"

android:src="@mipmap/avatar"

app:riv_border_color="#333333"

app:riv_border_width="2dp"

app:riv_corner_radius="25dp"

app:riv_mutate_background="true"

app:riv_oval="true"

app:riv_tile_mode="repeat" />

 <com.makeramen.roundedimageview.RoundedImageView

android:id="@+id/imCompanyHeadItem"

android:layout_width="50dp"

android:layout_marginTop="10dp"

android:layout_marginRight="6.5dp"

android:layout_marginLeft="6.5dp"

android:src="@drawable/head_home"

android:layout_gravity="center"

android:layout_height="50dp"

app:riv_border_color="@color/_c7ced8"

app:riv_border_width="1dp"

app:riv_corner_radius_top_left="5dp"

app:riv_corner_radius_bottom_right="5dp"

app:riv_corner_radius_bottom_left="5dp"

app:riv_corner_radius_top_right="5dp"

app:riv_mutate_background="true"

app:riv_oval="false"

app:riv_tile_mode="repeat"/>

二.自定义RoundImageView

1.布局:

 <com.iruiyou.pet.utils.RoundImageView

android:id="@+id/headIv"

android:layout_width="125dp"

android:layout_height="125dp"

android:layout_marginTop="92dp"

android:src="@drawable/head_home"

loonggg:border_incolor="#000fff"

loonggg:border_outcolor="#fff000"

loonggg:border_width="10dp"

app:layout_constraintLeft_toLeftOf="parent"

app:layout_constraintRight_toRightOf="parent"

app:layout_constraintTop_toTopOf="parent"/>

2.自定义类:

import android.content.Context;

import android.content.res.TypedArray;

import android.graphics.Bitmap;

import android.graphics.Canvas;

import android.graphics.Paint;

import android.graphics.PorterDuff;

import android.graphics.PorterDuffXfermode;

import android.graphics.Rect;

import android.graphics.drawable.BitmapDrawable;

import android.graphics.drawable.Drawable;

import android.graphics.drawable.NinePatchDrawable;

import android.util.AttributeSet;

import android.widget.ImageView;

import com.iruiyou.pet.R;



/**

*


* @author sgf


* 自定义圆形头像


*


*/


public class RoundImageView extends ImageView {

private int mBorderThickness = 0;

private Context mContext;

private int defaultColor = 0xFFFFFFFF;

// 外圆边框颜色

private int mBorderOutsideColor = 0;

// 内圆边框颜色

private int mBorderInsideColor = 0;

// RoundImageView控件默认的长、宽

private int defaultWidth = 0;

private int defaultHeight = 0;



public RoundImageView(Context context) {

super(context);

mContext = context;

}



public RoundImageView(Context context, AttributeSet attrs) {

super(context, attrs);

mContext = context;

// 设置RoundImageView的属性值,比如颜色,宽度等

setRoundImageViewAttributes(attrs);

}



public RoundImageView(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

mContext = context;

setRoundImageViewAttributes(attrs);

}



// 从attr.xml文件中获取属性值,并给RoundImageView设置

private void setRoundImageViewAttributes(AttributeSet attrs) {

TypedArray a = mContext.obtainStyledAttributes(attrs,

R.styleable.round_image_view);

mBorderThickness = a.getDimensionPixelSize(

R.styleable.round_image_view_border_width, 0);

mBorderOutsideColor = a.getColor(

R.styleable.round_image_view_border_outcolor, defaultColor);

mBorderInsideColor = a.getColor(

R.styleable.round_image_view_border_incolor, defaultColor);

a.recycle();

}



// 具体解释:比如我自定义一个控件,怎么实现呢,以RoundImageView为例,首先是继承ImageView,然后实现其构造函数,在构造函数中,获取attr中的属性值(再次解释:这里获取的具体的这个属性的值是怎么来的呢?比如颜色和宽度,这个在attr.xml中定义了相关的名字,而在使用RoundImageView的xml布局文件中,我们会设置其值,这里需要用的值,就是从那里设置的),并设置在本控件中,然后继承onDraw方法,画出自己想要的图形或者形状即可

/**

* 这个是继承的父类的onDraw方法


*


* onDraw和下面的方法不用管,基本和学习自定义没关系,就是实现怎么画圆的,你可以改变下面代码试着画三角形头像,哈哈


*/


@Override

protected void onDraw(Canvas canvas) {

Drawable drawable = getDrawable();

if (drawable == null) {

return;

}

if (getWidth() == 0 || getHeight() == 0) {

return;

}

this.measure(0, 0);

if (drawable.getClass() == NinePatchDrawable.class)

return;

Bitmap b = ((BitmapDrawable) drawable).getBitmap();

Bitmap bitmap = b.copy(Bitmap.Config.ARGB_8888, true);

if (defaultWidth == 0) {

defaultWidth = getWidth();

}

if (defaultHeight == 0) {

defaultHeight = getHeight();

}

int radius = 0;

// 这里的判断是如果内圆和外圆设置的颜色值不为空且不是默认颜色,就定义画两个圆框,分别为内圆和外圆边框

if (mBorderInsideColor != defaultColor

&& mBorderOutsideColor != defaultColor) {

radius = (defaultWidth < defaultHeight ? defaultWidth

: defaultHeight) / 2 - 2 * mBorderThickness;

// 画内圆

drawCircleBorder(canvas, radius + mBorderThickness / 2,

mBorderInsideColor);

// 画外圆

drawCircleBorder(canvas, radius + mBorderThickness

+ mBorderThickness / 2, mBorderOutsideColor);

} else if (mBorderInsideColor != defaultColor

&& mBorderOutsideColor == defaultColor) {// 这里的是如果内圆边框不为空且颜色值不是默认值,就画一个内圆的边框

radius = (defaultWidth < defaultHeight ? defaultWidth

: defaultHeight) / 2 - mBorderThickness;

drawCircleBorder(canvas, radius + mBorderThickness / 2,

mBorderInsideColor);

} else if (mBorderInsideColor == defaultColor

&& mBorderOutsideColor != defaultColor) {// 这里的是如果外圆边框不为空且颜色值不是默认值,就画一个外圆的边框

radius = (defaultWidth < defaultHeight ? defaultWidth

: defaultHeight) / 2 - mBorderThickness;

drawCircleBorder(canvas, radius + mBorderThickness / 2,

mBorderOutsideColor);

} else {// 这种情况是没有设置属性颜色的情况下,即没有边框的情况

radius = (defaultWidth < defaultHeight ? defaultWidth

: defaultHeight) / 2;

}

Bitmap roundBitmap = getCroppedRoundBitmap(bitmap, radius);

canvas.drawBitmap(roundBitmap, defaultWidth / 2 - radius, defaultHeight

/ 2 - radius, null);

}



/**

* 获取裁剪后的圆形图片


*


* @param bmp


* @param radius


* 半径


* @return


*/


public Bitmap getCroppedRoundBitmap(Bitmap bmp, int radius) {

Bitmap scaledSrcBmp;

int diameter = radius * 2;

// 为了防止宽高不相等,造成圆形图片变形,因此截取长方形中处于中间位置最大的正方形图片

int bmpWidth = bmp.getWidth();

int bmpHeight = bmp.getHeight();

int squareWidth = 0, squareHeight = 0;

int x = 0, y = 0;

Bitmap squareBitmap;

if (bmpHeight > bmpWidth) {// 高大于宽

squareWidth = squareHeight = bmpWidth;

x = 0;

y = (bmpHeight - bmpWidth) / 2;

// 截取正方形图片

squareBitmap = Bitmap.createBitmap(bmp, x, y, squareWidth,

squareHeight);

} else if (bmpHeight < bmpWidth) {// 宽大于高

squareWidth = squareHeight = bmpHeight;

x = (bmpWidth - bmpHeight) / 2;

y = 0;

squareBitmap = Bitmap.createBitmap(bmp, x, y, squareWidth,

squareHeight);

} else {

squareBitmap = bmp;

}

if (squareBitmap.getWidth() != diameter

|| squareBitmap.getHeight() != diameter) {

scaledSrcBmp = Bitmap.createScaledBitmap(squareBitmap, diameter,

diameter, true);

} else {

scaledSrcBmp = squareBitmap;

}

Bitmap output = Bitmap.createBitmap(scaledSrcBmp.getWidth(),

scaledSrcBmp.getHeight(), Bitmap.Config.ARGB_8888);

Canvas canvas = new Canvas(output);



Paint paint = new Paint();

Rect rect = new Rect(0, 0, scaledSrcBmp.getWidth(),

scaledSrcBmp.getHeight());



paint.setAntiAlias(true);

paint.setFilterBitmap(true);

paint.setDither(true);

canvas.drawARGB(0, 0, 0, 0);

canvas.drawCircle(scaledSrcBmp.getWidth() / 2,

scaledSrcBmp.getHeight() / 2, scaledSrcBmp.getWidth() / 2,

paint);

paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

canvas.drawBitmap(scaledSrcBmp, rect, rect, paint);

bmp = null;

squareBitmap = null;

scaledSrcBmp = null;

return output;

}



/**

* 画边缘的圆,即内圆或者外圆


*/


private void drawCircleBorder(Canvas canvas, int radius, int color) {

Paint paint = new Paint();

/* 去锯齿 */

paint.setAntiAlias(true);

paint.setFilterBitmap(true);

paint.setDither(true);

paint.setColor(color);

/* 设置paint的 style 为STROKE:空心 */

paint.setStyle(Paint.Style.STROKE);

/* 设置paint的外框宽度 */

paint.setStrokeWidth(mBorderThickness);

canvas.drawCircle(defaultWidth / 2, defaultHeight / 2, radius, paint);

}

}

3.res--values--attrs.xml文件


<?xml version="1.0" encoding="utf-8"?>

<resources>



<declare-styleable name="round_image_view">

<attr name="border_width" format="dimension" />

<attr name="border_incolor" format="color" />

<attr name="border_outcolor" format="color"></attr>

</declare-styleable>



</resources>

三.第三方NiceImageView

1.效果图如下:



 2.特点:

基于AppCompatImageView扩展

支持圆角、圆形显示

可绘制边框,圆形时可绘制内外两层边框

支持边框不覆盖图片

可绘制遮罩

3.基本用法:

 1. 添加JitPack仓库 在项目根目录下的 build.gradle 中添加仓库:

allprojects {

repositories {

...

maven { url "https://jitpack.io" }

}

}

2. 添加项目依赖


dependencies {

implementation 'com.github.SheHuan:NiceImageView:1.0.5'

}

3. 在布局文件中添加CornerLabelView

<com.shehuan.niv.NiceImageView

android:layout_width="200dp"

android:layout_height="200dp"

android:layout_marginTop="10dp"

android:src="@drawable/cat"

app:border_color="#FF7F24"

app:border_width="4dp"

app:is_circle="true" />

4.支持的属性、方法

属性名含义默认值对应方法
is_circle是否显示为圆形(默认为矩形)falseisCircle()
corner_top_left_radius左上角圆角半径0dpsetCornerTopLeftRadius()
corner_top_right_radius右上角圆角半径0dpsetCornerTopRightRadius()
corner_bottom_left_radius左下角圆角半径0dpsetCornerBottomLeftRadius()
corner_bottom_right_radius右下角圆角半径0dpsetCornerBottomRightRadius()
corner_radius统一设置四个角的圆角半径0dpsetCornerRadius()
border_width边框宽度0dpsetBorderWidth()
border_color边框颜色#ffffffsetBorderColor()
inner_border_width相当于内层边框(is_circle为true时支持)0dpsetInnerBorderWidth()
inner_border_color内边框颜色#ffffffsetInnerBorderColor()
is_cover_srcborder、inner_border是否覆盖图片内容falseisCoverSrc()
mask_color图片上绘制的遮罩颜色不设置颜色则不绘制setMaskColor()

可参考:https://github.com/SheHuan/NiceImageView


 5.其它:


如果你需要实现类似钉钉的圆形组合头像,例如:



可以先生成对应的Bitmap,并用圆形的 NiceImageView 显示即可。如何生成组合Bitmap可以参考这里:CombineBitmap


四.如果你的项目中只有圆形的图片而不需要设置圆角图片的话,可以试试下面的第三方:


https://github.com/hdodenhof/CircleImageView

https://github.com/open-android/RoundedImageView


收起阅读 »

Android之CircleImageView使用

文章大纲一、什么是CircleImageView二、代码实战三、项目源码下载一、什么是CircleImageView  圆角 ImageView,在我们的 App 中这个想必是太常见了,也许我们可以有无数种展示圆角图片的方法,但是 CircleImageVie...
继续阅读 »

文章大纲

一、什么是CircleImageView
二、代码实战
三、项目源码下载

一、什么是CircleImageView

  圆角 ImageView,在我们的 App 中这个想必是太常见了,也许我们可以有无数种展示圆角图片的方法,但是 CircleImageView 绝对是我们在开发时需要优先考虑的,如果你还不知道 CircleImageView,那么你需要赶快去体验它在处理圆角图片时的强大了,相信你肯定会觉得和 CircleImageView 相见恨晚。

二、代码实战

1. 添加依赖

    //添加CircleImageView依赖
implementation 'de.hdodenhof:circleimageview:2.1.0'

2. 添加图片资源

 

3. 资源文件activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<de.hdodenhof.circleimageview.CircleImageView
xmlns:circleimageview="http://schemas.android.com/apk/res-auto"
android:id="@+id/imageview"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:src="@drawable/test"
circleimageview:civ_border_color="@android:color/holo_red_light"
circleimageview:civ_border_overlay="false"
circleimageview:civ_border_width="2dp"
circleimageview:civ_fill_color="@android:color/holo_blue_light"/>

</android.support.constraint.ConstraintLayout>

常用属性:
(1)civ_border_width: 设置边框的宽度,默认为0,即无边框。
(2)civ_border_color: 设置边框的颜色,默认为黑色。
(3)civ_border_overlay:设置边框是否覆盖在图片上,默认为false,即边框在图片外圈。
(4)civ_fill_color:设置图片的底色,默认透明。
(5)civ_border_width:设置边框大小
(6)civ_fill_color:设置图片的底色,默认透明

4. MainActivity.java

public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
}

5. 运行结果

 
demo下载地址:CircleImageViewTest.zip
收起阅读 »

移动端强大的富文本编辑器richeditor-android

通常我们使用富文本编辑器都是在H5端实现,但是如果你遇到在移动端发表文章的功能,那么richeditor-android这套框架可以轻松为你实现,不需要再使用大量的控件进行拼凑! 功能表如下图所示: 引入richeditor-android ...
继续阅读 »


通常我们使用富文本编辑器都是在H5端实现,但是如果你遇到在移动端发表文章的功能,那么richeditor-android这套框架可以轻松为你实现,不需要再使用大量的控件进行拼凑!



  • 功能表如下图所示:





  • 引入richeditor-android



richeditor-android需要的jar:

implementation 'jp.wasabeef:richeditor-android:1.2.2'


这是一个Dialog框架,demo中不想自己去写,所以就使用了第三方
implementation 'com.afollestad.material-dialogs:core:0.9.6.0'


  • 引入控件RichEditor


   <jp.wasabeef.richeditor.RichEditor
android:id="@+id/editor"
android:layout_width="match_parent"
android:layout_height="wrap_content" />


  • 使用到的权限


如果拍照需要相机权限,选择图片需要SD卡权限,插入网络图片需要网络权限


 <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />


  • 初始化RichEditor


       mEditor = (RichEditor) findViewById(R.id.editor);

//初始化编辑高度
mEditor.setEditorHeight(200);
//初始化字体大小
mEditor.setEditorFontSize(22);
//初始化字体颜色
mEditor.setEditorFontColor(Color.BLACK);
//mEditor.setEditorBackgroundColor(Color.BLUE);

//初始化内边距
mEditor.setPadding(10, 10, 10, 10);
//设置编辑框背景,可以是网络图片
// mEditor.setBackground("https://raw.githubusercontent.com/wasabeef/art/master/chip.jpg");
// mEditor.setBackgroundColor(Color.BLUE);
mEditor.setBackgroundResource(R.drawable.bg);
//设置默认显示语句
mEditor.setPlaceholder("Insert text here...");
//设置编辑器是否可用
mEditor.setInputEnabled(true);


  • 实时监听Editor输入内容


   mPreview = (TextView) findViewById(R.id.preview);
mEditor.setOnTextChangeListener(new RichEditor.OnTextChangeListener() {
@Override
public void onTextChange(String text) {
mPreview.setText(text);
}
});


  • 功能方法


        /**
* 撤销当前标签状态下所有内容
*/

findViewById(R.id.action_undo).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.undo();
}
});
/**
* 恢复撤销的内容
*/

findViewById(R.id.action_redo).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.redo();
}
});
/**
* 加粗
*/

findViewById(R.id.action_bold).setOnClickListener(new View.OnClickListener() {

@Override
public void onClick(View v) {
mEditor.focusEditor();
mEditor.setBold();
}
});
/**
* 斜体
*/

findViewById(R.id.action_italic).setOnClickListener(new View.OnClickListener() {

@Override
public void onClick(View v) {
mEditor.focusEditor();
mEditor.setItalic();
}
});
/**
* 下角表
*/

findViewById(R.id.action_subscript).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
if (mEditor.getHtml() == null) {
return;
}
mEditor.setSubscript();
}
});
/**
* 上角标
*/

findViewById(R.id.action_superscript).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
if (mEditor.getHtml() == null) {
return;
}
mEditor.setSuperscript();
}
});

/**
* 删除线
*/

findViewById(R.id.action_strikethrough).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
mEditor.setStrikeThrough();
}
});
/**
*下划线
*/

findViewById(R.id.action_underline).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
mEditor.setUnderline();
}
});
/**
* 设置标题(1到6)
*/

findViewById(R.id.action_heading1).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setHeading(1);
}
});

findViewById(R.id.action_heading2).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setHeading(2);
}
});

findViewById(R.id.action_heading3).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setHeading(3);
}
});

findViewById(R.id.action_heading4).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setHeading(4);
}
});

findViewById(R.id.action_heading5).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setHeading(5);
}
});

findViewById(R.id.action_heading6).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setHeading(6);
}
});
/**
* 设置字体颜色
*/

findViewById(R.id.action_txt_color).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
new MaterialDialog.Builder(MainActivity.this)
.title("选择字体颜色")
.items(R.array.color_items)
.itemsCallbackSingleChoice(-1, new MaterialDialog.ListCallbackSingleChoice() {
@Override
public boolean onSelection(MaterialDialog dialog, View itemView, int which, CharSequence text) {

dialog.dismiss();
switch (which) {
case 0://红
mEditor.setTextColor(Color.RED);
break;
case 1://黄
mEditor.setTextColor(Color.YELLOW);
break;
case 2://蓝
mEditor.setTextColor(Color.GREEN);
break;
case 3://绿
mEditor.setTextColor(Color.BLUE);
break;
case 4://黑
mEditor.setTextColor(Color.BLACK);
break;
}
return false;
}
}).show();
}
});

findViewById(R.id.action_bg_color).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
new MaterialDialog.Builder(MainActivity.this)
.title("选择字体背景颜色")
.items(R.array.text_back_color_items)
.itemsCallbackSingleChoice(-1, new MaterialDialog.ListCallbackSingleChoice() {
@Override
public boolean onSelection(MaterialDialog dialog, View itemView, int which, CharSequence text) {

dialog.dismiss();
switch (which) {
case 0://红
mEditor.setTextBackgroundColor(Color.RED);
break;
case 1://黄
mEditor.setTextBackgroundColor(Color.YELLOW);
break;
case 2://蓝
mEditor.setTextBackgroundColor(Color.GREEN);
break;
case 3://绿
mEditor.setTextBackgroundColor(Color.BLUE);
break;
case 4://黑
mEditor.setTextBackgroundColor(Color.BLACK);
break;
case 5://透明
mEditor.setTextBackgroundColor(R.color.transparent);
break;
}
return false;
}
}).show();

}
});
/**
* 向右缩进
*/

findViewById(R.id.action_indent).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
mEditor.setIndent();
}
});
/**
* 向左缩进
*/

findViewById(R.id.action_outdent).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
mEditor.setOutdent();
}
});
/**
*文章左对齐
*/

findViewById(R.id.action_align_left).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
mEditor.setAlignLeft();
}
});
/**
* 文章居中对齐
*/

findViewById(R.id.action_align_center).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setAlignCenter();
}
});
/**
* 文章右对齐
*/

findViewById(R.id.action_align_right).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setAlignRight();
}
});
/**
* 无序排列
*/

findViewById(R.id.action_insert_bullets).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setBullets();
}
});
/**
* 有序排列
*/

findViewById(R.id.action_insert_numbers).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setNumbers();
}
});
/**
* 引用
*/

findViewById(R.id.action_blockquote).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.setBlockquote();
}
});

/**
* 插入图片
*/

findViewById(R.id.action_insert_image).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
ActivityCompat.requestPermissions(MainActivity.this, mPermissionList, 100);
}
});
/**
* 插入连接
*/

findViewById(R.id.action_insert_link).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new MaterialDialog.Builder(MainActivity.this)
.title("将输入连接地址")
.items("http://blog.csdn.net/huangxiaoguo1")
.itemsCallbackSingleChoice(-1, new MaterialDialog.ListCallbackSingleChoice() {
@Override
public boolean onSelection(MaterialDialog dialog, View itemView, int which, CharSequence text) {
dialog.dismiss();
mEditor.focusEditor();
mEditor.insertLink("http://blog.csdn.net/huangxiaoguo1",
"http://blog.csdn.net/huangxiaoguo1");
return false;
}
}).show();
}
});
/**
* 选择框
*/

findViewById(R.id.action_insert_checkbox).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mEditor.focusEditor();
mEditor.insertTodo();
}
});

/**
* 获取并显示Html
*/

findViewById(R.id.tv_showhtml).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(v.getContext(), WebViewActivity.class);
intent.putExtra("contextURL", mEditor.getHtml());
startActivity(intent);
}
});


  • 插入图片并使用屏幕宽度




权限,我这里只是选着图片,关于拍照的自己可以去实现

String[] mPermissionList = new String[]{
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE};


@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode) {
case 100:
boolean writeExternalStorage = grantResults[0] == PackageManager.PERMISSION_GRANTED;
boolean readExternalStorage = grantResults[1] == PackageManager.PERMISSION_GRANTED;
if (grantResults.length > 0 && writeExternalStorage && readExternalStorage) {
getImage();
} else {
Toast.makeText(this, "请设置必要权限", Toast.LENGTH_SHORT).show();
}

break;
}
}

private void getImage() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
startActivityForResult(new Intent(Intent.ACTION_GET_CONTENT).setType("image/*"),
REQUEST_PICK_IMAGE);
} else {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");
startActivityForResult(intent, REQUEST_PICK_IMAGE);
}
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == Activity.RESULT_OK) {
switch (requestCode) {
case REQUEST_PICK_IMAGE:
if (data != null) {
String realPathFromUri = RealPathFromUriUtils.getRealPathFromUri(this, data.getData());
mEditor.insertImage("https://unsplash.it/2000/2000?random&58",
"huangxiaoguo\" style=\"max-width:100%");
mEditor.insertImage(realPathFromUri, realPathFromUri + "\" style=\"max-width:100%");
// mEditor.insertImage(realPathFromUri, realPathFromUri + "\" style=\"max-width:100%;max-height:100%");

} else {
Toast.makeText(this, "图片损坏,请重新选择", Toast.LENGTH_SHORT).show();
}

break;
}
}
}

注意这里 “\” style=\”max-width:100%”是让我们从手机选择的图片和网络加载的图片适配屏幕宽高,解决图片太大显示不全问题!


关于如何获得手机图片真正地址(realPathFromUri )请看http://blog.csdn.net/huangxiaoguo1/article/details/78983582


richeditor-android github地址:https://github.com/wasabeef/richeditor-android


demo地址:http://download.csdn.net/download/huangxiaoguo1/10205773

收起阅读 »

Android加载离线和网络git

本文介绍如何将android-gif-drawable集成到项目中,并且如何使用android-gif-drawable加载离线和网络Gif动图。 android-gif-drawable的集成 在线集成 Github上相关教程,也比较简单,将依赖添加到...
继续阅读 »


本文介绍如何将android-gif-drawable集成到项目中,并且如何使用android-gif-drawable加载离线和网络Gif动图。


android-gif-drawable的集成


在线集成


Github上相关教程,也比较简单,将依赖添加到项目的build.gradle文件即可:


dependencies {
compile 'pl.droidsonroids.gif:android-gif-drawable:1.2.11'
}

离线集成


Android Studio 3.0中有效



  1. 进入Github上的realease页面-realease点我


  2. 下载其中的android-gif-drawable-1.2.11.aar


  3. android-gif-drawable-1.2.11.aar添加到项目的libs目录中


  4. 在项目的build.gradle中添加该arr文件



compile(name:'android-gif-drawable-1.2.11', ext:'aar')


  1. 集成完毕,可以进行测试。


android-gif-drawable的使用


android-gif-drawable有四种控件:GifImageViewGifImageButtonGifTextViewGifTextureView。这里以ImageView为例进行介绍。


加载本地图片



  1. 直接在布局中选定资源文件


<pl.droidsonroids.gif.GifImageView
android:id="@+id/fragment_gif_local"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/dog"/>


  1. 通过代码进行动态添加gif动图


//1. 构建GifDrawable
GifDrawable gifFromResDrawable = new GifDrawable( getResources(), R.drawable.dog );
//2. 设置给GifImageView控件
gifImageView.setImageDrawable(gifFromResDrawable);

GifDrawable


GifDrawable是用于该开源库的Drawable类。构造方法大致有9种:


//1. asset file
GifDrawable gifFromAssets = new GifDrawable( getAssets(), "anim.gif" );

//2. resource (drawable or raw)
GifDrawable gifFromResource = new GifDrawable( getResources(), R.drawable.anim );

//3. byte array
byte[] rawGifBytes = ...
GifDrawable gifFromBytes = new GifDrawable( rawGifBytes );

//4. FileDescriptor
FileDescriptor fd = new RandomAccessFile( "/path/anim.gif", "r" ).getFD();
GifDrawable gifFromFd = new GifDrawable( fd );

//5. file path
GifDrawable gifFromPath = new GifDrawable( "/path/anim.gif" );

//6. file
File gifFile = new File(getFilesDir(),"anim.gif");
GifDrawable gifFromFile = new GifDrawable(gifFile);

//7. AssetFileDescriptor
AssetFileDescriptor afd = getAssets().openFd( "anim.gif" );
GifDrawable gifFromAfd = new GifDrawable( afd );

//8. InputStream (it must support marking)
InputStream sourceIs = ...
BufferedInputStream bis = new BufferedInputStream( sourceIs, GIF_LENGTH );
GifDrawable gifFromStream = new GifDrawable( bis );

//9. direct ByteBuffer
ByteBuffer rawGifBytes = ...
GifDrawable gifFromBytes = new GifDrawable( rawGifBytes );

加载网络Gif


我们解决的办法是将Gif图片下载到缓存目录中,然后从磁盘缓存中获取该Gif动图进行显示。


1、下载工具DownloadUtils.java


public class DownloadUtils {
private final int DOWN_START = 1; // Handler消息类型(开始下载)
private final int DOWN_POSITION = 2; // Handler消息类型(下载位置)
private final int DOWN_COMPLETE = 3; // Handler消息类型(下载完成)
private final int DOWN_ERROR = 4; // Handler消息类型(下载失败)
private OnDownloadListener onDownloadListener;

public void setOnDownloadListener(OnDownloadListener onDownloadListener) {
this.onDownloadListener = onDownloadListener;
}

/**
* 下载文件
*
* @param url 文件路径
* @param filepath 保存地址
*/

public void download(String url, String filepath) {
MyRunnable mr = new MyRunnable();
mr.url = url;
mr.filepath = filepath;
new Thread(mr).start();
}

@SuppressWarnings("unused")
private void sendMsg(int what) {
sendMsg(what, null);
}

private void sendMsg(int what, Object mess) {
Message m = myHandler.obtainMessage();
m.what = what;
m.obj = mess;
m.sendToTarget();
}

Handler myHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case DOWN_START: // 开始下载
int filesize = (Integer) msg.obj;
onDownloadListener.onDownloadConnect(filesize);
break;
case DOWN_POSITION: // 下载位置
int pos = (Integer) msg.obj;
onDownloadListener.onDownloadUpdate(pos);
break;
case DOWN_COMPLETE: // 下载完成
String url = (String) msg.obj;
onDownloadListener.onDownloadComplete(url);
break;
case DOWN_ERROR: // 下载失败
Exception e = (Exception) msg.obj;
e.printStackTrace();
onDownloadListener.onDownloadError(e);
break;
}
super.handleMessage(msg);
}
};

class MyRunnable implements Runnable {
private String url = "";
private String filepath = "";

@Override
public void run() {
try {
doDownloadTheFile(url, filepath);
} catch (Exception e) {
sendMsg(DOWN_ERROR, e);
}
}
}

/**
* 下载文件
*
* @param url 下载路劲
* @param filepath 保存路径
* @throws Exception
*/

private void doDownloadTheFile(String url, String filepath) throws Exception {
if (!URLUtil.isNetworkUrl(url)) {
sendMsg(DOWN_ERROR, new Exception("不是有效的下载地址:" + url));
return;
}
URL myUrl = new URL(url);
URLConnection conn = myUrl.openConnection();
conn.connect();
InputStream is = null;
int filesize = 0;
try {
is = conn.getInputStream();
filesize = conn.getContentLength();// 根据响应获取文件大小
sendMsg(DOWN_START, filesize);
} catch (Exception e) {
sendMsg(DOWN_ERROR, new Exception(new Exception("无法获取文件")));
return;
}
FileOutputStream fos = new FileOutputStream(filepath); // 创建写入文件内存流,
// 通过此流向目标写文件
byte buf[] = new byte[1024];
int numread = 0;
int temp = 0;
while ((numread = is.read(buf)) != -1) {
fos.write(buf, 0, numread);
fos.flush();
temp += numread;
sendMsg(DOWN_POSITION, temp);
}
is.close();
fos.close();
sendMsg(DOWN_COMPLETE, filepath);
}

interface OnDownloadListener{
public void onDownloadUpdate(int percent);

public void onDownloadError(Exception e);

public void onDownloadConnect(int filesize);

public void onDownloadComplete(Object result);
}
}

2、调用DonwloadUtils进行下载,下载完成后加载本地图片


//1. 下载gif图片(文件名自定义可以通过Hash值作为key)
DownloadUtils downloadUtils = new DownloadUtils();
downloadUtils.download(gifUrlArray[0],
getDiskCacheDir(getContext())+"/0.gif");
//2. 下载完毕后通过“GifDrawable”进行显示
downloadUtils.setOnDownloadListener(new DownloadUtils.OnDownloadListener() {
@Override
public void onDownloadUpdate(int percent) {
}
@Override
public void onDownloadError(Exception e) {
}
@Override
public void onDownloadConnect(int filesize) {
}
//下载完毕后进行显示
@Override
public void onDownloadComplete(Object result) {
try {
GifDrawable gifDrawable = new GifDrawable(getDiskCacheDir(getContext())+"/0.gif");
mGifOnlineImageView.setImageDrawable(gifDrawable);
} catch (IOException e) {
e.printStackTrace();
}
}
});

//获取缓存的路径
public String getDiskCacheDir(Context context) {
String cachePath = null;
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
|| !Environment.isExternalStorageRemovable()) {
// 路径:/storage/emulated/0/Android/data/<application package>/cache
cachePath = context.getExternalCacheDir().getPath();
} else {
// 路径:/data/data/<application package>/cache
cachePath = context.getCacheDir().getPath();
}
return cachePath;
}

github地址:https://github.com/koral--/android-gif-drawable

转载请注明链接:http://blog.csdn.net/feather_wch/article/details/79558240

收起阅读 »

Andorid进阶二:LeakCanary源码分析,从头到尾搞个明白

四,ObjectWatcher 保留对象检查分析我们转到 ObjectWatcher 的 expectWeaklyReachable 方法看看@Synchronized override fun expectWeaklyReachable( watched...
继续阅读 »

四,ObjectWatcher 保留对象检查分析

我们转到 ObjectWatcher 的 expectWeaklyReachable 方法看看

@Synchronized override fun expectWeaklyReachable(
watchedObject: Any,
description: String
) {
//是否启用 , AppWatcher 持有的ObjectWatcher 默认是启用的
if (!isEnabled()) {
return
}
///移除之前已经被回收的监听对象
removeWeaklyReachableObjects()
val key = UUID.randomUUID()
.toString()
val watchUptimeMillis = clock.uptimeMillis()
//(1) 创建弱引用
val reference =
KeyedWeakReference(watchedObject, key, description, watchUptimeMillis, queue)
SharkLog.d {
"Watching " +
(if (watchedObject is Class<*>) watchedObject.toString() else "instance of ${watchedObject.javaClass.name}") +
(if (description.isNotEmpty()) " ($description)" else "") +
" with key $key"
}

watchedObjects[key] = reference
checkRetainedExecutor.execute {
//(2)
moveToRetained(key)
}
}

继续分析源码中标注的地方。

(1) 创建弱引用

标注(1.2.4)处的代码是初始化的主要代码,创建要观察对象的弱引用,传入queue 作为gc 后的对象信息存储队列,WeakReference 中,当持有对象呗gc的时候,会将其包装对象压入队列中。可以在后续对该队列进行观察。

(2) moveToRetained(key),检查对应key对象的保留

作为Executor的runner 执行,在AppWatcher中,默认延迟五秒后执行该方法 查看源码分析

@Synchronized private fun moveToRetained(key: String) {
///移除已经被回收的观察对象
removeWeaklyReachableObjects()
val retainedRef = watchedObjects[key]
if (retainedRef != null) {
//记录泄漏时间
retainedRef.retainedUptimeMillis = clock.uptimeMillis()
//回调泄漏监听
onObjectRetainedListeners.forEach { it.onObjectRetained() }
}
}

从上述代码可知,ObjectWatcher 监测内存泄漏总共有以下几步

  1. 清除已经被内存回收的监听对象
  2. 创建弱引用,传入 ReferenceQueue 作为gc 信息保存队列
  3. 在延迟指定的时间后,再次检查针对的对象是否被回收(通过检查ReferenceQueue队列内有无该WeakReference实例)
  4. 检测到对象没有被回收后,回调 onObjectRetainedListeners 们的 onObjectRetained

五,dumpHeap,怎么个DumpHeap流程

(1.1)objectWatcher 添加 OnObjectRetainedListeners 监听

回到最初AppWatcher的 manualInstall 方法。 可以看到其中执行了loadLeakCanary 方法。 代码如下:

///(2)
LeakCanaryDelegate.loadLeakCanary(application)
//反射获取InternalLeakCanary实例
val loadLeakCanary by lazy {
try {
val leakCanaryListener = Class.forName("leakcanary.internal.InternalLeakCanary")
leakCanaryListener.getDeclaredField("INSTANCE")
.get(null) as (Application) -> Unit
} catch (ignored: Throwable) {
NoLeakCanary
}
}

该方法通过反射获取了 InternalLeakCanary 的静态实例。 并且调用了他的 invoke(application: Application)方法,所以我们接下来看InternalLeakCanary的该方法:

override fun invoke(application: Application) {
_application = application

checkRunningInDebuggableBuild()
//(1.2)添加 addOnObjectRetainedListener
AppWatcher.objectWatcher.addOnObjectRetainedListener(this)

val heapDumper = AndroidHeapDumper(application, createLeakDirectoryProvider(application))
//Gc触发器
val gcTrigger = GcTrigger.Default

val configProvider = { LeakCanary.config }

val handlerThread = HandlerThread(LEAK_CANARY_THREAD_NAME)
handlerThread.start()
val backgroundHandler = Handler(handlerThread.looper)
///(1.3)
heapDumpTrigger = HeapDumpTrigger(
application, backgroundHandler, AppWatcher.objectWatcher, gcTrigger, heapDumper,
configProvider
)
///(1.4) 添加application前后台变化监听
application.registerVisibilityListener { applicationVisible ->
this.applicationVisible = applicationVisible
heapDumpTrigger.onApplicationVisibilityChanged(applicationVisible)
}
//(1.5)
registerResumedActivityListener(application)
//(1.6)
addDynamicShortcut(application)

// 6 判断是否应该DumpHeap
// We post so that the log happens after Application.onCreate()
mainHandler.post {
// https://github.com/square/leakcanary/issues/1981
// We post to a background handler because HeapDumpControl.iCanHasHeap() checks a shared pref
// which blocks until loaded and that creates a StrictMode violation.
backgroundHandler.post {
SharkLog.d {
when (val iCanHasHeap = HeapDumpControl.iCanHasHeap()) {
is Yup -> application.getString(R.string.leak_canary_heap_dump_enabled_text)
is Nope -> application.getString(
R.string.leak_canary_heap_dump_disabled_text, iCanHasHeap.reason()
)
}
}
}
}
}

我们看到初始化的时候做了这么6步

  • (1.2) 将自己加入到ObjectWatcher 的对象异常持有监听器中
  • (1.3)创建内存快照转储触发器 HeapDumpTrigger
  • (1.4)监听application 前后台变动,并且记录来到后台时间,便于LeakCanary 针对刚刚切入后台的一些destroy操作做泄漏监测
  • (1.5)注册activity生命周期回调,获取当前resumed的activity实例
  • (1.6)添加动态的桌面快捷入口
  • (1.7)在异步线程中,判断是否处于可dumpHeap的状态,如果处于触发一次内存泄漏检查 其中最重要的是 1.2,我们重点分析作为ObjectRetainedListener 他在回调中做了哪些工作。

(1.2)添加对象异常持有监听

可以看到代码(1.2),在objectWatcher将自己加入到泄漏监测回调中。 当ObjectWatcher监测到对象依然被异常持有的时候,会回调 onObjectRetained 方法。 从源码中可知,其中调用了 heapDumpTrigger的 scheduleRetainedObjectCheck方法, 代码如下。

fun scheduleRetainedObjectCheck() {
if (this::heapDumpTrigger.isInitialized) {
heapDumpTrigger.scheduleRetainedObjectCheck()
}
}

HeapDumpTrigger 顾名思义,就是内存快照转储的触发器。在回调中最终调用了HeapDumpTrigger 的 checkRetainedObjects方法来检查内存泄漏。

(1.3)检查内存泄漏checkRetainedObjects

private fun checkRetainedObjects() {
val iCanHasHeap = HeapDumpControl.iCanHasHeap()

val config = configProvider()
//省略一些代码,主要是判断 iCanHasHeap。
//如果当前处于不dump内存快照的状态,就先不处理。如果有新的异常持有对象被发现则发送通知提示
//%d retained objects, tap to dump heap
/** ...*/

var retainedReferenceCount = objectWatcher.retainedObjectCount

//主动触发gc
if (retainedReferenceCount > 0) {
gcTrigger.runGc()
//重新获取异常持有对象
retainedReferenceCount = objectWatcher.retainedObjectCount
}
//如果泄漏数量小于阈值,且app在前台,或者刚转入后台,就展示泄漏通知,并先返回
if (checkRetainedCount(retainedReferenceCount, config.retainedVisibleThreshold)) return

//如果泄漏数量到达dumpHeap要求,继续往下
///转储内存快照在 WAIT_BETWEEN_HEAP_DUMPS_MILLIS (默认60秒)只会触发一次,如果之前刚触发过,就先不生成内存快照,直接发送通知了事。
//省略转储快照时机判断,不满足的话会提示 Last heap dump was less than a minute ago
/**...*/

dismissRetainedCountNotification()
val visibility = if (applicationVisible) "visible" else "not visible"
///转储内存快照
dumpHeap(
retainedReferenceCount = retainedReferenceCount,
retry = true,
reason = "$retainedReferenceCount retained objects, app is $visibility"
)
}

这一块也可以看出检测是否需要dumpHeap分为4步。

  1. 如果没有检测到异常持有的对象,返回
  2. 如果有异常对象,主动触发gc
  3. 如果还有异常对象,就是内存泄漏了。
  4. 判断泄漏数量是否到达需要dump的地步
  5. 判断一分钟内是否叫进行过dump了
  6. dumpHeap 前面都是判断代码,关键重点在于dumpHeap方法

(1.4)dumpHeap 转储内存快照

private fun dumpHeap(
retainedReferenceCount: Int,
retry: Boolean,
reason: String
) {
saveResourceIdNamesToMemory()
val heapDumpUptimeMillis = SystemClock.uptimeMillis()
KeyedWeakReference.heapDumpUptimeMillis = heapDumpUptimeMillis
when (val heapDumpResult = heapDumper.dumpHeap()) {
is NoHeapDump -> {
//省略 dump失败,等待重试代码和发送失败通知代码
}
is HeapDump -> {
lastDisplayedRetainedObjectCount = 0
lastHeapDumpUptimeMillis = SystemClock.uptimeMillis()
///清除 objectWatcher 中,在heapDumpUptimeMillis之前持有的对象,也就是已经dump的对象
objectWatcher.clearObjectsWatchedBefore(heapDumpUptimeMillis)
// 发送文件到HeapAnalyzerService解析
HeapAnalyzerService.runAnalysis(
context = application,
heapDumpFile = heapDumpResult.file,
heapDumpDurationMillis = heapDumpResult.durationMillis,
heapDumpReason = reason
)
}
}
}

HeapDumpTrigger#dumpHeap中调用到了 AndroidHeapDumper#dumpHeap方法。 并且在dump后马上调用 HeapAnalyzerService.runAnalysis 进行内存分析工作,该方法在下一节分析。先看AndroidHeapDumper#dumHeap源码

override fun dumpHeap(): DumpHeapResult {
//创建新的hprof 文件
val heapDumpFile = leakDirectoryProvider.newHeapDumpFile() ?: return NoHeapDump

val waitingForToast = FutureResult<Toast?>()
///展示dump吐司
showToast(waitingForToast)

///如果展示吐司时间超过五秒,就不dump了
if (!waitingForToast.wait(5, SECONDS)) {
SharkLog.d { "Did not dump heap, too much time waiting for Toast." }
return NoHeapDump
}

//省略dumpHeap通知栏提示消息代码
val toast = waitingForToast.get()

return try {
val durationMillis = measureDurationMillis {
//调用DumpHprofData
Debug.dumpHprofData(heapDumpFile.absolutePath)
}
if (heapDumpFile.length() == 0L) {
SharkLog.d { "Dumped heap file is 0 byte length" }
NoHeapDump
} else {
HeapDump(file = heapDumpFile, durationMillis = durationMillis)
}
} catch (e: Exception) {
SharkLog.d(e) { "Could not dump heap" }
// Abort heap dump
NoHeapDump
} finally {
cancelToast(toast)
notificationManager.cancel(R.id.leak_canary_notification_dumping_heap)
}
}

在该方法内,最终调用 Debug.dumpHprofData 方法 完成hprof 快照的生成。

六,分析内存 HeapAnalyzerService

上面代码分析中可以看到,在dumpHeap后紧跟着就是启动内存分析服务的方法。 现在我们跳转到HeapAnalyzerService的源码处。

override fun onHandleIntentInForeground(intent: Intent?) {
//省略参数获取代码
val config = LeakCanary.config
val heapAnalysis = if (heapDumpFile.exists()) {
analyzeHeap(heapDumpFile, config)
} else {
missingFileFailure(heapDumpFile)
}
//省略完善分析结果属性的代码
onAnalysisProgress(REPORTING_HEAP_ANALYSIS)
config.onHeapAnalyzedListener.onHeapAnalyzed(fullHeapAnalysis)
}

可以看到重点在于 analyzeHeap,其中调用了 HeapAnalyzer#analyze HeapAnalyzer 类位于shark模块中。

(1)HeapAnalyzer#analyze

内存分析方法代码如下:

fun analyze(
heapDumpFile: File,
leakingObjectFinder: LeakingObjectFinder,
referenceMatchers: List<ReferenceMatcher> = emptyList(),
computeRetainedHeapSize: Boolean = false,
objectInspectors: List<ObjectInspector> = emptyList(),
metadataExtractor: MetadataExtractor = MetadataExtractor.NO_OP,
proguardMapping: ProguardMapping? = null
): HeapAnalysis {

//省略内存快照文件不存在的处理代码

return try {
listener.onAnalysisProgress(PARSING_HEAP_DUMP)
///io读取 内存快照
val sourceProvider = ConstantMemoryMetricsDualSourceProvider(FileSourceProvider(heapDumpFile))
sourceProvider.openHeapGraph(proguardMapping).use { graph ->
val helpers =
FindLeakInput(graph, referenceMatchers, computeRetainedHeapSize, objectInspectors)
//关键代码:在此处找到泄漏的结果以及其对应调用栈
val result = helpers.analyzeGraph(
metadataExtractor, leakingObjectFinder, heapDumpFile, analysisStartNanoTime
)
val lruCacheStats = (graph as HprofHeapGraph).lruCacheStats()
///io读取状态
val randomAccessStats =
"RandomAccess[" +
"bytes=${sourceProvider.randomAccessByteReads}," +
"reads=${sourceProvider.randomAccessReadCount}," +
"travel=${sourceProvider.randomAccessByteTravel}," +
"range=${sourceProvider.byteTravelRange}," +
"size=${heapDumpFile.length()}" +
"]"
val stats = "$lruCacheStats $randomAccessStats"
result.copy(metadata = result.metadata + ("Stats" to stats))
}
} catch (exception: Throwable) {
//省略异常处理
}
}

通过分析代码可知:分析内存快照分为以下5步:

  1. 读取hprof内存快照文件
  2. 找到LeakCanary 标记的泄漏对象们的数量和弱引用包装 ids,class name 为com.squareup.leakcanary.KeyedWeakReference

代码在 KeyedWeakReferenceFinder#findLeakingObjectIds

  1. 找到泄漏对象的gcRoot开始的路径

代码在PathFinder#findPathsFromGcRoots

  1. 返回分析结果,走结果回调
  2. 回调内 展示内存分析成功或者失败的通知栏消息,并将泄漏列表存储到数据库中

详情代码看 DefaultOnHeapAnalyzedListener#onHeapAnalyzed 以及 LeaksDbHelper

  1. 点开通知栏跳转到LeaksActivity 展示内存泄漏信息。

七,总结

终于从头到尾,总算是梳理了一波LeakCanary 源码

过程中学习到了这么多—>

  • 主动调用Gc的方式 GcTrigger.Default.runGc()
Runtime.getRuntime().gc()
  • seald class 密封类来表达状态,比如以下几个(关键好处在于使用when可以直接覆盖所有情况,而不必使用else)。
sealed class ICanHazHeap {
object Yup : ICanHazHeap()
abstract class Nope(val reason: () -> String) : ICanHazHeap()
class SilentNope(reason: () -> String) : Nope(reason)
class NotifyingNope(reason: () -> String) : Nope(reason)
}
sealed class Result {
data class Done(
val analysis: HeapAnalysis,
val stripHeapDumpDurationMillis: Long? = null
) : Result()
data class Canceled(val cancelReason: String) : Result()
}
  • 了解了系统创建内存快照的api
 Debug.dumpHprofData(heapDumpFile.absolutePath)
  • 知道了通过 ReferenceQueue 检测内存对象是否被gc,之前WeakReference都很少用。
  • 学习了leakCanary的分模块思想。作为sdk,很多功能模块引入自动开启。比如 leakcanary-android-process 自动开启对应进程等。
  • 学习了通过反射hook代码,替换实例达成添加钩子的操作。比如在Service泄漏监听代码中,替换HandleractivityManager的操作。
收起阅读 »

Andorid进阶一:LeakCanary源码分析,从头到尾搞个明白

"内存优化会不会?知道怎么定位内存问题吗?"面试官和蔼地坐在小会议室的一侧,亲切地问有些拘谨地小张。"就是...那个,用LeakCanary 检测一下泄漏,然后找到对应泄漏的地方,把错误的代码改一下,没回收的引用回收掉,优化下长短生命周期线程的依赖关系吧""那...
继续阅读 »

"内存优化会不会?知道怎么定位内存问题吗?"面试官和蔼地坐在小会议室的一侧,亲切地问有些拘谨地小张。

"就是...那个,用LeakCanary 检测一下泄漏,然后找到对应泄漏的地方,把错误的代码改一下,没回收的引用回收掉,优化下长短生命周期线程的依赖关系吧"

"那你了解LeakCanary 分析内存泄漏的原理吗?"

"不好意思,平时没有注意去看过" 小张心想:面试怎么老问这个,我只是个普通的菜鸟啊。

前言

app性能优化总是开发中必不可少的一环,而其中内存优化又是重点之一。内存泄漏带来的内存溢出崩溃,内存抖动带来的卡顿不流畅。都在切切实实地影响着用户的体验。我们常常会使用LeakCanary来定位内存泄漏问题。也是时候来探索一下人家是怎么实现的了。

名词理解

hprof : hprof 文件是 Java 的 内存快照文件(Heap Profile 的缩写),格式后缀为 .hprof,在leakCanary 中用于内存保存分析 WeakReference : 弱引用,当一个对象仅仅被weak reference(弱引用)指向, 而没有任何其他strong reference(强引用)指向的时候, 如果这时GC运行, 那么这个对象就会被回收,不论当前的内存空间是否足够,这个对象都会被回收。在leakCanary 中用于监测该回收的无用对象是否被释放。 curtains:Square 的另一个开源框架,Curtains 提供了用于处理 Android 窗口的集中式 API。在leakCanary中用于监测window rootView 在detached 后的内存泄漏。

目录

本文主要从以下几点入手分析

  1. 如何在项目中使用 LeakCanary工具
  2. 官方原理说明
  3. 默认如何监听Activity ,view ,fragment 和 viewmodel
  4. Watcher.watch(object) 如何监听内存泄漏
  5. 如何保存内存泄漏内存文件
  6. 如何分析内存泄漏文件
  7. 展示内存泄漏堆栈到ui中 不支持在 Docs 外粘贴 block

一,怎么用?

查看官网文档 可以看出使用方法非常简单,基础用法只需要添加相关依赖就行

//(1)
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
复制代码

debugImplementation 只在debug模式的编译和最终的debug apk打包时有效 注(1):标注的代码中用了一行就实现了初始化,怎么做到的呢? 通过查看源码可以看到,leakcanary 通过 ContentProvider 进行初始化,在AppWatcherInstaller 类的oncreate方法中调用了真正的初始化代码AppWatcher.manualInstall(application)。在AndroidManifest.xml中注册该provider,注册的ContentProvider会在 application 启动的时候自动回调 oncreate方法。

internal sealed class AppWatcherInstaller : ContentProvider() {
/**[MainProcess] automatically sets up the LeakCanary code that runs in the main app process. */
// (1)
internal class MainProcess : AppWatcherInstaller()
internal class LeakCanaryProcess : AppWatcherInstaller()
override fun onCreate(): Boolean {
val application = context!!.applicationContext as Application
///(2)
AppWatcher.manualInstall(application)
return true
}
//...
}
复制代码

说明一下源码中的数字标注

  1. 代码中定义了两个内部类继承自 AppWatcherInstaller。当用户额外依赖 leakcanary-android-process 模块的时候,自动在 process=":leakcanary" 也注册该provider。

代码参见 leakcanary-android-process 模块中的AndroidManifest.xml

  1. 这是真正的初始化代码注册入口

二,官方阐述

官方说明

本小节来自于官方网站的工作原理说明精简 安装 LeakCanary 后,它会通过 4 个步骤自动检测并报告内存泄漏:

  1. 检测被持有的对象

    LeakCanary 挂钩到 Android 生命周期以自动检测活动和片段何时被销毁并应进行垃圾收集。这些被销毁的对象被传递给一个ObjectWatcher,它持有对它们的弱引用。 可以主动观察一个不再需要的对象比如一个 dettached view 或者 已经销毁的 presenter

AppWatcher.objectWatcher.watch(myDetachedView, "View was detached")
复制代码

如果ObjectWatcher等待 5 秒并运行垃圾收集后没有清除持有的弱引用,则被监视的对象被认为是保留的,并且可能会泄漏。LeakCanary 将此记录到 Logcat:

D LeakCanary: Watching instance of com.example.leakcanary.MainActivity
(Activity received Activity#onDestroy() callback)

... 5 seconds later ...

D LeakCanary: Scheduling check for retained objects because found new object
retained
复制代码
  1. Dumping the heap 转储堆信息到文件中

    当保留对象的数量达到阈值时,LeakCanary 将 Java 内存快照 dumping 转储到 Android 文件系统上的.hprof文件(堆内存快照)中。转储堆会在短时间内冻结应用程序,并展示下图的吐司: img

  2. 分析堆内存

    LeakCanary使用Shark解析.hprof文件并在该内存快照文件中定位被保留的泄漏对象。 对于每个保留对象,LeakCanary 找到该对象的引用路径,该引用阻止了垃圾收集器对它的回收。也就是泄漏跟踪。 LeakCanary为每个泄漏跟踪创建一个签名 (对持有的引用属性进行相加做sha1Hash),并将具有相同签名的泄漏(即由相同错误引起的泄漏)组合在一起。如何创建签名和通过签名分组有待后文分析。

  3. 分类内存泄漏

    LeakCanary 将它在您的应用中发现的泄漏分为两类:Application Leaks (应用程序泄漏)和Library Leaks(库泄漏)。一个Library Leaks是由已知的第三方库导致的,你没有控制权。这种泄漏正在影响您的应用程序,但不幸的是,修复它可能不在您的控制范围内,因此 LeakCanary 将其分离出来。 这两个类别分开Logcat结果中打印:

====================================
HEAP ANALYSIS RESULT
====================================
0 APPLICATION LEAKS
====================================
1 LIBRARY LEAK
...
┬───
│ GC Root: Local variable in native code

...
复制代码

LeakCanary在其泄漏列表展示中会将其用Library Leak 标签标记: img LeakCanary 附带一个已知泄漏的数据库,它通过引用名称的模式匹配来识别。例如:

Leak pattern: instance field android.app.Activity$1#this$0
Description: Android Q added a new IRequestFinishCallback$Stub class [...]
┬───
│ GC Root: Global variable in native code

├─ android.app.Activity$1 instance
│ Leaking: UNKNOWN
│ Anonymous subclass of android.app.IRequestFinishCallback$Stub
│ ↓ Activity$1.this$0
│ ~~~~~~
╰→ com.example.MainActivity instance
复制代码

Library Leaks 通常我们都无力对齐进行修复 您可以在AndroidReferenceMatchers类中查看已知泄漏的完整列表。如果您发现无法识别的 Android SDK 泄漏,请报告。您还可以自定义已知库泄漏的列表

三,监测activity,fragment,rootView和viewmodel

前面提到初始化的代码如下,所以我们 查看manualInstall 的内部细节。

///初始化代码
AppWatcher.manualInstall(application)

///AppWatcher 的 manualInstall 代码
@JvmOverloads
fun manualInstall(
application: Application,
retainedDelayMillis: Long = TimeUnit.SECONDS.toMillis(5),
watchersToInstall: List<InstallableWatcher> = appDefaultWatchers(application)
) {
//*******检查是否为主线程********/
checkMainThread()
if (isInstalled) {
throw IllegalStateException(
"AppWatcher already installed, see exception cause for prior install call", installCause
)
}
check(retainedDelayMillis >= 0) {
"retainedDelayMillis $retainedDelayMillis must be at least 0 ms"
}
installCause = RuntimeException("manualInstall() first called here")
this.retainedDelayMillis = retainedDelayMillis
if (application.isDebuggableBuild) {
LogcatSharkLog.install()
}
// Requires AppWatcher.objectWatcher to be set
///(2)
LeakCanaryDelegate.loadLeakCanary(application)
///(1)
watchersToInstall.forEach {
it.install()
}
}
复制代码

AppWatcher 作为Android 平台使用 ObjectWatcher 封装的api中心。自动安装配置默认的监听。 以上代码关键的地方用数字标出了

(1)Install 默认的监听观察

标注(1)处的代码执行了 InstallableWatcher 的 install 操作,在调用的时候并没有传递 watchersToInstall 参数,所以使用的是 appDefaultWatchers(application)。该处代码在下面,提供了 四个默认监听的Watcher

fun appDefaultWatchers(
application: Application,
///(1.1)
reachabilityWatcher: ReachabilityWatcher = objectWatcher
): List<InstallableWatcher> {
return listOf(
///(1.2)
ActivityWatcher(application, reachabilityWatcher),
///(1.3)
FragmentAndViewModelWatcher(application, reachabilityWatcher),
///(1.4)
RootViewWatcher(reachabilityWatcher),
///(1.5)
ServiceWatcher(reachabilityWatcher)
)
}
复制代码

用数字标出的四个我们逐个分析

(1.1) reachabilityWatcher 参数

标注(1.1)处的代码是一个 ReachabilityWatcher 参数,reachabilityWatcher 在后续的四个实例创建时候都有用到,代码中可以看到reachabilityWatcher实例是AppWatcher 的成员变量:objectWatcher,对应的实例化代码如下。

/**
* The [ObjectWatcher] used by AppWatcher to detect retained objects.
* Only set when [isInstalled] is true.
*/

val objectWatcher = ObjectWatcher(
clock = { SystemClock.uptimeMillis() },
checkRetainedExecutor = {
check(isInstalled) {
"AppWatcher not installed"
}
mainHandler.postDelayed(it, retainedDelayMillis)
},
isEnabled = { true }
)
复制代码

可以看到objectWatcher 是一个 ObjectWatcher对象,该对象负责检测持有对象的泄漏情况,会在第三小节进行分析。 回到 ActivityWatcher 实例的创建,继续往下看标注的代码

(1.2)ActivityWatcher 实例 完成Activity 实例的监听

回到之前,标注(1.2)处的代码创建了ActivityWatcher实例,并在install 的时候安装,查看ActivityWatcher 类的源码,看监听Activity泄漏是怎么实现的

class ActivityWatcher(
private val application: Application,
private val reachabilityWatcher: ReachabilityWatcher
) : InstallableWatcher {

private val lifecycleCallbacks =
//(1.2.1) 通过动态代理,构造出生命周期回调的实现类
object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityDestroyed(activity: Activity) {
//(1.2.3)
reachabilityWatcher.expectWeaklyReachable(
activity, "${activity::class.java.name} received Activity#onDestroy() callback"
)
}
}

override fun install() {
//(1.2.3)
application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
}

override fun uninstall() {
application.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
}
复制代码

(1.2.1) lifecycleCallbacks 实例

标注(1.2.1)处的代码创建了ActivityLifecycleCallbacks实例,该实例实现了Application.ActivityLifecycleCallbacks。通过 by ``*noOpDelegate*``() ,利用动态代理实现了其他回调方法,感兴趣的可以查看 noOpDelegate 的源码

(1.2.2) activity监听器的 install 方法

标注(1.2.2)处的代码是初始化的主要代码,该方法很简单,就是在application的 中注册 lifecycleCallbacks,在activity 被destroy 的时候会走到其中实现的方法

(1.2.3) 监听activity 的 onActivityDestroyed 回调

标注(1.2.3)处的代码是初始化的主要代码,在 activity被销毁的时候,回调该方法,在其中检查该实例是否有泄漏,调用AppWatcher.objectWatcher. expectWeaklyReachable 方法,在其中完成activity的泄漏监测。 这时候又回到了 1.1 提到的 ObjectWatcher源码,相关分析看第四节 。

(1.2-end)Activity监测相关总结

这样ActivityInstaller 就看完了,了解了Activity 的初始化代码以及加入监听的细节。总结一下分为如下几步:

  1. 调用ActivityInstaller.install 初始化方法
  2. 在Application 注册ActivityLifecycleCallbacks
  3. 在所有activity onDestroy的时候调用ObjectWatcher的 expectWeaklyReachable方法,检查过五秒后activity对象是否有被内存回收。标记内存泄漏。下一节分析。
  4. 检测到内存泄漏的后续操作。后文分析。

(1.3) FragmentAndViewModelWatcher 监测 Fragment 和Viewodel实例

(1.3)处是创建了 FragmentAndViewModelWatcher 实例。监测fragment和viewmodel的内存泄漏。

该类实现了 SupportFragment和 androidxFragment以及androidO 的兼容,作为sdk开发来说,这种 兼容方式可以学习一下。

private val lifecycleCallbacks =
object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
for (watcher in fragmentDestroyWatchers) {
watcher(activity)
}
}
}

override fun install() {
application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
}
复制代码

ActivityWatcher 同样的,install是注册了生命周期监听。不过是在对每个 activity create 的时候,交给 fragmentDestroyWatchers 元素们监听。所以 fragmentDestroyWatchers才是真正的fragmentviewmodel 监听者。 接下来看 fragmentDestroyWatchers 的元素们创建:

private val fragmentDestroyWatchers: List<(Activity) -> Unit> = run {
val fragmentDestroyWatchers = mutableListOf<(Activity) -> Unit>()

//(1.3.1) android框架自带的fragment泄漏监测支持从 AndroidO(26)开始。
if (SDK_INT >= O) {
fragmentDestroyWatchers.add(
AndroidOFragmentDestroyWatcher(reachabilityWatcher)
)
}
//(1.3.2)
getWatcherIfAvailable(
ANDROIDX_FRAGMENT_CLASS_NAME,
ANDROIDX_FRAGMENT_DESTROY_WATCHER_CLASS_NAME,
reachabilityWatcher
)?.let {
fragmentDestroyWatchers.add(it)
}
//(1.3.3)
getWatcherIfAvailable(
ANDROID_SUPPORT_FRAGMENT_CLASS_NAME,
ANDROID_SUPPORT_FRAGMENT_DESTROY_WATCHER_CLASS_NAME,
reachabilityWatcher
)?.let {
fragmentDestroyWatchers.add(it)
}
fragmentDestroyWatchers
}
复制代码

可以看到内部创建了AndroidOFragmentDestroyWatcher 来针对Fragment 进行监听。原理是利用在 FragmentManager 中注册 FragmentManager.FragmentLifecycleCallbacks 来监听fragment 和 fragment.view 以及viewmodel 的实例泄漏。 从官方文档可知,android内部的 fragment 在Api 26中才添加。所以LeakCanary针对于android框架自带的fragment泄漏监测支持也是从 AndroidO(26)开始,见代码(1.3.1)。 标注的 1.3.1,1.3.2,1.3.3 实例化的三个Wathcer 分别是 AndroidOFragmentDestroyWatcher,AndroidXFragmentDestroyWatcher,AndroidSupportFragmentDestroyWatcher。内部实现代码大同小异,通过反射实例化不同的Watcher实现了androidX 和support 以及安卓版本间的兼容。

(1.3.1) AndroidOFragmentDestroyWatcher 实例

(1.3.1)处的代码添加了一个androidO的观察者实例。详情见代码,因为实现大同小异,分析参考1.3.2.

(1.3.2) AndroidXFragmentDestroyWatcher 实例

(1.3.2)处的代码 调用 getWatcherIfAvailable 通过反射创建了AndroidXFragmentDestroyWatcher实例,如果不存在Androidx库则返回null。 现在跳到 AndroidXFragmentDestroyWatcher 的源码分析

internal class AndroidXFragmentDestroyWatcher(
private val reachabilityWatcher: ReachabilityWatcher
) : (Activity) -> Unit {

private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {

override fun onFragmentCreated(
fm: FragmentManager,
fragment: Fragment,
savedInstanceState: Bundle?
) {
//(1.3.2.1)初始化 ViewModelClearedWatcher
ViewModelClearedWatcher.install(fragment, reachabilityWatcher)
}

override fun onFragmentViewDestroyed(
fm: FragmentManager,
fragment: Fragment
) {
//监测 fragment.view 的泄漏情况
val view = fragment.view
if (view != null) {
reachabilityWatcher.expectWeaklyReachable(
view, "${fragment::class.java.name} received Fragment#onDestroyView() callback " +
"(references to its views should be cleared to prevent leaks)"
)
}
}

override fun onFragmentDestroyed(
fm: FragmentManager,
fragment: Fragment
) {
//监测 fragment 的泄漏情况
reachabilityWatcher.expectWeaklyReachable(
fragment, "${fragment::class.java.name} received Fragment#onDestroy() callback"
)
}
}

///初始化,注册fragmentLifecycleCallbacks
override fun invoke(activity: Activity) {
if (activity is FragmentActivity) {
val supportFragmentManager = activity.supportFragmentManager
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
//注册activity的 viewModel 监听回调
ViewModelClearedWatcher.install(activity, reachabilityWatcher)
}
}
}
复制代码

通过源码可以看到,初始化该watcher是通过以下几步。

  1. FragmentManager.registerFragmentLifecycleCallbacks 注册监听回调
  2. ViewModelClearedWatcher.install 初始化了对于activity.viewModel的监听
  3. 在回调onFragmentCreated 中回调中使用ViewModelClearedWatcher.install注册了对于fragment.viewModel的监听。
  4. 在 onFragmentViewDestroyed 监听 fragment.view 的泄漏
  5. 在 onFragmentDestroyed 监听 fragment的泄漏。 监听方法和ActivityWatcher大同小异,不同是多了个 ViewModelClearedWatcher.install 。现在分析这一块的源码,也就是标注中的 (1.3.2.1)。
//该watcher 继承了ViewModel,生命周期被 ViewModelStoreOwner 管理。
internal class ViewModelClearedWatcher(
storeOwner: ViewModelStoreOwner,
private val reachabilityWatcher: ReachabilityWatcher
) : ViewModel() {

private val viewModelMap: Map<String, ViewModel>?

init {
//(1.3.2.3)通过反射获取所有的 store 存储的所有viewModelMap
viewModelMap = try {
val mMapField = ViewModelStore::class.java.getDeclaredField("mMap")
mMapField.isAccessible = true
@Suppress("UNCHECKED_CAST")
mMapField[storeOwner.viewModelStore] as Map<String, ViewModel>
} catch (ignored: Exception) {
null
}
}

override fun onCleared() {
///(1.3.2.4) viewmodle 被清理释放的时候回调,检查所有viewmodle 是否会有泄漏
viewModelMap?.values?.forEach { viewModel ->
reachabilityWatcher.expectWeaklyReachable(
viewModel, "${viewModel::class.java.name} received ViewModel#onCleared() callback"
)
}
}

companion object {
fun install(
storeOwner: ViewModelStoreOwner,
reachabilityWatcher: ReachabilityWatcher
) {
val provider = ViewModelProvider(storeOwner, object : Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T =
ViewModelClearedWatcher(storeOwner, reachabilityWatcher) as T
})
///(1.3.2.2) 获取ViewModelClearedWatcher实例
provider.get(ViewModelClearedWatcher::class.java)
}
}
}
复制代码

通过代码,可以看到viewModel的泄漏监测是通过创建一个新的viewModel实例来实现。在该实例的onCleared处监听storeOwner的其余 viewModel 是否有泄漏。标注出的代码逐一分析:

(1.3.2.2 ) 处代码:

获取ViewModelClearedWatcher 实例,在自定义的 Factory中传入storeOwner 和 reachabilityWatcher。

(1.3.2.3 ) 处代码:

通过反射获取storeOwner 的viewModelMap

(1.3.2.4 ) 处代码:

在ViewModel完成使命OnClear的时候,开始监测storeOwner旗下所有ViewModel的内存泄漏情况。

(1.3-end)Fragment 和 viewmodel 监测泄漏总结:

监测方式都是通过ObjectWatcher的 expectWeaklyReachable 方法进行。fragment 利用FragmentLifecyclerCallback回调注册实现,ViewModel 则是在对应StoreOwner下创建了监测viewModel来实现生命周期的响应。 其中我们也能学习到通过反射来创建对应的平台兼容实现对象方式。以及借助创建viewModel来监听其余ViewModel生命周期的想法。

(1.4) RootViewWatcher 的源码分析

默认的四个Watcher中,来到了接下来的 RootViewWatcher。window rootview 监听依赖了squre自家的Curtains框架。

implementation "com.squareup.curtains:curtains:1.0.1"
复制代码

类的关键源码如下:

 private val listener = OnRootViewAddedListener { rootView ->
//如果是 Dialog TOOLTIP, TOAST, UNKNOWN 等类型的windows
//trackDetached 为true
if (trackDetached) {
rootView.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {

val watchDetachedView = Runnable {
reachabilityWatcher.expectWeaklyReachable(
rootView, "${rootView::class.java.name} received View#onDetachedFromWindow() callback"
)
}

override fun onViewAttachedToWindow(v: View) {
mainHandler.removeCallbacks(watchDetachedView)
}

override fun onViewDetachedFromWindow(v: View) {
mainHandler.post(watchDetachedView)
}
})
}
}

override fun install() {
Curtains.onRootViewsChangedListeners += listener
}

override fun uninstall() {
Curtains.onRootViewsChangedListeners -= listener
}
}
复制代码

看到关键代码,就是 在Curtains中添加onRootViewsChangedListeners 监听器。当windowsType类型为 **Dialog** ***TOOLTIP***, ***TOAST*****,**或者未知的时候 ,在 onViewDetachedFromWindow 的时候监听泄漏情况。 Curtains中的监听器会在windows rootView 变化的时候被全局调用。Curtains是squareup 的另一个开源库,Curtains 提供了用于处理 Android 窗口的集中式 API。具体移步他的官方仓库

(1.5) ServiceWatcher 监听Service内存泄漏

接下来就是AppWatcher中的最后一个Watcher。 ServiceWatcher。代码比较长,截取关键点分析。

(1.5.1)先看成员变量 activityThreadServices :

private val servicesToBeDestroyed = WeakHashMap<IBinder, WeakReference<Service>>()
private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") }
private val activityThreadInstance by lazy {
activityThreadClass.getDeclaredMethod("currentActivityThread").invoke(null)!!
}

private val activityThreadServices by lazy {
val mServicesField =
activityThreadClass.getDeclaredField("mServices").apply { isAccessible = true }

@Suppress("UNCHECKED_CAST")
mServicesField[activityThreadInstance] as Map<IBinder, Service>
}
复制代码

activityThreadServices 是个装了所有<IBinder, Service> 对的Map。代码中可以看到很粗暴地,直接通过反射从ActivityThread实例中拿到了mServices 变量 。赋值给activityThreadServices。 源码中有多个swap操作,在install的时候执行,主要目的是将原来的一些service相关生命周期回调加上一些钩子,用来监测内存泄漏,并且会在unInstall的时候给换回来。

(1.5.2)swapActivityThreadHandlerCallback :

拿到ActivityThread 的Handler,将其回调的 handleMessage,换成加了料的Handler.Callback,加料代码如下

Handler.Callback { msg ->
if (msg.what == STOP_SERVICE) {
val key = msg.obj as IBinder
activityThreadServices[key]?.let {
onServicePreDestroy(key, it)
}
}
mCallback?.handleMessage(msg) ?: false
}
复制代码

代码中可以看到,主要是对于 STOP_SERVICE 的操作做了一个钩子,在之前执行 onServicePreDestroy。主要作用是为该service 创建一个弱引用,并且加到servicesToBeDestroyed[token] 中 。

(1.5.3)然后再看 swapActivityManager 方法。

该方法完成了将ActivityManager替换成IActivityManager的一个动态代理类。代码如下:

Proxy.newProxyInstance(
activityManagerInterface.classLoader, arrayOf(activityManagerInterface)
) { _, method, args ->
//private const val METHOD_SERVICE_DONE_EXECUTING = "serviceDoneExecuting"
if (METHOD_SERVICE_DONE_EXECUTING == method.name) {
val token = args!![0] as IBinder
if (servicesToBeDestroyed.containsKey(token)) {
///(1.5.3)
onServiceDestroyed(token)
}
}
try {
if (args == null) {
method.invoke(activityManagerInstance)
} else {
method.invoke(activityManagerInstance, *args)
}
} catch (invocationException: InvocationTargetException) {
throw invocationException.targetException
}
}
复制代码

代码所示,替换后的ActivityManager 在调用serviceDoneExecuting 方法的时候添加了个钩子,如果该service在之前加入的servicesToBeDestroyed map中,则调用onServiceDestroyed 监测该service内存泄漏。

(1.5.4)代码的onServiceDestroyed具体代码如下

private fun onServiceDestroyed(token: IBinder) {
servicesToBeDestroyed.remove(token)?.also { serviceWeakReference ->
serviceWeakReference.get()?.let { service ->
reachabilityWatcher.expectWeaklyReachable(
service, "${service::class.java.name} received Service#onDestroy() callback"
)
}
}
}
复制代码

这里面的代码很熟悉,和之前监测activity等是一样的。 回到swapActivityManager方法,看代理ActivityManager的具体类型。 可以看到代理的对象如下面代码所示,根据版本不同可能是ActivityManager 实例或者是ActivityManagerNative实例。 代理的接口是 Class.forName("android.app.IActivityManager")

val (className, fieldName) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
"android.app.ActivityManager" to "IActivityManagerSingleton"
} else {
"android.app.ActivityManagerNative" to "gDefault"
}
复制代码

(1.5-end)Service 泄漏监测总结

总结一下,service的泄漏分析通过加钩子的方式,对一些系统执行做了监听。主要分为以下几步:

  1. 获取ActivityThread中mService变量,得到service实例的引用
  2. 通过swapActivityThreadHandlerCallback 在ActivityThread 的 Handler.sendMessage 中添加钩子,在执行到msg.what == STOP_SERVICE 的时候


收起阅读 »

Android Compose 初探!

使用前的准备工作android studio Arctic Fox版本或更新的版本如果是一个新项目,可以在创建的时候,新建一个Empty Compose Activity在module的build.gradle文件中添加android { buildF...
继续阅读 »

使用前的准备工作

  1. android studio Arctic Fox版本或更新的版本

  2. 如果是一个新项目,可以在创建的时候,新建一个Empty Compose Activity

    image.png

  3. 在module的build.gradle文件中添加

    android {
    buildFeatures {
    compose true
    }
    composeOptions {
    kotlinCompilerExtensionVersion compose_version
    kotlinCompilerVersion '1.4.32'
    }
    }
    dependencies {
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.3.0'
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
    implementation 'androidx.activity:activity-compose:1.3.0-alpha06'
    }

需要添加

buildFeatures {
compose true
}

组件

组件的定义

在Compose中一个UI组件就是一个带有@Composable注解的函数

@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}

布局组件

如果没有采用布局组件,直接单视图写到一个Compose中,会存在异常的情况。官方是这么说的:

A Composable function might emit several UI elements. However, if you don't provide guidance on how they should be arranged, Compose might arrange the elements in a way you don't like

  • Row 横向排列视图, Row的相关属性如下:
    inline fun Row(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    content: @Composable RowScope.() -> Unit
    )
  • Column 纵向排列视图, 其属性和上面的Row类似
  • Box 将一个元素覆盖在另一个上面, 类似于FrameLayout这种

视图组件

  • Text 类似于原生View中的TextView
  • Button 按钮
  • LazyColumn 类似于原生RecyclerView
  • Image 图片控件 关于网络图片,可以采用Coil框架
  • TextField 文件输入框
  • Surface用来控制组件的背景,边框,文本颜色等
  • AlertDialog 弹窗控件,类似于原生View中的AlertDialog

组件的状态管理

remember

通过remember来记录组件某些相关属性值,当属性发生变化,会自动触发UI的更新。

@Composable
fun HelloContent() {
Column(modifier = Modifier.padding(16.dp)) {
var nameState = remember { mutableStateOf("") }
var name = nameState.value;
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
TextField(
value = name,
onValueChange = { println("data----->$it");nameState.value = it }
)
}
}

这段代码实现的功能就是当用户在一个输入框中输入文字的时候,即时回显在页面上。当采用这种方式编码时,状态是耦合在组件中,当调用者不关心内部的状态的,这种方式是ok的,但它的弊端就是不利于组件的复用。我们可以将状态和组件分离开,此时,便就是利用状态提升(state hoisting)的手段

@Composable
fun HelloScreen() {
var nameState = remember { mutableStateOf("") }
HelloContent(name = nameState.value, onNameChange = { nameState.value = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
TextField(
value = name,
onValueChange = { onNameChange(it) }
)
}
}

这里是将状态提到HelloContent的外面, 方面HelloContent组件的复用

rememberSaveable

remember类似,区别在于rememberSaveable进行状态管理时,当activity或进程重新创建了(如屏幕旋转),其状态信息不会丢失。 将上面的var nameState = remember { mutableStateOf("") } 中的remember换成rememberSaveable就可以了

ViewModel

可以利用ViewModel进行全局的状态管理

class HelloViewModel : ViewModel() {

// LiveData holds state which is observed by the UI
// (state flows down from ViewModel)
private val _name = MutableLiveData("")
val name: LiveData = _name

// onNameChange is an event we're defining that the UI can invoke
// (events flow up from UI)
fun onNameChange(newName: String) {
_name.value = newName
}
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
// by default, viewModel() follows the Lifecycle as the Activity or Fragment
// that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

// name is the current value of [helloViewModel.name]
// with an initial value of ""
val name: String by helloViewModel.name.observeAsState("")
HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
}

Modifers

Modifers是用来装饰composable, Modifiers用来告诉一个UI元素如何布局,显示,和相关的行为。

布局相关的属性

  • fillMaxWidth
  • matchParentSize
  • height
  • width
  • padding
  • size

显示

  • background
  • clip: 如Modifier.clip(RoundedCornerShape(4.dp)),一个圆角便出来了

绑定事件

利用clickable来绑定事件

Row(
Modifier
.fillMaxWidth()
.clickable { onClick(); },
verticalAlignment = Alignment.CenterVertically
) {
...
}

实例

采用Compose方案的开发体验非常接近于用Vue或React, 代码结构非常清晰,不用xml来画UI确实省了不少事,以下是一段代码片断来画一个微信的个人中心页

image.png

@Preview(showBackground = true)
@Composable
fun PersonalCenter() {
Column() {
Header("Hello World", "Wechat_0001")
Divider(
Modifier
.fillMaxWidth()
.height(8.dp), GrayBg
)
RowList()
Divider(
Modifier
.fillMaxHeight(), GrayBg
)
}
}

@Composable
fun Header(nickName: String, wechatNo: String) {
Row(
Modifier
.fillMaxWidth()
.padding(24.dp, 24.dp, 16.dp, 24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.avatar),
contentDescription = "头像",
Modifier
.size(50.dp)
.clip(
RoundedCornerShape(4.dp)
)
)
Column() {
Text(nickName, Modifier.padding(12.dp, 2.dp, 0.dp, 0.dp), TextColor, fontSize = 18.sp)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"微信号 :$wechatNo",
Modifier
.padding(12.dp, 10.dp, 0.dp, 0.dp)
.weight(1.0f), TextColorGray, fontSize = 14.sp
)
Icon(painterResource(R.drawable.ic_qrcode), "二维码", Modifier.size(16.dp))
Icon(
painterResource(R.drawable.right_arrow_3),
contentDescription = "more",
Modifier.padding(12.dp, 0.dp, 0.dp, 0.dp)
)
}
}
}
}

@Composable
fun RowItem(@DrawableRes icon: Int, title: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable { onClick(); },
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(icon), contentDescription = title + "icon",
Modifier
.padding(16.dp, 12.dp, 16.dp, 12.dp)
.size(24.dp)
)
Text(title, Modifier.weight(1f), TextColor, fontSize = 15.sp)
Icon(
painterResource(R.drawable.right_arrow_3),
contentDescription = "more",
Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp)
)
}
}

@Composable
fun RowList() {
var context = LocalContext.current;
Column() {
RowItem(icon = R.drawable.ic_pay, title = "支付") { onItemClick(context, "payment") }
Divider(
Modifier
.fillMaxWidth()
.height(8.dp), GrayBg
)
RowItem(icon = R.drawable.ic_collections, title = "收藏") {
onItemClick(context, "收藏")
}
Divider(
Modifier
.fillMaxWidth()
.padding(56.dp, 0.dp, 0.dp, 0.dp)
.height(0.2.dp), GrayBg
)
RowItem(icon = R.drawable.ic_photos, title = "相册") {
onItemClick(context, "相册")
}
Divider(
Modifier
.fillMaxWidth()
.padding(56.dp, 0.dp, 0.dp, 0.dp)
.height(0.2.dp), GrayBg
)
RowItem(icon = R.drawable.ic_cards, title = "卡包") {
Toast.makeText(context, "payment", Toast.LENGTH_SHORT).show()
}
Divider(
Modifier
.fillMaxWidth()
.padding(56.dp, 0.dp, 0.dp, 0.dp)
.height(0.2.dp), GrayBg
)
RowItem(icon = R.drawable.ic_stickers, title = "表情") {
Toast.makeText(context, "payment", Toast.LENGTH_SHORT).show()
}
Divider(
Modifier
.fillMaxWidth()
.height(8.dp), GrayBg
)
RowItem(icon = R.drawable.ic_settings, title = "设置") {
Toast.makeText(context, "payment", Toast.LENGTH_SHORT).show()
}
}
}

fun onItemClick(context: Context, data: String) {
Toast.makeText(context, data, Toast.LENGTH_SHORT).show()
}

View中嵌Compose

var view = LinearLayout(this)
view.addView(ComposeView(this).apply {
setContent {
PersonalCenter();
}
})

Compose中嵌View

@Compose
fun RowList() {
...
AndroidView({View(context)}, Modifier.width(20.dp).height(20.dp).background(Color.Green)){}
...
}

总结

  • Compose使用了一套新的布局,渲染机制, 它里面的元素和我们以前写的各种View是有区别的,比如Compose里面的Text并不是我们以前认识的TextView或其它的原生控件, 它采用了更底层的api来实现
  • 数据的自动订阅(完成双向绑定)
  • 声明式UI: compose通过自动订阅机制来完成UI的自动更新
  • compose和现有的原生View混用
收起阅读 »

Android 安卓超级强劲的轻量级数据库ObjectBox,快的飞起

文章目录 ObjectBox 引入ObjectBox 简单的代码栗子 生成和创建数据库 ObjectBox初始化 基本操作 - 增 基本操作 - ...
继续阅读 »


在这里插入图片描述







ObjectBox


ObjectBox是一个超快的面向对象数据库,相比于Sqlite,效率高了10倍左右






引入ObjectBox


在跟项目中的build.gradle中引入:


buildscript {
...
ext.objectboxVersion = '2.9.1'

dependencies {
...
classpath "io.objectbox:objectbox-gradle-plugin:$objectboxVersion"
...
}
}

在app下的build.gradle头部引入


(有以下两种情况,看你项目中用的什么):


plugins {
...
id 'io.objectbox'
}

apply plugin: 'io.objectbox'





简单的代码栗子


接下来将会讲解ObjectBox基本使用






生成和创建数据库


1、新建一个模型类,并使用 @Entity 将类注解,@Id 为自增主键(进阶的代码栗子会详细一点讲注解),@Id 注解也是必不可少的。


package com.mt.objectboxproject

import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id

@Entity
data class Person(
@Id
var id: Long = 0,
var age: Int = 0,
var name: String? = null
)



2、AndroidStudio操作:Build -> MakeProject,或者点击运行按钮旁边的小锤子锤一下,这一步是为了生成ObjectBox所需要的文件,之后便会看到生成了 app\objectbox-models\default.json 文件






ObjectBox初始化


1、创建ObjectBox的小助手,需要在 Application 中进行调用 init 初始化


package com.mt.objectboxproject

import android.content.Context
import io.objectbox.BoxStore

/**
* ObjectBox的小助手,需要在Application中进行调用init初始化
*/

object ObjectBox {
lateinit var store: BoxStore
private set

fun init(context: Context) {
store = MyObjectBox.builder()
.androidContext(context.applicationContext)
.build()
}
}



2、在 Application 中初始化


package com.mt.objectboxproject

import android.app.Application

class MainApplication : Application() {
override fun onCreate() {
super.onCreate()

//初始化ObjectBox
ObjectBox.init(this)
}
}



基本操作 - 增


package com.mt.objectboxproject

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

//插入一条数据
val userBox = ObjectBox.store.boxFor(Person::class.java)
val person = Person()
person.age = 21
person.name = "第三女神程忆难"
userBox.put(person)

//==========================================================================================

//插入多条数据
val persons = mutableListOf<Person>()

//模拟多条数据
val person1 = Person()
person1.age = 24
person1.name = "1bit"

val person2 = Person()
person2.age = 25
person2.name = "梦想橡皮擦"

val person3 = Person()
person3.age = 26
person3.name = "沉默王二"

persons.add(person1)
persons.add(person2)
persons.add(person3)

//插入数据库
userBox.put(persons)


}
}



基本操作 - 查


package com.mt.objectboxproject

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val userBox = ObjectBox.store.boxFor(Person::class.java)

//==========================================================================================
//根据主键id查询
val person = userBox[1]

//==========================================================================================
//获取person有所数据
val allPersons = userBox.all

//==========================================================================================
//条件查询
val build = userBox.query()
.equal(Person_.name, "沉默王二")
.order(Person_.name)
.build()

//查找数据
val find = build.find()

//记得close
build.close()

}
}



基本操作 - 删


package com.mt.objectboxproject

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val userBox = ObjectBox.store.boxFor(Person::class.java)

//==========================================================================================
//根据主键Id去删除
val isRemoved = userBox.remove(1)

//==========================================================================================
//根据主键id集合去删除
val ids = mutableListOf<Long>(1,2,3,4)
userBox.removeByIds(ids)

//==========================================================================================
//根据模型类去删除
val person = userBox[1]
person.name = "第三女神程忆难"
userBox.remove(person)

//==========================================================================================
//删除所有数据
userBox.removeAll()

}
}



基本操作 - 改


package com.mt.objectboxproject

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val userBox = ObjectBox.store.boxFor(Person::class.java)

//==========================================================================================
//先查询获取到person,set值后重新put
val person = userBox[1]
person.name = "小傅哥"
userBox.put(person)
}
}





进阶的代码栗子


注解讲解






注解



  • @Id:主键,默认为自增主键,交由ObjectBox管理

  • @Index:注释一个属性,为相应的数据库列创建一个数据库索引。这可以提高查询该属性时的性能。

  • @Transient:标记不应保留的属性。在 Java 中,静态或瞬态属性也不会被持久化。

  • @NameInDb:对数据库中字段名进行自定义。

  • @Backlink:反向关联。

  • @ToOne:一对一关联注解。

  • @ToMany:一对多关联注解。






ObjectBox地址:https://docs.objectbox.io

收起阅读 »

简阅-一个以Kotlin实现的第三方聚合阅读App开源啦

简阅(SimpleRead)以Kotlin实现的简单纯净的阅读软件,主要使用到MVP+RxJava+Retrofit+RxLifecycle+Glide+GreenDao等技术软件开发背景简阅是我学习安卓开发的第一个项目,最初是使用传统的MVC模式,然后一步步...
继续阅读 »

简阅(SimpleRead)

以Kotlin实现的简单纯净的阅读软件,主要使用到MVP+RxJava+Retrofit+RxLifecycle+Glide+GreenDao等技术

软件开发背景

简阅是我学习安卓开发的第一个项目,最初是使用传统的MVC模式,然后一步步迭代,由MVP再到Kotlin.如今项目功能已经基本稳定,我将项目规范了下, 然后开源供大家交流学习,毕竟当时学习也看了很多前辈的项目,学到了很多,所以现在是时候回报开源社区啦。

软件地址

酷安下载地址

软件截图

  

实现的功能

知乎日报
  • 获取知乎日报最新新闻
  • 上拉加载前一天知乎新闻
  • 可选择阅读具体某天的知乎新闻
  • 可随机阅读一篇知乎新闻
  • 已读新闻灰显
  • 收藏/取消收藏某一篇新闻
  • 分享新闻
煎蛋新鲜事
  • 获取最新煎蛋新鲜事
  • 上拉加载前一天新鲜事
  • 已读新闻灰显
  • 收藏/取消收藏某一篇新闻
  • 分享新闻
每日一文
  • 查看当天的文章
  • 随机一篇文章
  • 三种阅读风格切换

其余

  • 遵循Material Design设计
  • 多种主题选择
  • Frament懒加载
  • 网络缓存
  • 离线缓存

技术慨要

  • MVP

    MVP是目前安卓开发中最流行的架构之一,Model负责数据和业务逻辑,View层负责view相关的展示以及context层的调用,Presenter层负责使M层和V层交互

  • RxJava

    RxJava是一个基于事件流的异步响应框架

    给 Android 开发者的 RxJava 详解 -- 扔物线

  • Retrofit

    RESTful的HTTP网络请求框架,优势在于可以结合RxJava实现链式网络请求以及轻松实现线程调度,同时它是以注解的方式标注请求,优雅简洁

  • RxLifecycle

    RxLifecycle是知乎团队出的一个方便取消RxJava订阅的库,使用它结合RxJava无需再到onDestory()中取消订阅

  • GreenDao

    GreenDao是一个老牌ORM数据库框架,目前3.2.2版本可以说是最值得使用的ORM框架

  • Glide

    一个API简洁但是功能极为强大的图片加载框架

  • jsoup

    jsoup是一个强大的解析html网页源码的库

  • BaseRecyclerViewAdapterHelper

    一个快速实现RecyclerviewAdapter的库,和普通写法比起来能减少70%代码量

  • 其余还有一些相关技术就不一一罗列出来了,大家可以自行查看源码.

收起阅读 »

Android资源冲突检测插件

背景 之前我们写了一篇定义关于如何定义Gradle插件,有兴趣的朋友可以看一下,今天我们就来简单讲一个自定义Gradle插件的实战Android项目Module间资源冲突检测的Gradle插件。对应的使用方法和源码已经在GitHub给出Android资源冲突检...
继续阅读 »

背景


之前我们写了一篇定义关于如何定义Gradle插件,有兴趣的朋友可以看一下,今天我们就来简单讲一个自定义Gradle插件的实战Android项目Module间资源冲突检测的Gradle插件。对应的使用方法和源码已经在GitHub给出Android资源冲突检测插件


解决问题


具体我们的插件的作用是干什么的呢?这里简单解释一下,就是当我们的项目越来越大的时候我们会将项目拆分为多个Module,这个时候,每个Module里面都有自己的资源文件,包括图片,文字,颜色,字体大小等。如果我们在多个Module里面定义了相同名字的资源,但是对应的资源内容不一样,这个时候项目并不会出错,但是当我们最终打包的时候多个Module中的资源只会留下一个,这样我们想要的效果就会出错。这个插件就是用来跑整个项目所有Module将有冲突的资源提取出来,目前只支持String,Color,Dimen,其他的会在后续补充。


实现方式


首先,我们先接着自定义Gradle插件的思路往下讲,关于自定义Gradle的一些基本知识:

大家也可以查看:Gradle官方文档

或者查看我的上一篇:如何定义Gradle插件

1、先定义一个我们自己的Task,继承DefaultTask,用来接收一些参数


public class GeekTask extends DefaultTask {
private boolean strings;
private boolean colors;
private boolean dimens;
public boolean getStringFlag() {
return strings;
}
@Input
public void checkString(boolean flag) {
this.strings = flag;
}

public boolean getColorFlag() {
return colors;
}
@Input
public void checkColors(boolean flag) {
this.colors = flag;
}

@Input
public void checkDimens(boolean flag){
this.dimens = flag;
}

public boolean getDimensFlag(){
return dimens;
}
@TaskAction
void sayGreeting() {
System.out.printf("%s, %s!\n", getStringFlag(), getColorFlag());
}

}

2、我们怎么去调用我们定义的这个Task呢?


 @Override
void apply(Project project) {
GeekTask task = project.getTasks().create("checkResources", GeekTask.class)
}

其中checkResources是我们定义的Task的名称,后面我们可以调用它。


checkResources{
checkString true
checkColors true
checkDimens true
}

这个是定义在我们需要使用我们自己写的插件的Module对应的Gradle文件里面的checkResources表示Task的名字,下面的是对应的方法和参数。当然,在这个Gradle里面需要引用我们的插件apply plugin: '插件名字'

3、使用传递进来的参数。


GeekTask task = project.getTasks().create("checkResources", GeekTask.class)
task.doLast {
System.out.println(task.getStringFlag())
if (task.getStringFlag()) {
// do check string
}
if (task.getColorFlag()) {
// do check color
}
if (task.getDimensFlag()) {
// do check dimen
}
}

上面我们通过我们定义的task就可获取到,我们传递进来的参数,task.doLast这一步表示我们里面的代码执行在Task的之后保证可以获取到参数,这里稍微讲一下插件代码的运行时机:

如果我们直接写在apply()方法中的代码是执行的编译期,也就是一开始就执行了,是执行在任何之前的。

task.doFirst {}虽然也是在Task之前执行,但是它是在要执行Task的时候先执行doFirst里面的代码。

task.doLast{}这个是执行在Task执行之后的。

4、怎么实现资源检测。

这个代码比较简单主要是获取所有module下对应资源的文件,然后进行解析和比较,具体的代码这里就不写了,有兴趣的朋友可以下载完整代码Android资源冲突检测插件


如何使用


首先我们要在项目最外层的build.gradle里面引用我上传的项目


apply plugin: 'geekplugin'

其次加载其代码


classpath 'com.geek.check:AndroidResourceCheck:1.0.0'

这里注意是calsspath具体和compile的区别大家可以Google一下

然后设置参数,用来配置我们需要检测的资源


checkResources{
checkString true
checkColors true
checkDimens true
}

最后就是运行这个插件

我们可以在项目的根目录运行这个Task


gradle checkResources

如果我们有资源冲突文件,最后会在项目的跟目录生成ResourcesError目录,对应的冲突文件在里面,大家可以查看。


总结


好了,这个插件大概就这么多东西,相信大家通过这个也会对自定义Gradle插件有更深的一些认识,当然,这还只是一些皮毛,更深层次的使用还需要大家去研究,谁有更好的资料和建议也可以评论提出,我们一起进步。



作者:Only凹凸曼
链接:https://www.jianshu.com/p/9d2a047f2c22
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

一文全面了解Android单元测试

前言 众所周知,一个好的项目需要不断地打造,而一些有效的测试则是加速这一过程的利器。本篇博文将带你了解并逐步深入Android单元测试。 什么是单元测试? 单元测试就是针对类中的某一个方法进行验证是否正确的过程,单元就是指独立的粒子,在Android...
继续阅读 »

前言


众所周知,一个好的项目需要不断地打造,而一些有效的测试则是加速这一过程的利器。本篇博文将带你了解并逐步深入Android单元测试。


什么是单元测试?




单元测试就是针对类中的某一个方法进行验证是否正确的过程,单元就是指独立的粒子,在Android和Java中大都是指方法。


为什么要进行单元测试?




使用单元测试可以提高开发效率,当项目随着迭代越来越大时,每一次编译、运行、打包、调试需要耗费的时间会随之上升,因此,使用单元测试可以不需这一步骤就可以对单个方法进行功能或逻辑测试。 同时,为了能测试每一个细分功能模块,需要将其相关代码抽成相应的方法封装起来,这也在一定程度上改善了代码的设计。因为是单个方法的测试,所以能更快地定位到bug。


单元测试case需要对这段业务逻辑进行验证。在验证的过程中,开发人员可以深度了解业务流程,同时新人来了看一下项目单元测试就知道哪个逻辑跑了多少函数,需要注意哪些边界——是的,单元测试做的好和文档一样具备业务指导能力。


Android测试的分类




Android测试主要分为三个方面:



  • 1)、单元测试(Junit4、Mockito、PowerMockito、Robolectric)

  • 2)、UI测试(Espresso、UI Automator)

  • 3)、压力测试(Monkey)


一、单元测试之基础Junit4




什么是Junit4?




Junit4是事实上的Java标准测试库,并且它是JUnit框架有史以来的最大改进,其主要目标便是利用Java5的Annotation特性简化测试用例的编写。


开始使用Junit4进行单元测试




1.Android Studio已经自动集成了Junit4测试框架,如下


    dependencies {
...
testImplementation 'junit:junit:4.12'
}

2.Junit4框架使用时涉及到的重要注解如下


    @Test 指明这是一个测试方法 (@Test注解可以接受2个参数,一个是预期错误
expected,一个是超时时间timeout,
格式如 @Test(expected = IndexOutOfBoundsException.class),
@Test(timeout = 1000)
@Before 在所有测试方法之前执行
@After 在所有测试方法之后执行
@BeforeClass 在该类的所有测试方法和@Before方法之前执
行 (修饰的方法必须是静态的)@AfterClass 在该类的所有测试方法和@After
方法之后执行(修饰的方法必须是静态的)
@Ignore 忽略此单元测试

此外,很多时候,因为某些原因(比如正式代码还没有实现等),我们可能想让JUnit忽略某些方法,让它在跑所有测试方法的时候不要跑这个测试方法。要达到这个目的也很简单,只需要在要被忽略的测试方法前面加上@Ignore就可以了


3.主要的测试方法——断言


    assertEquals(expected, actual) 判断2个值是否相等,相等则测试通过。
assertEquals(expected, actual, tolerance) tolerance 偏差值

注意:上面的每一个方法,都有一个重载的方法,可以加一个String类型的参数,表示如果验证失败的话,将用这个字符串作为失败的结果报告


4.自定义Junit Rule——实现TestRule接口并重写apply方法


    public class JsonChaoRule implements TestRule {

@Override
public Statement apply(final Statement base, final Description description) {
Statement repeatStatement = new Statement() {
@Override
public void evaluate() throws Throwable {
//测试前的初始化工作
//执行测试方法
base.evaluate();
//测试后的释放资源等工作
}
};
return repeatStatement;
}
}

然后在想要的测试类中使用@Rule注解声明使用JsonChaoRule即可(注意被@Rule注解的变量必须是final的):


    @Rule
public final JsonChaoRule repeatRule = new JsonChaoRule();

5.开始上手,使用Junit4进行单元测试



  • 1.编写测试类。

  • 2.鼠标右键点击测试类,选择选择Go To->Test (或者使用快捷键Ctrl+Shift+T,此快捷键可 以在方法和测试方法之间来回切换)在Test/java/项目 测试文件夹/下自动生成测试模板。

  • 3.使用断言(assertEqual、assertEqualArrayEquals等等)进行单元测试。

  • 4.右键点击测试类,Run编写好的测试类。


6.使用Android Studio自带的Gradle脚本自动化单元测试


点击Android Studio中的Gradle projects下的app/Tasks/verification/test即可同时测试module下所有的测试类(案例),并在module下的build/reports/tests/下生成对应的index.html测试报告


7.对Junit4的总结:



  • 优点:速度快,支持代码覆盖率等代码质量的检测工具,

  • 缺点:无法单独对Android UI,一些类进行操作,与原生JAVA有一些差异。


可能涉及到的额外的概念:


打桩方法:使方法简单快速地返回一个有效的结果。


测试驱动开发:编写测试,实现功能使测试通过,然后不断地使用这种方式实现功能的快速迭代开发。


二、单元测试之基础Mockito


什么是Mockito?


Mockito 是美味的 Java 单元测试 Mock 框架,mock可以模拟各种各样的对象,从而代替真正的对象做出希望的响应。


开始使用Mockito进行单元测试


1.在build.gradle里面添加Mcokito的依赖


    testImplementation 'org.mockito:mockito-core:2.7.1'

2.使用mock()方法模拟对象


    Person mPerson = mock(Person.class); 

能量补充站(-vov-)


在JUnit框架下,case(即每一个测试点,带@Test注解的那个函数)也是个函数,直接调用这个函数就不是case,和case是无关的,两者并不会相互影响,可以直接调用以减少重复代码。单元测试不应该对某一个条件过度耦合,因此,需要用mock解除耦合,直接mock出网络请求得到的数据,单独验证页面对数据的响应。


3.验证方法的调用,指定方法的返回值,或者执行特定的动作


    when(iMathUtils.sum(1, 1)).thenReturn(2); 
doReturn(3).when(iMathUtils).sum(1,1);
//给方法设置桩可以设置多次,只会返回最后一次设置的值
doReturn(2).when(iMathUtils).sum(1,1);

//验证方法调用次数
//方法调用1次
Mockito.when(mockValidator.verifyPassword("xiaochuang_is_handsome")).thenReturn(true);
//方法调用3次
Mockito.when(mockValidator.verifyPassword("xiaochuang_is_handsome")
, Mockito.times(3).thenReturn(true);

//verify方法用于验证“模仿对象”的互动或验证发生的某些行为
verify(mPerson, atLeast(2)).getAge();

//参数匹配器,用于匹配特定的参数
any()
contains()
argThat()
when(mPerson.eat(any(String.class))).thenReturn("米饭");

//除了mock()外,spy()也可以模拟对象,spy与mock的
//唯一区别就是默认行为不一样:spy对象的方法默认调用
//真实的逻辑,mock对象的方法默认什么都不做,或直接
//返回默认值
//如果要保留原来对象的功能,而仅仅修改一个或几个
//方法的返回值,可以采用spy方法,无参构造的类初始
//化也使用spy方法
Person mPerson = spy(Person.class);

//检查入参的mocks是否有任何未经验证的交互
verifyNoMoreInteractions(iMathUtils);

4.使用Mockito后的思考


简单的测试会使整体的代码更简单,更可读、更可维护。如果你不能把测试写的很简单,那么请在测试时重构你的代码



  • 优点:丰富强大的方式验证“模仿对象”的互动或验证发生的某些行为

  • 缺点:Mockito框架不支持mock匿名类、final类、static方法、private方法。


虽然,static方法可以使用wrapper静态类的方式实现mockito的单元测试,但是,毕竟过于繁琐,因此,PowerMockito由此而来。


三、拯救Mockito于水深火热的PowerMockito


什么是PowerMockito?


PowerMockito是一个扩展了Mockito的具有更强大功能的单元测试框架,它支持mock匿名类、final类、static方法、private方法


开始PowerMockito之旅


1.在build.gradle里面添加Mcokito的依赖


    testImplementation 'org.powermock:powermock-module-junit4:1.6.5'
testImplementation 'org.powermock:powermock-api-mockito:1.6.5'

2.用PowerMockito来模拟对象


    //使用PowerMock须加注解@PrepareForTest和@RunWith(PowerMockRunner.class)(@PrepareForTest()里写的
// 是对应方法所在的类 ,mockito支持的方法使用PowerMock的形式实现时,可以不加这两个注解)
@PrepareForTest(T.class)
@RunWith(PowerMockRunner.class)

//mock含静态方法或字段的类
PowerMockito.mockStatic(Banana.class);

//Powermock提供了一个Whitebox的class,可以方便的绕开权限限制,可以get/set private属性,实现注入。
//也可以调用private方法。也可以处理static的属性/方法,根据不同需求选择不同参数的方法即可。
修改类里面静态字段的值
Whitebox.setInternalState(Banana.class, "COLOR", "蓝色");

//调用类中的真实方法
PowerMockito.when(banana.getBananaInfo()).thenCallRealMethod();

//验证私有方法是否被调用
PowerMockito.verifyPrivate(banana, times(1)).invoke("flavor");

//忽略调用私有方法
PowerMockito.suppress(PowerMockito.method(Banana.class, "flavor"));

//修改私有变量
MemberModifier.field(Banana.class, "fruit").set(banana, "西瓜");

//使用PowerMockito mock出来的对象可以直接调用final方法
Banana banana = PowerMockito.mock(Banana.class);

//whenNew 方法的意思是之后 new 这个对象时,返回某个被 Mock 的对象而不是让真的 new
//新的对象。如果构造方法有参数,可以在withNoArguments方法中传入。
PowerMockito.whenNew(Banana.class).withNoArguments().thenReturn(banana);

3.使用PowerMockRule来代替@RunWith(PowerMockRunner.class)的方式,需要多添加以下依赖:


    testImplementation "org.powermock:powermock-module-junit4-rule:1.7.4"
testImplementation "org.powermock:powermock-classloading-xstream:1.7.4"

使用示例如下:


    @Rule
public PowerMockRule mPowerMockRule = new PowerMockRule();

4.使用Parameterized来进行参数化测试:


通过注解@Parameterized.parameters提供一系列数据给构造器中的构造参数或给被注解@Parameterized.parameter注解的public全局变量


    RunWith(Parameterized.class)
public class ParameterizedTest {

private int num;
private boolean truth;

public ParameterizedTest(int num, boolean truth) {
this.num = num;
this.truth = truth;
}

//被此注解注解的方法将把返回的列表数据中的元素对应注入到测试类
//的构造函数ParameterizedTest(int num, boolean truth)中
@Parameterized.Parameters
public static Collection providerTruth()
{
return Arrays.asList(new Object[][]{
{0, true},
{1, false},
{2, true},
{3, false},
{4, true},
{5, false}
});
}

// //也可不使用构造函数注入的方式,使用注解注入public变量的方式
// @Parameterized.Parameter
// public int num;
// //value = 1指定括号里的第二个Boolean值
// @Parameterized.Parameter(value = 1)
// public boolean truth;

@Test
public void printTest() {
Assert.assertEquals(truth, print(num));
System.out.println(num);
}

private boolean print(int num) {
return num % 2 == 0;
}

}

四、能在Java单元测试里面执行Android代码的Robolectric


什么是Robolectric?


Robolectric通过一套能运行在JVM上的Android代码,解决了在Java单元测试中很难进行Android单元测试的痛点。


进入Roboletric的领地




1.在build.gradle里面添加Robolectric的依赖


        //Robolectric核心
testImplementation "org.robolectric:robolectric:3.8"
//支持support-v4
testImplementation 'org.robolectric:shadows-support-v4:3.4-rc2'
//支持Multidex功能
testImplementation "org.robolectric:shadows-multidex:3.+"

2.Robolectric常用用法


首先给指定的测试类上面进行配置


    @RunWith(RobolectricTestRunner.class)
//目前Robolectric最高支持sdk版本为23。
@Config(constants = BuildConfig.class, sdk = 23)

下面是一些常用用法:


    //当Robolectric.setupActivity()方法返回的时候,
//默认会调用Activity的onCreate()、onStart()、onResume()
mTestActivity = Robolectric.setupActivity(TestActivity.class);

//获取TestActivity对应的影子类,从而能获取其相应的动作或行为
ShadowActivity shadowActivity = Shadows.shadowOf(mTestActivity);
Intent intent = shadowActivity.getNextStartedActivity();

//使用ShadowToast类获取展示toast时相应的动作或行为
Toast latestToast = ShadowToast.getLatestToast();
Assert.assertNull(latestToast);
//直接通过ShadowToast简单工厂类获取Toast中的文本
Assert.assertEquals("hahaha", ShadowToast.getTextOfLatestToast());

//使用ShadowAlertDialog类获取展示AlertDialog时相应的
//动作或行为(暂时只支持app包下的,不支持v7。。。)
latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
Assert.assertNull(latestAlertDialog);

//使用RuntimeEnvironment.application可以获取到
//Application,方便我们使用。比如访问资源文件。
Application application = RuntimeEnvironment.application;
String appName = application.getString(R.string.app_name);
Assert.assertEquals("WanAndroid", appName);

//也可以直接通过ShadowApplication获取application
ShadowApplication application = ShadowApplication.getInstance();
Assert.assertNotNull(application.hasReceiverForIntent(intent));

自定义Shadow类:


    @Implements(Person.class)
public class ShadowPerson {

@Implementation
public String getName() {
return "AndroidUT";
}

}

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class,
sdk = 23,
shadows = {ShadowPerson.class})


Person person = new Person();
//实际上调用的是ShadowPerson的方法,输出JsonChao
Log.d("test", person.getName());

ShadowPerson shadowPerson = Shadow.extract(person);
//测试通过
Assert.assertEquals("JsonChao", shadowPerson.getName());

}

注意:异步测试出现一些问题(比如改变一些编码习惯,比如回调函数不能写成匿名内部类对象,需要定义一个全局变量,并破坏其封装性,即提供一个get方法,供UT调用),解决方案使用Mockito来结合进行测试,将异步转为同步


3.Robolectric的优缺点



  • 优点:支持大部分Android平台依赖类底层的引用与模拟。

  • 缺点:异步测试有些问题,需要结合一些框架来配合完成更多功能。


五、单元测试覆盖率报告生成之jacoco


什么是Jacoco


Jacoco的全称为Java Code Coverage(Java代码覆盖率),可以生成java的单元测试代码覆盖率报告


加入Jacoco到你的单元测试大家族


在应用Module下加入jacoco.gradle自定义脚本,app.gradle apply from它,同步,即可看到在app的Task下生成了Report目录,Report目录 下生成了JacocoTestReport任务。


    apply plugin: 'jacoco'

jacoco {
toolVersion = "0.7.7.201606060606" //指定jacoco的版本
reportsDir = file("$buildDir/JacocoReport") //指定jacoco生成报告的文件夹
}

//依赖于testDebugUnitTest任务
task jacocoTestReport(type: JacocoReport, dependsOn: 'testDebugUnitTest') {
group = "reporting" //指定task的分组
reports {
xml.enabled = true //开启xml报告
html.enabled = true //开启html报告
}

def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug",
includes: ["**/*Presenter.*"],
excludes: ["*.*"])//指定类文件夹、包含类的规则及排除类的规则,
//这里我们生成所有Presenter类的测试报告
def mainSrc = "${project.projectDir}/src/main/java" //指定源码目录

sourceDirectories = files([mainSrc])
classDirectories = files([debugTree])
executionData = files("${buildDir}/jacoco/testDebugUnitTest.exec")//指定报告数据的路径
}

在Gradle构建板块Gradle.projects下的app/Task/verification下,其中testDebugUnitTest构建任务会生成单元测试结果报告,包含xml及html格式,分别对应test-results和reports文件夹;jacocoTestReport任务会生成单元测试覆盖率报告,结果存放在jacoco和JacocoReport文件夹。


image


生成的JacocoReport文件夹下的index.html即对应的单元测试覆盖率报告,用浏览器打开后,可以看到覆盖情况被不同的颜色标识出来,其中绿色表示代码被单元测试覆盖到,黄色表示部分覆盖,红色则表示完全没有覆盖到


六、单元测试的流程


要验证程序正确性,必然要给出所有可能的条件(极限编程),并验证其行为或结果,才算是100%覆盖条件。实际项目中,验证一般条件边界条件就OK了。


在实际项目中,单元测试对象与页面是一对一的,并不建议跨页面,这样的单元测试耦合太大,维护困难。 需要写完后,看覆盖率,找出单元测试中没有覆盖到的函数分支条件等,然后继续补充单元测试case列表,并在单元测试工程代码中补上case。 直到规划的页面中所有逻辑的重要分支、边界条件都被覆盖,该项目的单元测试结束。


建议(-ovo-)~


可以从公司项目小规模使用,形成自己的单元测试风格后,就可更大范围地推广了。




资源git地址:==》完整项目单元测试学习案例


收起阅读 »

全新 LeakCanary 2 ! 完全基于 Kotlin 重构升级 !

大概一年以前,写过一篇 LeakCanary 源码解析 ,当时是基于 1.5.4 版本进行分析的 。Square 公司在今年四月份发布了全新的 2.0 版本,完全使用 Kotlin 进行重构,核心原理并没有太大变化,但是做了一定的性能优化。在本文中,就让我们通...
继续阅读 »

大概一年以前,写过一篇 LeakCanary 源码解析 ,当时是基于 1.5.4 版本进行分析的 。Square 公司在今年四月份发布了全新的 2.0 版本,完全使用 Kotlin 进行重构,核心原理并没有太大变化,但是做了一定的性能优化。在本文中,就让我们通过源码来看看 2.0 版本发生了哪些变化。本文不会过多的分析源码细节,详细细节可以阅读我之前基于 1.5.4 版本写的文章,两个版本在原理方面并没有太大变化。



含注释 fork 版本 LeakCanary 源码



使用


首先来对比一下两个版本的使用方式。


1.5.4 版本


在老版本中,我们需要添加如下依赖:


dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:1.5.4'
releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.5.4'
}

leakcanary-android-no-op 库在 release 版本中使用,其中是没有任何逻辑代码的。


然后需要在自己的 Application 中进行初始化。


public class ExampleApplication extends Application {

@Override public void onCreate() {
super.onCreate();
if (LeakCanary.isInAnalyzerProcess(this)) {
// This process is dedicated to LeakCanary for heap analysis.
// You should not init your app in this process.
return;
}
LeakCanary.install(this);
// Normal app init code...
}
}

LeakCanary.install() 执行后,就会构建 RefWatcher 对象,开始监听 Activity.onDestroy() 回调, 通过 RefWatcher.watch() 监测 Activity 引用的泄露情况。发现内存泄露之后进行 heap dump ,利用 Square 公司的另一个库 haha(已废弃)来分析 heap dump 文件,找到引用链之后通知用户。这一套原理在新版本中还是没变的。


2.0 版本


新版本的使用更加方便了,你只需要在 build.gradle 文件中添加如下依赖:


debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.0-alpha-2'

是的,你没看过,这样就可以了。你肯定会有一个疑问,那它是如何初始化的呢?我刚看到这个使用文档的时候,同样也有这个疑问。当你看看源码之后就一目了然了。我先不解释,看一下源码中的 LeakSentryInstaller 这个类:


/**
* Content providers are loaded before the application class is created. [LeakSentryInstaller] is
* used to install [leaksentry.LeakSentry] on application start.
*
* Content Provider 在 Application 创建之前被自动加载,因此无需用户手动在 onCrate() 中进行初始化
*/

internal class LeakSentryInstaller : ContentProvider() {

override fun onCreate(): Boolean {
CanaryLog.logger = DefaultCanaryLog()
val application = context!!.applicationContext as Application
InternalLeakSentry.install(application) // 进行初始化工作,核心
return true
}

override fun query(
uri: Uri,
strings: Array<String>?,
s: String?,
strings1: Array<String>?,
s1: String?
)
: Cursor? {
return null
}

override fun getType(uri: Uri): String? {
return null
}

override fun insert(
uri: Uri,
contentValues: ContentValues?
)
: Uri? {
return null
}

override fun delete(
uri: Uri,
s: String?,
strings: Array<String>?
)
: Int {
return 0
}

override fun update(
uri: Uri,
contentValues: ContentValues?,
s: String?,
strings: Array<String>?
)
: Int {
return 0
}
}

看到这个类你应该也明白了。LeakCanary 利用 ContentProvier 进行了初始化。ContentProvier 一般会在 Application 被创建之前被加载,LeakCanary 在其 onCreate() 方法中调用了 InternalLeakSentry.install(application) 进行初始化。这应该是我第一次看到第三方库这么进行初始化。这的确是方便了开发者,但是仔细想想弊端还是很大的,如果所有第三方库都这么干,开发者就没法控制应用启动时间了。很多开发者为了加快应用启动速度,都下了很大心血,包括按需延迟初始化第三方库。但在 LeakCanary 中,这个问题并不存在,因为它本身就是一个只在 debug 版本中使用的库,并不会对 release 版本有任何影响。


源码解析


前面提到了 InternalLeakSentry.install() 就是核心的初始化工作,其地位就和 1.5.4 版本中的 LeakCanary.install() 一样。下面就从 install() 方法开始,走进 LeakCanary 2.0 一探究竟。


1. LeakCanary.install()


fun install(application: Application) {
CanaryLog.d("Installing LeakSentry")
checkMainThread() // 只能在主线程调用,否则会抛出异常
if (this::application.isInitialized) {
return
}
InternalLeakSentry.application = application

val configProvider = { LeakSentry.config }
ActivityDestroyWatcher.install( // 监听 Activity.onDestroy(),见 1.1
application, refWatcher, configProvider
)
FragmentDestroyWatcher.install( // 监听 Fragment.onDestroy(),见 1.2
application, refWatcher, configProvider
)
listener.onLeakSentryInstalled(application) // 见 1.3
}

install() 方法主要做了三件事:



  • 注册 Activity.onDestroy() 监听

  • 注册 Fragment.onDestroy() 监听

  • 监听完成后进行一些初始化工作


依次看一看。


1.1 ActivityDestroyWatcher.install()


ActivityDestroyWatcher 类的源码很简单:


internal class ActivityDestroyWatcher private constructor(
private val refWatcher: RefWatcher,
private val configProvider: () -> Config
) {

private val lifecycleCallbacks = object : ActivityLifecycleCallbacksAdapter() {
override fun onActivityDestroyed(activity: Activity) {
if (configProvider().watchActivities) {
refWatcher.watch(activity) // 监听到 onDestroy() 之后,通过 refWatcher 监测 Activity
}
}
}

companion object {
fun install(
application: Application,
refWatcher: RefWatcher,
configProvider: ()
-> Config
) {
val activityDestroyWatcher =
ActivityDestroyWatcher(refWatcher, configProvider)
// 注册 Activity 生命周期监听
application.registerActivityLifecycleCallbacks(activityDestroyWatcher.lifecycleCallbacks)
}
}
}

install() 方法中注册了 Activity 生命周期监听,在监听到 onDestroy() 时,调用 RefWatcher.watch() 方法开始监测 Activity。


1.2 FragmentDestroyWatcher.install()


FragmentDestroyWatcher 是一个接口,它有两个实现类 AndroidOFragmentDestroyWatcherSupportFragmentDestroyWatcher


internal interface FragmentDestroyWatcher {

fun watchFragments(activity: Activity)

companion object {

private const val SUPPORT_FRAGMENT_CLASS_NAME = "androidx.fragment.app.Fragment"

fun install(
application: Application,
refWatcher: RefWatcher,
configProvider: ()
-> LeakSentry.Config
) {
val fragmentDestroyWatchers = mutableListOf<FragmentDestroyWatcher>()

if (SDK_INT >= O) { // >= 26,使用 AndroidOFragmentDestroyWatcher
fragmentDestroyWatchers.add(
AndroidOFragmentDestroyWatcher(refWatcher, configProvider)
)
}

if (classAvailable(
SUPPORT_FRAGMENT_CLASS_NAME
)
) {
fragmentDestroyWatchers.add( // androidx 使用 SupportFragmentDestroyWatcher
SupportFragmentDestroyWatcher(refWatcher, configProvider)
)
}

if (fragmentDestroyWatchers.size == 0) {
return
}

application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacksAdapter() {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
)
{
for (watcher in fragmentDestroyWatchers) {
watcher.watchFragments(activity)
}
}
})
}

private fun classAvailable(className: String): Boolean {
return try {
Class.forName(className)
true
} catch (e: ClassNotFoundException) {
false
}
}
}
}

如果我没记错的话,1.5.4 是不监测 Fragment 的泄露的。而 2.0 版本提供了对 Android O 以及 androidx 版本中的 Fragment 的内存泄露检测。 AndroidOFragmentDestroyWatcherSupportFragmentDestroyWatcher 的实现代码其实是一致的,Android O 及以后,androidx 都具备对 Fragment 生命周期的监听功能。以 AndroidOFragmentDestroyWatcher 为例,简单看一下它的实现。


@RequiresApi(Build.VERSION_CODES.O) //
internal class AndroidOFragmentDestroyWatcher(
private val refWatcher: RefWatcher,
private val configProvider: () -> Config
) : FragmentDestroyWatcher {

private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {

override fun onFragmentViewDestroyed(
fm: FragmentManager,
fragment: Fragment
)
{
val view = fragment.view
if (view != null && configProvider().watchFragmentViews) {
refWatcher.watch(view)
}
}

override fun onFragmentDestroyed(
fm: FragmentManager,
fragment: Fragment
)
{
if (configProvider().watchFragments) {
refWatcher.watch(fragment)
}
}
}

override fun watchFragments(activity: Activity) {
val fragmentManager = activity.fragmentManager
fragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
}
}

同样,还是使用 RefWatcher.watch() 方法来进行监测。


1.3 listener.onLeakSentryInstalled()


onLeakSentryInstalled() 回调中会初始化一些检测内存泄露过程中需要的对象,如下所示:


override fun onLeakSentryInstalled(application: Application) {
this.application = application

val heapDumper = AndroidHeapDumper(application, leakDirectoryProvider) // 用于 heap dump

val gcTrigger = GcTrigger.Default // 用于手动调用 GC

val configProvider = { LeakCanary.config } // 配置项

val handlerThread = HandlerThread(LEAK_CANARY_THREAD_NAME)
handlerThread.start()
val backgroundHandler = Handler(handlerThread.looper) // 发起内存泄漏检测的线程

heapDumpTrigger = HeapDumpTrigger(
application, backgroundHandler, LeakSentry.refWatcher, gcTrigger, heapDumper, configProvider
)
application.registerVisibilityListener { applicationVisible ->
this.applicationVisible = applicationVisible
heapDumpTrigger.onApplicationVisibilityChanged(applicationVisible)
}
addDynamicShortcut(application)
}

对老版本代码熟悉的同学,看到这些对象应该很熟悉。



  • heapDumper 用于确认内存泄漏之后进行 heap dump 工作。

  • gcTrigger 用于发现可能的内存泄漏之后手动调用 GC 确认是否真的为内存泄露。


这两个对象是 LeakCanary 检测内存泄漏的核心。后面会进行详细分析。


到这里,整个 LeakCanary 的初始化工作就完成了。与 1.5.4 版本不同的是,新版本增加了对 Fragment 以及 androidx 的支持。当发生 Activity.onDestroy()Fragment.onFragmentViewDestroyed() , Fragment.onFragmentDestroyed() 三者之一时,RefWatcher 就开始工作了,调用其 watch() 方法开始检测引用是否泄露。


2. RefWatcher.watch()


在看源码之前,我们先来看几个后面会使用到的队列。


  /**
* References passed to [watch] that haven't made it to [retainedReferences] yet.
* watch() 方法传进来的引用,尚未判定为泄露
*/

private val watchedReferences = mutableMapOf<String, KeyedWeakReference>()
/**
* References passed to [watch] that we have determined to be retained longer than they should
* have been.
* watch() 方法传进来的引用,已经被判定为泄露
*/

private val retainedReferences = mutableMapOf<String, KeyedWeakReference>()
private val queue = ReferenceQueue<Any>() // 引用队列,配合弱引用使用

通过 watch() 方法传入的引用都会保存在 watchedReferences 中,被判定泄露之后保存在 retainedReferences 中。注意,这里的判定过程不止会发生一次,已经进入队列 retainedReferences 的引用仍有可能被移除。queue 是一个 ReferenceQueue 引用队列,配合弱引用使用,这里记住一句话:



弱引用一旦变得弱可达,就会立即入队。这将在 finalization 或者 GC 之前发生。



也就是说,会被 GC 回收的对象引用,会保存在队列 queue 中。


回头再来看看 watch() 方法的源码。


  @Synchronized fun watch(
watchedReference: Any,
referenceName: String
)
{
if (!isEnabled()) {
return
}
removeWeaklyReachableReferences() // 移除队列中将要被 GC 的引用,见 2.1
val key = UUID.randomUUID()
.toString()
val watchUptimeMillis = clock.uptimeMillis()
val reference = // 构建当前引用的弱引用对象,并关联引用队列 queue
KeyedWeakReference(watchedReference, key, referenceName, watchUptimeMillis, queue)
if (referenceName != "") {
CanaryLog.d(
"Watching instance of %s named %s with key %s", reference.className,
referenceName, key
)
} else {
CanaryLog.d(
"Watching instance of %s with key %s", reference.className, key
)
}

watchedReferences[key] = reference // 将引用存入 watchedReferences
checkRetainedExecutor.execute {
moveToRetained(key) // 如果当前引用未被移除,仍在 watchedReferences 队列中,
// 说明仍未被 GC,移入 retainedReferences 队列中,暂时标记为泄露
// 见 2.2
}
}

逻辑还是比较清晰的,首先会调用 removeWeaklyReachableReferences() 方法,这个方法在整个过程中会多次调用。其作用是移除 watchedReferences 中将被 GC 的引用。


2.1 removeWeaklyReachableReferences()


  private fun removeWeaklyReachableReferences() {
// WeakReferences are enqueued as soon as the object to which they point to becomes weakly
// reachable. This is before finalization or garbage collection has actually happened.
// 弱引用一旦变得弱可达,就会立即入队。这将在 finalization 或者 GC 之前发生。
var ref: KeyedWeakReference?
do {
ref = queue.poll() as KeyedWeakReference? // 队列 queue 中的对象都是会被 GC 的
if (ref != null) {
val removedRef = watchedReferences.remove(ref.key)
if (removedRef == null) {
retainedReferences.remove(ref.key)
}
// 移除 watchedReferences 队列中的会被 GC 的 ref 对象,剩下的就是可能泄露的对象
}
} while (ref != null)
}

整个过程中会多次调用,以确保将已经入队 queue 的将被 GC 的对象引用移除掉,避免无谓的 heap dump 操作。而仍在 watchedReferences 队列中的引用,则可能已经泄露,移到队列 retainedReferences 中,这就是 moveToRetained() 方法的逻辑。代码如下:


2.2 moveToRetained()


  @Synchronized private fun moveToRetained(key: String) {
removeWeaklyReachableReferences() // 再次调用,防止遗漏
val retainedRef = watchedReferences.remove(key)
if (retainedRef != null) {
retainedReferences[key] = retainedRef
onReferenceRetained()
}
}

这里的 onReferenceRetained() 最后会回调到 InternalLeakCanary.kt 中。


  override fun onReferenceRetained() {
if (this::heapDumpTrigger.isInitialized) {
heapDumpTrigger.onReferenceRetained()
}
}

调用了 HeapDumpTriggeronReferenceRetained() 方法。


  fun onReferenceRetained() {
scheduleRetainedInstanceCheck("found new instance retained")
}

private fun scheduleRetainedInstanceCheck(reason: String) {
if (checkScheduled) {
return
}
checkScheduled = true
backgroundHandler.post {
checkScheduled = false
checkRetainedInstances(reason) // 检测泄露实例
}
}

checkRetainedInstances() 方法是确定泄露的最后一个方法了。这里会确认引用是否真的泄露,如果真的泄露,则发起 heap dump,分析 dump 文件,找到引用链,最后通知用户。整体流程和老版本是一致的,但在一些细节处理,以及 dump 文件的分析上有所区别。下面还是通过源码来看看这些区别。


  private fun checkRetainedInstances(reason: String) {
CanaryLog.d("Checking retained instances because %s", reason)
val config = configProvider()
// A tick will be rescheduled when this is turned back on.
if (!config.dumpHeap) {
return
}

var retainedKeys = refWatcher.retainedKeys

// 当前泄露实例个数小于 5 个,不进行 heap dump
if (checkRetainedCount(retainedKeys, config.retainedVisibleThreshold)) return

if (!config.dumpHeapWhenDebugging && DebuggerControl.isDebuggerAttached) {
showRetainedCountWithDebuggerAttached(retainedKeys.size)
scheduleRetainedInstanceCheck("debugger was attached", WAIT_FOR_DEBUG_MILLIS)
CanaryLog.d(
"Not checking for leaks while the debugger is attached, will retry in %d ms",
WAIT_FOR_DEBUG_MILLIS
)
return
}

// 可能存在被观察的引用将要变得弱可达,但是还未入队引用队列。
// 这时候应该主动调用一次 GC,可能可以避免一次 heap dump
gcTrigger.runGc()

retainedKeys = refWatcher.retainedKeys

if (checkRetainedCount(retainedKeys, config.retainedVisibleThreshold)) return

HeapDumpMemoryStore.setRetainedKeysForHeapDump(retainedKeys)

CanaryLog.d("Found %d retained references, dumping the heap", retainedKeys.size)
HeapDumpMemoryStore.heapDumpUptimeMillis = SystemClock.uptimeMillis()
dismissNotification()
val heapDumpFile = heapDumper.dumpHeap() // AndroidHeapDumper
if (heapDumpFile == null) {
CanaryLog.d("Failed to dump heap, will retry in %d ms", WAIT_AFTER_DUMP_FAILED_MILLIS)
scheduleRetainedInstanceCheck("failed to dump heap", WAIT_AFTER_DUMP_FAILED_MILLIS)
showRetainedCountWithHeapDumpFailed(retainedKeys.size)
return
}

refWatcher.removeRetainedKeys(retainedKeys) // 移除已经 heap dump 的 retainedKeys

HeapAnalyzerService.runAnalysis(application, heapDumpFile) // 分析 heap dump 文件
}

首先调用 checkRetainedCount() 函数判断当前泄露实例个数如果小于 5 个,仅仅只是给用户一个通知,不会进行 heap dump 操作,并在 5s 后再次发起检测。这是和老版本一个不同的地方。


  private fun checkRetainedCount(
retainedKeys: Set<String>,
retainedVisibleThreshold: Int // 默认为 5 个
)
: Boolean {
if (retainedKeys.isEmpty()) {
CanaryLog.d("No retained instances")
dismissNotification()
return true
}

if (retainedKeys.size < retainedVisibleThreshold) {
if (applicationVisible || applicationInvisibleLessThanWatchPeriod) {
CanaryLog.d(
"Found %d retained instances, which is less than the visible threshold of %d",
retainedKeys.size,
retainedVisibleThreshold
)
// 通知用户 "App visible, waiting until 5 retained instances"
showRetainedCountBelowThresholdNotification(retainedKeys.size, retainedVisibleThreshold)
scheduleRetainedInstanceCheck( // 5s 后再次发起检测
"Showing retained instance notification", WAIT_FOR_INSTANCE_THRESHOLD_MILLIS
)
return true
}
}
return false
}

当集齐 5 个泄露实例之后,也并不会立马进行 heap dump。而是先手动调用一次 GC。当然不是使用 System.gc(),如下所示:


  object Default : GcTrigger {
override fun runGc() {
// Code taken from AOSP FinalizationTest:
// https://android.googlesource.com/platform/libcore/+/master/support/src/test/java/libcore/
// java/lang/ref/FinalizationTester.java
// System.gc() does not garbage collect every time. Runtime.gc() is
// more likely to perform a gc.
Runtime.getRuntime()
.gc()
enqueueReferences()
System.runFinalization()
}

那么,为什么要进行这次 GC 呢?可能存在被观察的引用将要变得弱可达,但是还未入队引用队列的情况。这时候应该主动调用一次 GC,可能可以避免一次额外的 heap dump 。GC 之后再次调用 checkRetainedCount() 判断泄露实例个数。如果此时仍然满足条件,就要发起 heap dump 操作了。具体逻辑在 AndroidHeapDumper.dumpHeap() 方法中,核心方法就是下面这句代码:


Debug.dumpHprofData(heapDumpFile.absolutePath)

生成 heap dump 文件之后,要删除已经处理过的引用,


refWatcher.removeRetainedKeys(retainedKeys)

最后启动一个前台服务 HeapAnalyzerService 来分析 heap dump 文件。老版本中是使用 Square 自己的 haha 库来解析的,这个库已经废弃了,Square 完全重写了解析库,主要逻辑都在 moudle leakcanary-analyzer 中。这部分我还没有阅读,就不在这里分析了。对于新的解析器,官网是这样介绍的:



Uses 90% less memory and 6 times faster than the prior heap parser.



减少了 90% 的内存占用,而且比原来快了 6 倍。后面有时间单独来分析一下这个解析库。


后面的过程就不再赘述了,通过解析库找到最短 GC Roots 引用路径,然后展示给用户。


总结


通读完源码,LeakCanary 2 还是带来了很多的优化。与老版本相比,主要有以下不同:



  • 百分之百使用 Kotlin 重写

  • 自动初始化,无需用户手动再添加初始化代码

  • 支持 fragment,支持 androidx

  • 当泄露引用到达 5 个时才会发起 heap dump

  • 全新的 heap parser,减少 90% 内存占用,提升 6 倍速度



作者:秉心说TM
链接:https://juejin.cn/post/6844903876043210759
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


收起阅读 »

利用tess-two和cv4j实现简单的ocr功能

ocr
ocr 光学字符识别(英语:Optical Character Recognition, OCR)是指对文本资料的图像文件进行分析识别处理,获取文字及版面信息的过程。 Tesseract Tesseract是Ray Smith于1985到1995年间在惠普...
继续阅读 »

ocr



光学字符识别(英语:Optical Character Recognition, OCR)是指对文本资料的图像文件进行分析识别处理,获取文字及版面信息的过程。



Tesseract


Tesseract是Ray Smith于1985到1995年间在惠普布里斯托实验室开发的一个OCR引擎,曾经在1995 UNLV精确度测试中名列前茅。但1996年后基本停止了开发。2006年,Google邀请Smith加盟,重启该项目。目前项目的许可证是Apache 2.0。该项目目前支持Windows、Linux和Mac OS等主流平台。但作为一个引擎,它只提供命令行工具。 现阶段的Tesseract由Google负责维护,是最好的开源OCR Engine之一,并且支持中文。


tess-two是Tesseract在Android平台上的移植。


下载tess-two:


compile 'com.rmtheis:tess-two:8.0.0'

然后将训练好的eng.traineddata放入android项目的assets文件夹中,就可以识别英文了。


1. 简单地识别英文


初始化tess-two,加载训练好的tessdata


    private void prepareTesseract() {
try {
prepareDirectory(DATA_PATH + TESSDATA);
} catch (Exception e) {
e.printStackTrace();
}

copyTessDataFiles(TESSDATA);
}

/**
* Prepare directory on external storage
*
* @param path
* @throws Exception
*/
private void prepareDirectory(String path) {

File dir = new File(path);
if (!dir.exists()) {
if (!dir.mkdirs()) {
Log.e(TAG, "ERROR: Creation of directory " + path + " failed, check does Android Manifest have permission to write to external storage.");
}
} else {
Log.i(TAG, "Created directory " + path);
}
}

/**
* Copy tessdata files (located on assets/tessdata) to destination directory
*
* @param path - name of directory with .traineddata files
*/
private void copyTessDataFiles(String path) {
try {
String fileList[] = getAssets().list(path);

for (String fileName : fileList) {

// open file within the assets folder
// if it is not already there copy it to the sdcard
String pathToDataFile = DATA_PATH + path + "/" + fileName;
if (!(new File(pathToDataFile)).exists()) {

InputStream in = getAssets().open(path + "/" + fileName);

OutputStream out = new FileOutputStream(pathToDataFile);

// Transfer bytes from in to out
byte[] buf = new byte[1024];
int len;

while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
in.close();
out.close();

Log.d(TAG, "Copied " + fileName + "to tessdata");
}
}
} catch (IOException e) {
Log.e(TAG, "Unable to copy files to tessdata " + e.toString());
}
}



拍完照后,调用startOCR方法。

    private void startOCR(Uri imgUri) {
try {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = 4; // 1 - means max size. 4 - means maxsize/4 size. Don't use value <4, because you need more memory in the heap to store your data.
Bitmap bitmap = BitmapFactory.decodeFile(imgUri.getPath(), options);

String result = extractText(bitmap);
resultView.setText(result);

} catch (Exception e) {
Log.e(TAG, e.getMessage());
}
}

extractText()会调用tess-two的api来实现ocr文字识别。


    private String extractText(Bitmap bitmap) {
try {
tessBaseApi = new TessBaseAPI();
} catch (Exception e) {
Log.e(TAG, e.getMessage());
if (tessBaseApi == null) {
Log.e(TAG, "TessBaseAPI is null. TessFactory not returning tess object.");
}
}

tessBaseApi.init(DATA_PATH, lang);

tessBaseApi.setImage(bitmap);
String extractedText = "empty result";
try {
extractedText = tessBaseApi.getUTF8Text();
} catch (Exception e) {
Log.e(TAG, "Error in recognizing text.");
}
tessBaseApi.end();
return extractedText;
}

最后,显示识别的效果,此时的效果还算可以。






2. 识别代码

接下来,尝试用上面的程序识别一段代码。





此时,效果一塌糊涂。我们重构一下startOCR(),增加局部的二值化处理。

    private void startOCR(Uri imgUri) {
try {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = 4; // 1 - means max size. 4 - means maxsize/4 size. Don't use value <4, because you need more memory in the heap to store your data.
Bitmap bitmap = BitmapFactory.decodeFile(imgUri.getPath(), options);

CV4JImage cv4JImage = new CV4JImage(bitmap);
Threshold threshold = new Threshold();
threshold.adaptiveThresh((ByteProcessor)(cv4JImage.convert2Gray().getProcessor()), Threshold.ADAPTIVE_C_MEANS_THRESH, 12, 30, Threshold.METHOD_THRESH_BINARY);
Bitmap newBitmap = cv4JImage.getProcessor().getImage().toBitmap(Bitmap.Config.ARGB_8888);

ivImage2.setImageBitmap(newBitmap);

String result = extractText(newBitmap);
resultView.setText(result);

} catch (Exception e) {
Log.e(TAG, e.getMessage());
}
}

在这里,使用cv4j来实现图像的二值化处理。


            CV4JImage cv4JImage = new CV4JImage(bitmap);
Threshold threshold = new Threshold();
threshold.adaptiveThresh((ByteProcessor)(cv4JImage.convert2Gray().getProcessor()), Threshold.ADAPTIVE_C_MEANS_THRESH, 12, 30, Threshold.METHOD_THRESH_BINARY);
Bitmap newBitmap = cv4JImage.getProcessor().getImage().toBitmap(Bitmap.Config.ARGB_8888);


图像二值化就是将图像上的像素点的灰度值设置为0或255,也就是将整个图像呈现出明显的黑白效果。图像的二值化有利于图像的进一步处理,使图像变得简单,而且数据量减小,能凸显出感兴趣的目标的轮廓。



cv4j的github地址:https://github.com/imageprocessor/cv4j



cv4jgloomyfish和我一起开发的图像处理库,纯java实现。



再来试试效果,图片中间部分是二值化后的效果,此时基本能识别出代码的内容。





3. 识别中文

如果要识别中文字体,需要使用中文的数据包。可以去下面的网站上下载。


https://github.com/tesseract-ocr/tessdata


跟中文相关的数据包有chi_sim.traineddata、chi_tra.traineddata,它们分别表示是简体中文和繁体中文。


tessBaseApi.init(DATA_PATH, lang);

前面的例子都是识别英文的,所以原先的lang值为"eng",现在要识别简体中文的话需要将其值改为"chi_sim"。



最后

本项目只是demo级别的演示,离生产环境的使用还差的很远。

本项目的github地址:https://github.com/fengzhizi715/Tess-TwoDemo


为何说只是demo级别呢?



  • 数据包很大,特别是中文的大概有50多M,放在移动端的肯定不合适。一般正确的做法,都是放在云端。

  • 识别文字很慢,特别是中文,工程上还有很多优化的空间。

  • 做ocr之前需要做很多预处理的工作,在本例子中只用了二值化,其实还有很多预处理的步骤比如倾斜校正、字符切割等等。

  • 为了提高tess-two的识别率,可以自己训练数据集。




作者:fengzhizi715
链接:https://www.jianshu.com/p/5314f63c75d8
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

[译] R8 优化:字节码常量操作

1. Log Tags(日志标签)关于在类中定义标记字符串的最佳方法,有一个正在进行的争论(如果您甚至可以这样称呼它的话)。历史上有两种策略:字符串文本和对类调用 getSimpleName()。private static final String TAG ...
继续阅读 »

1. Log Tags(日志标签)

关于在类中定义标记字符串的最佳方法,有一个正在进行的争论(如果您甚至可以这样称呼它的话)。历史上有两种策略:字符串文本和对类调用 getSimpleName()

private static final String TAG = "MyClass";
// or
private static final String TAG = MyClass.class.getSimpleName();

究竟孰好孰坏,让我们写个例子测试下。

class MyClass {
private static final String TAG_STRING = "MyClass";
private static final String TAG_CLASS = MyClass.class.getSimpleName();

public static void main(String... args) {
Log.d(TAG_STRING, "String tag");
Log.d(TAG_CLASS, "Class tag");
}
}

对上面的代码执行,Compilingdexing 然后查看 Dalvik 字节码。

[000194] MyClass.:()V
0000: const-class v0, LMyClass;
0002: invoke-virtual {v0}, Ljava/lang/Class;.getSimpleName:()Ljava/lang/String;
0005: move-result-object v0
0006: sput-object v0, LMyClass;.TAG_CLASS:Ljava/lang/String;
0008: return-void

[000120] MyClass.main:([Ljava/lang/String;)V
0000: const-string v1, "MyClass"
0002: const-string v0, "String tag"
0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
0007: sget-object v1, LMyClass;.a:Ljava/lang/String;
0009: const-string v0, "Class tag"
000b: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
000e: return-void

在 main 函数中,0000 位置处加载 tag 的字符串常量,在 0007 处,查找该静态字段并读取值。在  方法中,静态字段是通过加载 MyClass 类然后在运行时调用 getSimpleName 方法获取。这个方法在类第一次加载的时候调用。

可以看到使用字符串常量效率更高,但使用 Class.getSimpleName() 对于重构之类需求更灵活。我们同样使用 R8 进行编译。

[000120] MyClass.main:([Ljava/lang/String;)V
0000: const-string v1, "MyClass"
0002: const-string v0, "String tag"
0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
0007: const-string v0, "Class tag"
0009: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
000c: return-void

可以看到在 0004 位置后面的操作中将变量 v1 的 MyClass 值进行了重复。

由于 myClass 的名称在编译时已知,R8 已将 myClass.class.getSimpleName() 替换为字符串变量 "myClass"。因为字段值现在是常量,所以  方法变为空并被删除。在调用位置上,用常量字符串替换了 sget 对象字节码。最后,对引用同一字符串的两个常量字符串字节码进行了重复数据消除,并进行重用。

因此,R8 确保不会进行额外的加载。因为 getSimpleName() 计算很简单,D8 实际上也会执行这种优化!

2. Applicability(拓展)

在 MyClass.class 上能够获取 getSimpleName()(以及 getName() 和 getCanonicalName()),这种方式的用途似乎有限——甚至可能仅限于此日志标记案例。优化只适用于类文本引用– getClass() 不起作用!再次结合其他 R8 特性,这种优化开始应用得更多。

我们来看下面的一个示例:

class Logger {
static Logger get(Class cls) {
return new Logger(cls.getSimpleName());
}
private Logger(String tag) { /* … */ }

}

class MyClass {
private static final Logger logger = Logger.get(MyClass.class);
}

如果 Logger.get 内嵌在所有调用处,则对以前具有方法参数动态输入的 class.getSimpleName 的调用将更改为类引用的静态输入(在本例中为 myClass.class)。R8 现在可以用字符串文字替换调用,从而产生直接调用构造函数的字段初始值设定项(也将删除其私有修饰符)。

class MyClass {
private static final Logger logger = new Logger("MyClass");
}

这依赖于 get 方法足够小或者满足 R8 的内联调用方式。

Kotlin 语言提供了强制函数内联的能力。它还允许将内联函数上的泛型类型参数标记为 reified,从而确保编译器知道在编译时解析为哪个类。使用这些特性,我们可以确保我们的函数始终是内联的,并且总是在显式类引用上调用 getSimpleName

class Logger private constructor(val tag: String) {

}
inline fun <reified T : Any> logger() = Logger(T::class.java.simpleName)

class MyClass {

companion object {
private val logger = logger()
}
}

logger 函数的初始值将始终具有与 myClass.Class.GetSimpleName() 等效的字节码,然后 R8 可以替换为字符串常量。

对于其他 Kotlin 示例,类型推断通常允许省略显式类型参数。

inline fun <reified T> typeAndValue(value: T) = "${T::class.java.name}: $value"
fun main() {
println(typeAndValue("hey"))
}

上面示例输出结果为:“java.lang.String: hey”,同时编译后的字节码中只有两个字符串常量,并且用 StringBuilder 连接,然后调用 System.out.println 输出,如果这个问题被解决,你会发现只有一个字符串常量调用 System.out.println

3. 混淆和优化

由于这种优化是在字节码上进行的,因此它必须与R8 的其他功能交互,这些功能可能会影响类,如 Obfuscation(混淆) 和 Optimization(优化)

让我们回到原来的例子。

class MyClass {
private static final String TAG_STRING = "MyClass";
private static final String TAG_CLASS = MyClass.class.getSimpleName();

public static void main(String... args) {
Log.d(TAG_STRING, "String tag");
Log.d(TAG_CLASS, "Class tag");
}
}

如果这个类被混淆了会发生什么?如果 R8 没有替换 getSimpleName 的调用,第一条日志消息将有一个 myclass 标记,第二条日志消息将有一个与模糊类名(如“a”)匹配的标记。

为了允许 R8 替换 getSimpleName,需要使用一个与运行时行为匹配的值。值得庆幸的是,由于 R8 也是执行混淆处理的工具,所以它可以直到类被赋予其最终名称时才进行替换。

[000158] a.main:([Ljava/lang/String;)V
0000: const-string v1, "MyClass"
0002: const-string v0, "String tag"
0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
0007: const-string v1, "a"
0009: const-string v0, "Class tag"
000b: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
000e: return-void

请注意 0007 现在将如何为第二个日志调用加载标记值(与原始 R8 输出不同),以及它如何正确反映混淆名称。

即使禁用了混淆,R8 还有其它优化会影响类名。虽然我打算在以后的文章中介绍它,如果 R8 能够证明不需要超类,并且子类是唯一的, 有时 R8 会将一个超类合并成一个子类。发生这种情况时,类名字符串优化将正确反映子类型名称,即使原始代码等效于 superType.class.getSimpleName()

3. String Data Section

前一篇文章讨论了如何在编译时执行 string.substring 或字符串串联之类的操作,从而导致 dex 文件的 string 部分的大小增加。本文中讨论的优化也会生成一些不存在的字符串,也可能会变大。

所以有两种场景需要考虑:“什么时候开启混淆?什么时候关闭混淆”。

启用混淆处理时,对 getSimpleName() 的调用不应创建新字符串。类和方法都将使用同一个字典进行混淆处理,默认字典以单个字母开头。这意味着,对于名为 b 的混淆类,插入字符串 “b” 几乎总是免费的,因为将有一个方法或字段的名称也是b。在DEX文件中,所有字符串都存储在一个池中,该池包含文字、类名、方法名和字段名,使模糊时匹配的可能性大于Y高。

但是,在禁用模糊处理的情况下,替换getSimpleName()永远都不是免费的。尽管dex文件有统一的字符串部分,类名还是以类型描述符的形式存储。这包括包名称,使用/作为分隔符,前缀为L,后缀为;。对于myclass,如果在假设的com.example包中,字符串数据包含lcom/example/myclass;的条目。由于这种格式,字符串“myclass”不存在,需要添加。

getName() 和 getCanonicalName() 都会产生新的字符串,都会返回全限定符字符串,而不是考虑存在的限定符。

由于混淆潜在创建了大量的字符串对象,所以它现在除了对顶级类型才可用。在 MyClass 中起作用,但是对于匿名类和内部类无法起作用。同样有研究表明不在一个单独的方法中使用混淆,来避免增加 dex 文件大小。

4. 总结

下篇文章中,我们将讨论 R8 的另一个优化。

收起阅读 »

CocoaPods 都做了什么

稍有 iOS 开发经验的人应该都是用过 CocoaPods,而对于 CI、CD 有了解的同学也都知道 Fastlane。而这两个在 iOS 开发中非常便捷的第三方库都是使用 Ruby 来编写的,这是为什么?先抛开这个话题不谈,我们来看一下 CocoaPods ...
继续阅读 »

稍有 iOS 开发经验的人应该都是用过 CocoaPods,而对于 CI、CD 有了解的同学也都知道 Fastlane。而这两个在 iOS 开发中非常便捷的第三方库都是使用 Ruby 来编写的,这是为什么?

先抛开这个话题不谈,我们来看一下 CocoaPods 和 Fastlane 是如何使用的,首先是 CocoaPods,在每一个工程使用 CocoaPods 的工程中都有一个 Podfile:

source 'https://github.com/CocoaPods/Specs.git'

target 'Demo' do
pod 'Mantle', '~> 1.5.1'
pod 'SDWebImage', '~> 3.7.1'
pod 'BlocksKit', '~> 2.2.5'
pod 'SSKeychain', '~> 1.2.3'
pod 'UMengAnalytics', '~> 3.1.8'
pod 'UMengFeedback', '~> 1.4.2'
pod 'Masonry', '~> 0.5.3'
pod 'AFNetworking', '~> 2.4.1'
pod 'Aspects', '~> 1.4.1'
end

这是一个使用 Podfile 定义依赖的一个例子,不过 Podfile 对约束的描述其实是这样的:

source('https://github.com/CocoaPods/Specs.git')

target('Demo') do
pod('Mantle', '~> 1.5.1')
...
end

Ruby 代码在调用方法时可以省略括号。

Podfile 中对于约束的描述,其实都可以看作是对代码简写,上面的代码在解析时可以当做 Ruby 代码来执行。

Fastlane 中的代码 Fastfile 也是类似的:

lane :beta do
increment_build_number
cocoapods
match
testflight
sh "./customScript.sh"
slack
end

使用描述性的”代码“编写脚本,如果没有接触或者使用过 Ruby 的人很难相信上面的这些文本是代码的。

Ruby 概述

在介绍 CocoaPods 的实现之前,我们需要对 Ruby 的一些特性有一个简单的了解,在向身边的朋友“传教”的时候,我往往都会用优雅这个词来形容这门语言(手动微笑)。

除了优雅之外,Ruby 的语法具有强大的表现力,并且其使用非常灵活,能快速实现我们的需求,这里简单介绍一下 Ruby 中的一些特性。

一切皆对象

在许多语言,比如 Java 中,数字与其他的基本类型都不是对象,而在 Ruby 中所有的元素,包括基本类型都是对象,同时也不存在运算符的概念,所谓的 1 + 1,其实只是 1.+(1) 的语法糖而已。

得益于一切皆对象的概念,在 Ruby 中,你可以向任意的对象发送 methods 消息,在运行时自省,所以笔者在每次忘记方法时,都会直接用 methods 来“查文档”:

2.3.1 :003 > 1.methods
=> [:%, :&, :*, :+, :-, :/, :<, :>, :^, :|, :~, :-@, :**, :<=>, :<<, :>>, :<=, :>=, :==, :===, :[], :inspect, :size, :succ, :to_s, :to_f, :div, :divmod, :fdiv, :modulo, :abs, :magnitude, :zero?, :odd?, :even?, :bit_length, :to_int, :to_i, :next, :upto, :chr, :ord, :integer?, :floor, :ceil, :round, :truncate, :downto, :times, :pred, :to_r, :numerator, :denominator, :rationalize, :gcd, :lcm, :gcdlcm, :+@, :eql?, :singleton_method_added, :coerce, :i, :remainder, :real?, :nonzero?, :step, :positive?, :negative?, :quo, :arg, :rectangular, :rect, :polar, :real, :imaginary, :imag, :abs2, :angle, :phase, :conjugate, :conj, :to_c, :between?, :instance_of?, :public_send, :instance_variable_get, :instance_variable_set, :instance_variable_defined?, :remove_instance_variable, :private_methods, :kind_of?, :instance_variables, :tap, :is_a?, :extend, :define_singleton_method, :to_enum, :enum_for, :=~, :!~, :respond_to?, :freeze, :display, :send, :object_id, :method, :public_method, :singleton_method, :nil?, :hash, :class, :singleton_class, :clone, :dup, :itself, :taint, :tainted?, :untaint, :untrust, :trust, :untrusted?, :methods, :protected_methods, :frozen?, :public_methods, :singleton_methods, :!, :!=, :__send__, :equal?, :instance_eval, :instance_exec, :__id__]

比如在这里向对象 1 调用 methods 就会返回它能响应的所有方法。

一切皆对象不仅减少了语言中类型的不一致,消灭了基本数据类型与对象之间的边界;这一概念同时也简化了语言中的组成元素,这样 Ruby 中只有对象和方法,这两个概念,这也降低了我们理解这门语言的复杂度:

  • 使用对象存储状态
  • 对象之间通过方法通信

block

Ruby 对函数式编程范式的支持是通过 block,这里的 block 和 Objective-C 中的 block 有些不同。

首先 Ruby 中的 block 也是一种对象,所有的 Block 都是 Proc 类的实例,也就是所有的 block 都是 first-class 的,可以作为参数传递,返回。

def twice(&proc)
2.times { proc.call() } if proc
end

def twice
2.times { yield } if block_given?
end

yield 会调用外部传入的 block,block_given? 用于判断当前方法是否传入了 block。

在这个方法调用时,是这样的:

twice do 
puts "Hello"
end

eval

最后一个需要介绍的特性就是 eval 了,早在几十年前的 Lisp 语言就有了 eval 这个方法,这个方法会将字符串当做代码来执行,也就是说 eval 模糊了代码与数据之间的边界。

> eval "1 + 2 * 3"
=> 7

有了 eval 方法,我们就获得了更加强大的动态能力,在运行时,使用字符串来改变控制流程,执行代码;而不需要去手动解析输入、生成语法树。

手动解析 Podfile

在我们对 Ruby 这门语言有了一个简单的了解之后,就可以开始写一个简易的解析 Podfile 的脚本了。

在这里,我们以一个非常简单的 Podfile 为例,使用 Ruby 脚本解析 Podfile 中指定的依赖:

source 'http://source.git'
platform :ios, '8.0'

target 'Demo' do
pod 'AFNetworking'
pod 'SDWebImage'
pod 'Masonry'
pod "Typeset"
pod 'BlocksKit'
pod 'Mantle'
pod 'IQKeyboardManager'
pod 'IQDropDownTextField'
end

因为这里的 source、platform、target 以及 pod 都是方法,所以在这里我们需要构建一个包含上述方法的上下文:

# eval_pod.rb
$hash_value = {}

def source(url)
end

def target(target)
end

def platform(platform, version)
end

def pod(pod)
end

使用一个全局变量 hash_value 存储 Podfile 中指定的依赖,并且构建了一个 Podfile 解析脚本的骨架;我们先不去完善这些方法的实现细节,先尝试一下读取 Podfile 中的内容并执行会不会有什么问题。

在 eval_pod.rb 文件的最下面加入这几行代码:

content = File.read './Podfile'
eval content
p $hash_value

这里读取了 Podfile 文件中的内容,并把其中的内容当做字符串执行,最后打印 hash_value 的值。

$ ruby eval_pod.rb

运行这段 Ruby 代码虽然并没有什么输出,但是并没有报出任何的错误,接下来我们就可以完善这些方法了:

def source(url)
$hash_value['source'] = url
end

def target(target)
targets = $hash_value['targets']
targets = [] if targets == nil
targets << target
$hash_value['targets'] = targets
yield if block_given?
end

def platform(platform, version)
end

def pod(pod)
pods = $hash_value['pods']
pods = [] if pods == nil
pods << pod
$hash_value['pods'] = pods
end

在添加了这些方法的实现之后,再次运行脚本就会得到 Podfile 中的依赖信息了,不过这里的实现非常简单的,很多情况都没有处理:

$ ruby eval_pod.rb
{"source"=>"http://source.git", "targets"=>["Demo"], "pods"=>["AFNetworking", "SDWebImage", "Masonry", "Typeset", "BlocksKit", "Mantle", "IQKeyboardManager", "IQDropDownTextField"]}

CocoaPods 中对于 Podfile 的解析与这里的实现其实差不多,接下来就进入了 CocoaPods 的实现部分了。

CocoaPods 的实现

在上面简单介绍了 Ruby 的一些语法以及如何解析 Podfile 之后,我们开始深入了解一下 CocoaPods 是如何管理 iOS 项目的依赖,也就是 pod install 到底做了些什么。

Pod install 的过程

pod install 这个命令到底做了什么?首先,在 CocoaPods 中,所有的命令都会由 Command 类派发到将对应的类,而真正执行 pod install 的类就是 Install:

module Pod
class Command
class Install < Command
def run
verify_podfile_exists!
installer = installer_for_config
installer.repo_update = repo_update?(:default => false)
installer.update = false
installer.install!
end
end
end
end

这里面会从配置类的实例 config 中获取一个 Installer 的实例,然后执行 install! 方法,这里的 installer 有一个 update 属性,而这也就是 pod install 和 update 之间最大的区别,其中后者会无视已有的 Podfile.lock 文件,重新对依赖进行分析

module Pod
class Command
class Update < Command
def run
...

installer = installer_for_config
installer.repo_update = repo_update?(:default => true)
installer.update = true
installer.install!
end
end
end
end

Podfile 的解析

Podfile 中依赖的解析其实是与我们在手动解析 Podfile 章节所介绍的差不多,整个过程主要都是由 CocoaPods-Core 这个模块来完成的,而这个过程早在 installer_for_config 中就已经开始了:

def installer_for_config
Installer.new(config.sandbox, config.podfile, config.lockfile)
end

这个方法会从 config.podfile 中取出一个 Podfile 类的实例:

def podfile
@podfile ||= Podfile.from_file(podfile_path) if podfile_path
end

类方法 Podfile.from_file 就定义在 CocoaPods-Core 这个库中,用于分析 Podfile 中定义的依赖,这个方法会根据 Podfile 不同的类型选择不同的调用路径:

Podfile.from_file
`-- Podfile.from_ruby
|-- File.open
`-- eval

from_ruby 类方法就会像我们在前面做的解析 Podfile 的方法一样,从文件中读取数据,然后使用 eval 直接将文件中的内容当做 Ruby 代码来执行。

def self.from_ruby(path, contents = nil)
contents ||= File.open(path, 'r:utf-8', &:read)

podfile = Podfile.new(path) do
begin
eval(contents, nil, path.to_s)
rescue Exception => e
message = "Invalid `#{path.basename}` file: #{e.message}"
raise DSLError.new(message, path, e, contents)
end
end
podfile
end

在 Podfile 这个类的顶部,我们使用 Ruby 的 Mixin 的语法来混入 Podfile 中代码执行所需要的上下文:

include Pod::Podfile::DSL

Podfile 中的所有你见到的方法都是定义在 DSL 这个模块下面的:

module Pod
class Podfile
module DSL
def pod(name = nil, *requirements) end
def target(name, options = nil) end
def platform(name, target = nil) end
def inhibit_all_warnings! end
def use_frameworks!(flag = true) end
def source(source) end
...
end
end
end

这里定义了很多 Podfile 中使用的方法,当使用 eval 执行文件中的代码时,就会执行这个模块里的方法,在这里简单看一下其中几个方法的实现,比如说 source 方法:

def source(source)
hash_sources = get_hash_value('sources') || []
hash_sources << source
set_hash_value('sources', hash_sources.uniq)
end

该方法会将新的 source 加入已有的源数组中,然后更新原有的 sources 对应的值。

稍微复杂一些的是 target 方法:

def target(name, options = nil)
if options
raise Informative, "Unsupported options `#{options}` for " \
"target `#{name}`."
end

parent = current_target_definition
definition = TargetDefinition.new(name, parent)
self.current_target_definition = definition
yield if block_given?
ensure
self.current_target_definition = parent
end

这个方法会创建一个 TargetDefinition 类的实例,然后将当前环境系的 target_definition 设置成这个刚刚创建的实例。这样,之后使用 pod 定义的依赖都会填充到当前的 TargetDefinition 中:

def pod(name = nil, *requirements)
unless name
raise StandardError, 'A dependency requires a name.'
end

current_target_definition.store_pod(name, *requirements)
end

当 pod 方法被调用时,会执行 store_pod 将依赖存储到当前 target 中的 dependencies 数组中:

def store_pod(name, *requirements)
return if parse_subspecs(name, requirements)
parse_inhibit_warnings(name, requirements)
parse_configuration_whitelist(name, requirements)

if requirements && !requirements.empty?
pod = { name => requirements }
else
pod = name
end

get_hash_value('dependencies', []) << pod
nil
end

总结一下,CocoaPods 对 Podfile 的解析与我们在前面做的手动解析 Podfile 的原理差不多,构建一个包含一些方法的上下文,然后直接执行 eval 方法将文件的内容当做代码来执行,这样只要 Podfile 中的数据是符合规范的,那么解析 Podfile 就是非常简单容易的。

安装依赖的过程

Podfile 被解析后的内容会被转化成一个 Podfile 类的实例,而 Installer 的实例方法 install! 就会使用这些信息安装当前工程的依赖,而整个安装依赖的过程大约有四个部分:

  • 解析 Podfile 中的依赖
  • 下载依赖
  • 创建 Pods.xcodeproj 工程
  • 集成 workspace
def install!
resolve_dependencies
download_dependencies
generate_pods_project
integrate_user_project
end

在上面的 install 方法调用的 resolve_dependencies 会创建一个 Analyzer 类的实例,在这个方法中,你会看到一些非常熟悉的字符串:

def resolve_dependencies
analyzer = create_analyzer

plugin_sources = run_source_provider_hooks
analyzer.sources.insert(0, *plugin_sources)

UI.section 'Updating local specs repositories' do
analyzer.update_repositories
end if repo_update?

UI.section 'Analyzing dependencies' do
analyze(analyzer)
validate_build_configurations
clean_sandbox
end
end

在使用 CocoaPods 中经常出现的 Updating local specs repositories 以及 Analyzing dependencies 就是从这里输出到终端的,该方法不仅负责对本地所有 PodSpec 文件的更新,还会对当前 Podfile 中的依赖进行分析:

def analyze(analyzer = create_analyzer)
analyzer.update = update
@analysis_result = analyzer.analyze
@aggregate_targets = analyzer.result.targets
end

analyzer.analyze 方法最终会调用 Resolver 的实例方法 resolve:

def resolve
dependencies = podfile.target_definition_list.flat_map do |target|
target.dependencies.each do |dep|
@platforms_by_dependency[dep].push(target.platform).uniq! if target.platform
end
end
@activated = Molinillo::Resolver.new(self, self).resolve(dependencies, locked_dependencies)
specs_by_target
rescue Molinillo::ResolverError => e
handle_resolver_error(e)
end

这里的 Molinillo::Resolver 就是用于解决依赖关系的类。

解决依赖关系(Resolve Dependencies)

CocoaPods 为了解决 Podfile 中声明的依赖关系,使用了一个叫做 Milinillo 的依赖关系解决算法;但是,笔者在 Google 上并没有找到与这个算法相关的其他信息,推测是 CocoaPods 为了解决 iOS 中的依赖关系创造的算法。

Milinillo 算法的核心是 回溯(Backtracking) 以及 向前检查(forward check)),整个过程会追踪栈中的两个状态(依赖和可能性)。

在这里并不想陷入对这个算法执行过程的分析之中,如果有兴趣可以看一下仓库中的 ARCHITECTURE.md 文件,其中比较详细的解释了 Milinillo 算法的工作原理,并对其功能执行过程有一个比较详细的介绍。

Molinillo::Resolver 方法会返回一个依赖图,其内容大概是这样的:

Molinillo::DependencyGraph:[
Molinillo::DependencyGraph::Vertex:AFNetworking(#<Pod::Specification name="AFNetworking">),
Molinillo::DependencyGraph::Vertex:SDWebImage(#<Pod::Specification name="SDWebImage">),
Molinillo::DependencyGraph::Vertex:Masonry(#<Pod::Specification name="Masonry">),
Molinillo::DependencyGraph::Vertex:Typeset(#<Pod::Specification name="Typeset">),
Molinillo::DependencyGraph::Vertex:CCTabBarController(#<Pod::Specification name="CCTabBarController">),
Molinillo::DependencyGraph::Vertex:BlocksKit(#<Pod::Specification name="BlocksKit">),
Molinillo::DependencyGraph::Vertex:Mantle(#<Pod::Specification name="Mantle">),
...
]

这个依赖图是由一个结点数组组成的,在 CocoaPods 拿到了这个依赖图之后,会在 specs_by_target 中按照 Target 将所有的 Specification 分组:

{
#<Pod::Podfile::TargetDefinition label=Pods>=>[],
#<Pod::Podfile::TargetDefinition label=Pods-Demo>=>[
#<Pod::Specification name="AFNetworking">,
#<Pod::Specification name="AFNetworking/NSURLSession">,
#<Pod::Specification name="AFNetworking/Reachability">,
#<Pod::Specification name="AFNetworking/Security">,
#<Pod::Specification name="AFNetworking/Serialization">,
#<Pod::Specification name="AFNetworking/UIKit">,
#<Pod::Specification name="BlocksKit/Core">,
#<Pod::Specification name="BlocksKit/DynamicDelegate">,
#<Pod::Specification name="BlocksKit/MessageUI">,
#<Pod::Specification name="BlocksKit/UIKit">,
#<Pod::Specification name="CCTabBarController">,
#<Pod::Specification name="CategoryCluster">,
...
]
}

而这些 Specification 就包含了当前工程依赖的所有第三方框架,其中包含了名字、版本、源等信息,用于依赖的下载。

下载依赖

在依赖关系解决返回了一系列 Specification 对象之后,就到了 Pod install 的第二部分,下载依赖:

def install_pod_sources
@installed_specs = []
pods_to_install = sandbox_state.added | sandbox_state.changed
title_options = { :verbose_prefix => '-> '.green }
root_specs.sort_by(&:name).each do |spec|
if pods_to_install.include?(spec.name)
if sandbox_state.changed.include?(spec.name) && sandbox.manifest
previous = sandbox.manifest.version(spec.name)
title = "Installing #{spec.name} #{spec.version} (was #{previous})"
else
title = "Installing #{spec}"
end
UI.titled_section(title.green, title_options) do
install_source_of_pod(spec.name)
end
else
UI.titled_section("Using #{spec}", title_options) do
create_pod_installer(spec.name)
end
end
end
end

在这个方法中你会看到更多熟悉的提示,CocoaPods 会使用沙盒(sandbox)存储已有依赖的数据,在更新现有的依赖时,会根据依赖的不同状态显示出不同的提示信息:

-> Using AFNetworking (3.1.0)

-> Using AKPickerView (0.2.7)

-> Using BlocksKit (2.2.5) was (2.2.4)

-> Installing MBProgressHUD (1.0.0)
...

虽然这里的提示会有三种,但是 CocoaPods 只会根据不同的状态分别调用两种方法:

  • install_source_of_pod
  • create_pod_installer

create_pod_installer 方法只会创建一个 PodSourceInstaller 的实例,然后加入 pod_installers 数组中,因为依赖的版本没有改变,所以不需要重新下载,而另一个方法的 install_source_of_pod 的调用栈非常庞大:

installer.install_source_of_pod
|-- create_pod_installer
| `-- PodSourceInstaller.new
`-- podSourceInstaller.install!
`-- download_source
`-- Downloader.download
`-- Downloader.download_request
`-- Downloader.download_source
|-- Downloader.for_target
| |-- Downloader.class_for_options
| `-- Git/HTTP/Mercurial/Subversion.new
|-- Git/HTTP/Mercurial/Subversion.download
`-- Git/HTTP/Mercurial/Subversion.download!
`-- Git.clone

在调用栈的末端 Downloader.download_source 中执行了另一个 CocoaPods 组件 CocoaPods-Download 中的方法:

def self.download_source(target, params)
FileUtils.rm_rf(target)
downloader = Downloader.for_target(target, params)
downloader.download
target.mkpath

if downloader.options_specific?
params
else
downloader.checkout_options
end
end

方法中调用的 for_target 根据不同的源会创建一个下载器,因为依赖可能通过不同的协议或者方式进行下载,比如说 Git/HTTP/SVN 等等,组件 CocoaPods-Downloader 就会根据 Podfile 中依赖的参数选项使用不同的方法下载依赖。

大部分的依赖都会被下载到 ~/Library/Caches/CocoaPods/Pods/Release/ 这个文件夹中,然后从这个这里复制到项目工程目录下的 ./Pods 中,这也就完成了整个 CocoaPods 的下载流程。

生成 Pods.xcodeproj

CocoaPods 通过组件 CocoaPods-Downloader 已经成功将所有的依赖下载到了当前工程中,这里会将所有的依赖打包到 Pods.xcodeproj 中:

def generate_pods_project(generator = create_generator)
UI.section 'Generating Pods project' do
generator.generate!
@pods_project = generator.project
run_podfile_post_install_hooks
generator.write
generator.share_development_pod_schemes
write_lockfiles
end
end

generate_pods_project 中会执行 PodsProjectGenerator 的实例方法 generate!:

def generate!
prepare
install_file_references
install_libraries
set_target_dependencies
end

这个方法做了几件小事:

  • 生成 Pods.xcodeproj 工程
  • 将依赖中的文件加入工程
  • 将依赖中的 Library 加入工程
  • 设置目标依赖(Target Dependencies)

这几件事情都离不开 CocoaPods 的另外一个组件 Xcodeproj,这是一个可以操作一个 Xcode 工程中的 Group 以及文件的组件,我们都知道对 Xcode 工程的修改大多数情况下都是对一个名叫 project.pbxproj 的文件进行修改,而 Xcodeproj 这个组件就是 CocoaPods 团队开发的用于操作这个文件的第三方库。

生成 workspace

最后的这一部分与生成 Pods.xcodeproj 的过程有一些相似,这里使用的类是 UserProjectIntegrator,调用方法 integrate! 时,就会开始集成工程所需要的 Target:

def integrate!
create_workspace
integrate_user_targets
warn_about_xcconfig_overrides
save_projects
end

对于这一部分的代码,也不是很想展开来细谈,简单介绍一下这里的代码都做了什么,首先会通过 Xcodeproj::Workspace 创建一个 workspace,之后会获取所有要集成的 Target 实例,调用它们的 integrate! 方法:

def integrate!
UI.section(integration_message) do
XCConfigIntegrator.integrate(target, native_targets)

add_pods_library
add_embed_frameworks_script_phase
remove_embed_frameworks_script_phase_from_embedded_targets
add_copy_resources_script_phase
add_check_manifest_lock_script_phase
end
end

方法将每一个 Target 加入到了工程,使用 Xcodeproj 修改 Copy Resource Script Phrase 等设置,保存 project.pbxproj,整个 Pod install 的过程就结束了。

总结

最后想说的是 pod install 和 pod update 区别还是比较大的,每次在执行 pod install 或者 update 时最后都会生成或者修改 Podfile.lock 文件,其中前者并不会修改 Podfile.lock 中显示指定的版本,而后者会会无视该文件的内容,尝试将所有的 pod 更新到最新版。

CocoaPods 工程的代码虽然非常多,不过代码的逻辑非常清晰,整个管理并下载依赖的过程非常符合直觉以及逻辑。

作者:Draveness

链接:https://zhuanlan.zhihu.com/p/22652365


收起阅读 »