2023年4月

稳定性测试:
测试应用程序在长时间运行过程中是否存在内存泄漏、崩溃等问题,以确保应用程序具有较高的稳定性和可靠性。

对于安卓端,官方提供了很好的稳定性测试工具:monkey。 相比较而言,iOS则没有,而且当前网络上似乎也没有很好的第三方工具可以使用,因此只能自己写了。

我们要开发的iOS稳定性测试程序,应该至少包含以下内容:

  1. 持续随机触发UI事件
  2. 崩溃重启,测试不中断
  3. 日志记录

首先我们确定以上设想的可行性,然后再制定实施方案。在iOS原生开发语言swift和object-C中提供了可进行单元测试和UI测试的XCTest框架,而同样可进行移动端UI测试的第三方框架还有Appium等,但相比较第三方的开源框架,原生的XCTest框架性能更好且更稳定,因此这里我们选择基于swift语言和XCTest框架来开发。XCTest框架提供了非常全面的启动App和UI操作相关的API接口, 因此1、2两点完全可以实现,当然第三点的日志记录的实现也同样不会有什么问题。接下来就是具体实施了。

首先,我们创建一个用来执行测试的主类:StabilityTestRunner,然后再编写代码去实现以上三点。

一、持续随机触发UI事件

让我们拆分一下,随机触发UI事件,实际上包含两部分:随机UI元素和随机的UI操作。那么:

随机生成UI元素:

func randomElement(of types: [ElementType]) -> XCUIElement? {
        var allElement:[XCUIElement] = []
        for type in types {
            if !self.exists{
                break
            }
            var elements: [XCUIElement]
            if self.alerts.count > 0 {
                elements = self.alerts.descendants(matching: type).allElementsBoundByIndex
            }else {
                elements = self.descendants(matching: type).allElementsBoundByIndex
            }
            let filteredElements = elements.filter { element in
                if !element.exists {
                    return false
                }
                if !element.isHittable || !element.isEnabled {
                    return false // Filter out non clickable and blocked elements.
                }
                return true
            }
            allElement.append(contentsOf: filteredElements)
        }
        
        return allElement.randomElement()
    }

随机生成UI操作:

/**
     Random execution of the given UI operation.
     - parameter element: Page Elements.
     - parameter actions: Dictionary objects containing different UI operations.
     */
    private func performRandomAction(on element: XCUIElement, actions: [String: (XCUIElement) -> ()]) {
        let keys = Array(actions.keys)
        let randomIndex = Int.random(in: 0..<keys.count)
        let randomKey = keys[randomIndex]
        let action = actions[randomKey]
        
        if action == nil {
            return
        }
        
        if !element.exists {
            return
        }
        
        if !element.isHittable {
            return
        }
        Utils.log("step\(currentStep): \(randomKey) \(element.description)")
        action!(element)
    }

二、持续测试和崩溃重启

while !isTestingComplete{
            // Randomly select page elements.
            let element = app.randomElement(of: elementType)
            if element != nil {
                currentStep += 1
                takeScreenshot(element: element!)
                performRandomAction(on: element!, actions: actions) // Perform random UI operations.
                XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate(format: "self == %d", XCUIApplication.State.runningForeground.rawValue), object: app)], timeout: stepInterval)
                if app.state != .runningForeground {
                    if app.state == .notRunning || app.state == .unknown {
                        Utils.saveImagesToFiles(images: screenshotData)
                        Utils.saveImagesToFiles(images: screenshotOfElementData, name: "screenshot_element")
                        Utils.log("The app crashed. The screenshot before the crash has been saved in the screenshot folder.")
                    }
                    app.activate()
                    
                }
            }
        }

三、日志记录

记录截图并标记UI元素:

private func takeScreenshot(element: XCUIElement) {
        let screenshot = app.windows.firstMatch.screenshot().image
        if screenshotData.count == 3 {
            let minKey = screenshotData.keys.sorted().first!
            screenshotData.removeValue(forKey: minKey)
        }
        let screenshotWithRect = Utils.drawRectOnImage(image: screenshot, rect: element.frame)
        screenshotData[currentStep] = screenshotWithRect.pngData()
        let screenshotOfElement = element.screenshot().pngRepresentation
        if screenshotOfElementData.count == 3 {
            let minKey = screenshotOfElementData.keys.sorted().first!
            screenshotOfElementData.removeValue(forKey: minKey)
        }
        screenshotOfElementData[currentStep] = screenshotOfElement
    }

通过文本日志记录测试执行过程:

static func log(_ message: String) {
        print(message)
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        let dateString = dateFormatter.string(from: Date())
        let fileManager = FileManager.default
        do {
            try fileManager.createDirectory(atPath: logSavingPath, withIntermediateDirectories: true, attributes: nil)
        } catch {
            print("Error creating images directory: \(error)")
        }
        var fileURL: URL
        if #available(iOS 16.0, *) {
            fileURL = URL.init(filePath: logSavingPath).appendingPathComponent("log.txt")
        } else {
            fileURL = URL.init(fileURLWithPath: logSavingPath).appendingPathComponent("log.txt")
        }
        do {
            try "\(dateString) \(message)".appendLineToURL(fileURL: fileURL)
        } catch {
            print("Error writing to log file: \(error)")
        }

日志导出:

// To add the log files to the test results file, you can view it on your Mac. The test results file path: /User/Library/Developer/Xcode/DerivedData/AppStability-*/Logs.
        let zipFile = "\(NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0])/Logs.zip"
        let attachment = XCTAttachment(contentsOfFile: URL(fileURLWithPath: zipFile))
        attachment.name = "Logs"
        attachment.lifetime = .keepAlways
        // Add the "Logs.zip" file to the end of test result file.
        add(attachment)
        Utils.log("The logs for test steps has been added to the end of test result file at /User/Library/Developer/Xcode/DerivedData/AppStability-*/Logs")

注:以上代码只是主体实现,了解相关细节可通过
GitHub

Gitee
查阅完整代码。

总结

总的来说实现起来并不是很困难,当然从程序使用角度而言,用户可自定义随机UI事件的UI元素范围和UI操作的范围以及测试执行的时长和时间间隔,因此需要对ios应用程序和Xcode的使用以及iOS UI事件有一定的了解,具体使用可查看完整工程中的示例。

golang pprof 监控系列(4) —— goroutine thread 统计原理

大家好,我是蓝胖子。

在之前 golang pprof监控 系列文章里我分别介绍了go trace以及go pprof工具对memory,block,mutex这些维度的统计原理,今天我们接着来介绍golang pprof工具对于goroutine 和thread的统计原理。

还记得在
golang pprof监控系列(2) —— memory,block,mutex 使用
文章里,通过http接口的方式暴露的方式展现 指标信息那个网页图吗?

image.png

这一节,我将会介绍其中的goroutine部分和threadcreate部分。

老规矩,在介绍统计原理前,先来看看http接口暴露的方式暴露了哪些信息。

http 接口暴露的方式

让我们点击网页的goroutine 链接。。。

goroutine profile 输出信息介绍

image.png
进入到了一个这样的界面,我们挨个分析下网页展现出来的信息:

首先地址栏 /debug/pprof/goroutine?debug= 1 代表这是在访问goroutine指标信息,debug =1 代表访问的内容将会以文本可读的形式展现出来。 debug=0 则是会下载一个goroutine指标信息的二进制文件,这个文件可以通过go tool pprof 工具去进行分析,关于go tool pprof 的使用网上也有相当多的资料,这里就不展开了。 debug = 2 将会把当前所有协程的堆栈信息以文本可读形式展示在网页上。如下图所示:

image.png
debug =2 时的 如上图所示,41代表协程的id,方括号内running代表了协程的状态是运行中,接着就是该协程此时的堆栈信息了。

让我们再回到debug = 1的分析上面去,刚才分析完了地址栏里的debug参数,接着,我们看输出的第一行

goroutine profile: total 6
1 @ 0x102ad6c60 0x102acf7f4 0x102b04de0 0x102b6e850 0x102b6e8dc 0x102b6f79c 0x102c27d04 0x102c377c8 0x102d0fc74 0x102bea72c 0x102bebec0 0x102bebf4c 0x102ca4af0 0x102ca49dc 0x102d0b084 0x102d10f30 0x102d176a4 0x102b09fc4
#	0x102b04ddf	internal/poll.runtime_pollWait+0x5f		/Users/xiongchuanhong/goproject/src/go/src/runtime/netpoll.go:303
#	0x102b6e84f	internal/poll.(*pollDesc).wait+0x8f		/Users/xiongchuanhong/goproject/src/go/src/internal/poll/fd_poll_runtime.go:84

......

goroutine profile 表明了这个profile的类型。

total 6 代表此时一共有6个协程。

接着是下面一行,1 代表了在这个堆栈上,只有一个协程在执行。但其实在计算出数字1时,并不仅仅按堆栈去做区分,还依据了协程labels值,也就是 协程的堆栈和lebels标签值 共同构成了一个key,而数字1就是在遍历所有协程信息时,对相同key进行累加计数得来的。

我们可以通过下面的方式为协程设置labels。

	pprof.SetGoroutineLabels(pprof.WithLabels(context.Background(), pprof.Labels("name", "lanpangzi", "age", "18")))

通过上述代码,我可以为当前协程设置了两个标签值,分别是name和age,设置label值之后,再来看debug=1后的网页输出,可以发现 设置的labels出现了。

1 @ 0x104f86c60 0x104fb7358 0x105236368 0x104f867ec 0x104fba024
# labels: {"age":"18", "name":"lanpangzi"}
#	0x104fb7357	time.Sleep+0x137	/Users/xiongchuanhong/goproject/src/go/src/runtime/time.go:193
#	0x105236367	main.main+0x437		/Users/xiongchuanhong/goproject/src/go/main/main.go:46
#	0x104f867eb	runtime.main+0x25b	/Users/xiongchuanhong/goproject/src/go/src/runtime/proc.go:255

而数字1之后,就是协程正在执行的堆栈信息了。至此,goroutine指标的输出信息介绍完毕。

threadcreate 输出信息介绍

介绍完goroutine指标的输出信息后,再来看看threadcreate 线程创建指标的 输出信息。

image.png
老规矩,先看地址栏,debug=1代表 输出的是文本可读的信息,threadcreate 就没有debug=2的特别输出了,debug=0时 同样也会下载一个可供go tool pprof分析的二进制文件。

接着threadcreate pfofile表明了profile的类型, total 12 代表了此时总共有12个线程被创建,然后紧接着是11 代表了在这个总共有11个线程是在这个堆栈的代码段上被创建的,注意这里后面没有堆栈内容,说明runtime在创建线程时,并没有把此时的堆栈记录下来,原因有可能是 这个线程是runtime自己使用的,堆栈没有必要展示给用户,所以干脆不记录了,具体原因这里就不深入研究了。

下面输出的内容可以看到在main方法里面创建了一个线程,runtime.newm 方法内部,runtime会启动一个系统线程。

threadcreate 输出内容比较简单,没有过多可以讲的。

程序代码暴露指标信息

看完了http接口暴露着两类指标的方式,我们再来看看如何通过代码来暴露他们。
还记得在
golang pprof监控系列(2) —— memory,block,mutex 使用
是如何通过程序代码 暴露memory block mutex 指标的吗,goroutine 和 threadcreate 和他们一样,也是通过pprof.Lookup方法进行暴露的。

os.Remove("goroutine.out")
	f, _ := os.Create("goroutine.out")
	defer f.Close()
	err := pprof.Lookup("goroutine").WriteTo(f, 1)
	if err != nil {
		log.Fatal(err)
	}
	
	.... 
	
	os.Remove("threadcreate.out")
	f, _ := os.Create("threadcreate.out")
	defer f.Close()
	err := pprof.Lookup("threadcreate").WriteTo(f, 1)
	if err != nil {
		log.Fatal(err)
	}

无非就是将pprof.Lookup的传入的参数值改成对应的指标名即可。

接着我们来看看runtime内部是如何对这两种类型的指标进行统计的,好的,正戏开始。

统计原理介绍

无论是 goroutine 还是threadcreate 的指标信息的输出,都是调用了同一个方法writeRuntimeProfile。 golang 源码版本 go1.17.12。

// src/runtime/pprof/pprof.go:708
func writeRuntimeProfile(w io.Writer, debug int, name string, fetch func([]runtime.StackRecord, []unsafe.Pointer) (int, bool)) error {
	var p []runtime.StackRecord
	var labels []unsafe.Pointer
	n, ok := fetch(nil, nil)
	for {
		p = make([]runtime.StackRecord, n+10)
		labels = make([]unsafe.Pointer, n+10)
		n, ok = fetch(p, labels)
		if ok {
			p = p[0:n]
			break
		}
	}
	return printCountProfile(w, debug, name, &runtimeProfile{p, labels})
}

让我们来分析下这个函数,函数会传递一个fetch 方法,goroutine和threadcreate信息在输出时选择了不同的fetch方法来获取到各自的信息。

为了对主干代码有比较清晰的认识,先暂时不看fetch方法的具体实现,此时我们只需要知道,fetch方法可以将需要的指标信息 获取到,并且将信息的堆栈存到变量名为p的堆栈类型的切片里,然后将labels信息,存储到
变量名为labels的切片里。

注意: 只有goroutine类型的指标才有labels信息

获取到了堆栈信息,labels 信息,接着就是要将这些信息进行输出了,进行输出的函数是 上述源码里的最后一行 中 的printCountProfile 函数。

printCountProfile 函数的逻辑比较简单,我简单概括下,输出的时候会将 printCountProfile 参数中的堆栈信息连同labels构成的结构体 进行遍历, 堆栈信息和labels信息组合作为key,对相同key的内容进行累加计数。最后 printCountProfile 将根据debug的值的不同选择不同的输出方式,例如debug=0是二进制文件下载 方式 ,debug=1则是 网页文本可读方式进行输出

至此,对goroutine和threadcreate 指标信息的输出过程应该有了解了,即通过fetch方法获取到指标信息,然后通过printCountProfile 方法对指标信息进行输出。

fetch 方法的具体实现,我们还没有开始介绍,现在来看看,goroutine和threadcreate信息在输出时选择了不同的fetch方法来获取到各自的信息。

源码如下:

// src/runtime/pprof/pprof.go:661  
func writeThreadCreate(w io.Writer, debug int) error {
	return writeRuntimeProfile(w, debug, "threadcreate", func(p []runtime.StackRecord, _ []unsafe.Pointer) (n int, ok bool) {
		return runtime.ThreadCreateProfile(p)
	})
}

// src/runtime/pprof/pprof.go:680 
func writeGoroutine(w io.Writer, debug int) error {
	if debug >= 2 {
		return writeGoroutineStacks(w)
	}
	return writeRuntimeProfile(w, debug, "goroutine", runtime_goroutineProfileWithLabels)
}

goroutine 指标信息在输出时,会选择runtime_goroutineProfileWithLabels函数来获取goroutine指标,而threadcreate 则会调用 runtime.ThreadCreateProfile(p) 去获取threadcreate指标信息。

goroutine fetch 函数实现

runtime_goroutineProfileWithLabels 方法的实现是由go:linkname 标签链接过去的,实际底层实现的方法是 runtime_goroutineProfileWithLabels。

// src/runtime/mprof.go:744
//go:linkname runtime_goroutineProfileWithLabels runtime/pprof.runtime_goroutineProfileWithLabels
func runtime_goroutineProfileWithLabels(p []StackRecord, labels []unsafe.Pointer) (n int, ok bool) {
	return goroutineProfileWithLabels(p, labels)
}

goroutineProfileWithLabels 就是实际获取goroutine堆栈和标签的方法了。

我们往goroutineProfileWithLabels 传递了两个数组,分别用于存储堆栈信息,和labels信息,而goroutineProfileWithLabels 则负责将两个数组填充上对应的信息。

goroutineProfileWithLabels 的逻辑也比较容易,我这里仅仅简单概括下,其内部会通过一个全局变量allgptr 去遍历所有的协程,allgptr 保存了程序中所有的协程的地址, 而协程的结构体g内部,有一个叫做label的属性,这个值就代表协程的标签值,在遍历协程时,通过该属性便可以获取到标签值了。

threadcreate fetch 函数实现

runtime.ThreadCreateProfile 是 获取threadcreate 指标的方法。

源码如下:

func ThreadCreateProfile(p []StackRecord) (n int, ok bool) {
	first := (*m)(atomic.Loadp(unsafe.Pointer(&allm)))
	for mp := first; mp != nil; mp = mp.alllink {
		n++
	}
	if n <= len(p) {
		ok = true
		i := 0
		for mp := first; mp != nil; mp = mp.alllink {
			p[i].Stack0 = mp.createstack
			i++
		}
	}
	return
}

首先是获取到allm变量的地址,allm是一个全局变量,它其实是 存储所有m链表 的表头元素。

// src/runtime/runtime2.go:1092
var (
	allm       *m
	.....

在golang里,每创建一个m结构便会在底层创建一个系统线程,所以你可以简单的认为m就是代表了一个线程。可以之后深入了解下gpm模型。

for mp := first; mp != nil; mp = mp.alllink {
			p[i].Stack0 = mp.createstack
			i++
		}

然后 ThreadCreateProfile 里 这段逻辑就是遍历了整个m链表,将m结构体保存的堆栈信息赋值给 参数p,p则是我们需要填充的堆栈信息数组,在m结构体里,alllink是一个指向链表下一个元素的指针,每次新创建m时,会将新m插入到表头位置,然后更新allm变量。

总结

至此,goroutine 和threadcreate的使用和原理都介绍完了,他们比起之前的memory,block之类的统计相对来说比较简单,简而言之就是遍历一个全局变量allgptr或者allm ,遍历时获取到协程或者线程的堆栈信息和labels信息,然后将这些信息进行输出即可。

前言

在上一篇
丝滑的贝塞尔曲线:从数学原理到应用
介绍贝塞尔曲线实现动画时给自己留了一个坑,实现的动画效果和CSS的
transition-timing-function: cubic-bezier
差别较大,如下图所示,红色为Linear、绿色为CSS的cubic-beizer、蓝色为自己实现的cbezier。本着有坑必填的原则,直接把Mozilla、Chromium的cubic-bezier实现源码给翻出来。

4月-01-2023 13-51-56.gif

为什么和CSS效果不一致?假设贝塞尔曲线为
cubic-bezier(0.25, 0.1, 0.25, 1.0)
,X轴表示时间,Y轴表示元素样式属性变化量,设贝塞尔曲线为Q(t), 我直接把动画时间当做贝塞尔曲线的t,用Qy(X)来计算Y值。而正确的流程是
已知X值,根据Qx(t)=X对方程求根,其根即为t值,再根据t值求
Y = Qy(t)

drawing

整个流程的难点在于如何对Qx(t)=X求根t值,已知贝塞尔曲线方程为

Q(t) = X1 * (1 - t)³ + X2 * 3t(1 -t)² + X3 * 3t²(1 - t) + X4 * t³

由于曲线X轴范围为[0, 1],因此
X1=0

X4=1
, 从而Q(t)可写为

Q(t) = X2 * 3t(1 -t)² + X3 * 3t²(1 - t) + t³

将方程各项展开再合并成如下形式,主要是为了更方便的介绍浏览器源码实现。

Q(t) = ( ( (1.0 - 3.0 * X2 + 3.0 * X1) * t + (3.0 * X2 - 6.0 * X1) ) * t + (3.0 * X1) ) * t

浏览器是如何实现的

浏览器实现流程:使用参数
cubic-beizer(X1, Y1, X2, Y2)
生成贝塞尔曲线方程,根据传入的X值对方程求解得到t值,再将t传入Y方向的曲线方程计算Y值。难点在于如何求解方程的根,而求根一般可通过
Newton-Raphson method
牛顿法、
Bisection method
二分法。在深入浏览器源码之前,先介绍这两种求根方法,Mozilla和Chromium都基于这两种方法求解贝塞尔曲线的根。

Newton-Raphson method
牛顿法

牛顿法是求解数学方程的一种常用方法。它是一种迭代算法,通过不断逼近函数的根来寻找解。牛顿法可以用于求解实函数的根,也可以用于求解实函数的最小值或最大值。

屏幕快照 2023-03-30 上午12.53.43.png

牛顿法的基本思想是:选择一个初始值
\(x_0\)
,然后通过递归计算出一个数列
\(x_1\)
,
\(x_2\)
, ...,
\(x_n\)
,使得每一项
\(x_{n+1}\)
都是函数
f(x)

\(x_n\)
处的切线与x轴的交点。也就是说,我们用当前的切线来近似替代原函数,从而得到更接近真实根的近似解。假设Xn处的函数值为
\(f(x_n)\)
、斜率为
\(f'(x_n)\)

\(X_{n+1}\)

\(f'(x_n)\)
与X轴的交点,从而有:

屏幕快照 2023-03-30 上午1.18.37.png

其中
f'(x)
是函数
f(x)
在 x 处的导数。每一次迭代中,我们通过计算
\(f(x_n)\)

\(f'(x_n)\)
来求出
\(x_{n+1}\)
的值,并将其作为下一次迭代的初始值。这个过程会不断重复,直到得到一个满足预定精度要求的解。

牛顿法的缺陷是,当斜率很小时,与X轴平行,得不到解。

Bisection method
二分法

Bisection method是一种求解方程的数值方法,可以用来找到一个函数f(x)=0的解。这种方法利用了连续函数的介值定理(Intermediate Value Theorem),即在一个区间[a,b]内,如果f(a)和f(b)的符号不同,那么必定存在至少一个数c属于[a,b],使得f(c)=0。

算法的基本思想是将一个区间[a,b]不断分成两半,然后找到包含根的那一半,并继续在该子区间内进行分割。每次分割之后,都会得到一个新的子区间,其长度是原来的一半。通过不断重复这个过程,可以越来越逼近根的位置。

屏幕快照 2023-03-30 下午10.57.22.png

上图中f(x)递增,并且
f(a) < 0, f(b) > 0
。我们继续获取了a和b的中点x0。由上图可知
f(x0) > 0
,所以我们可以把x0看成是新的b。于是我们继续寻找a和x0的中点,重复上述过程,由于我们最大的误差就是区间的长度,所以当我们区间的长度缩减到足够小,那么就说明我们已经找到了一个足够近似的解。

Mozilla实现

由于
@greweb
已经将Mozilla的
缓动动画
通过JS实现,Mozilla的实现直接通过JS代码介绍。
主体结构和我们介绍的流程一致,定义bezier方法,参数分别为P1、P2坐标,返回结果为函数
BezierEasing
, 其内部会根据传入的X值,调用getTForX(x)求根得到t,最后调用
calcBezier(t, mY1, mY2)
计算Y轴的曲线值。

export function bezier (mX1, mY1, mX2, mY2) {
  // ...其他逻辑

  return function BezierEasing (x) {
    // Because JavaScript number are imprecise, we should guarantee the extremes are right.
    if (x === 0 || x === 1) {
      return x;
    }
    return calcBezier(getTForX(x), mY1, mY2);
  };
};

calcBezier
函数比较简单,就是根据贝塞尔曲线方程以及传入的t值计算结果,由于要计算一阶导数,将Q(t)拆成了三部分,
1.0 - 3.0 * X2 + 3.0 * X1
对应A函数,
3.0 * X2 - 6.0 * X1
对应B函数,
3.0 * X1
对应C函数。

// Q(t) = ( ( (1.0 - 3.0 * X2 + 3.0 * X1) * t + (3.0 * X2 - 6.0 * X1) ) * t + (3.0 * X1) ) * t

function A (aA1, aA2) { return 1.0 - 3.0 * aA2 + 3.0 * aA1; }
function B (aA1, aA2) { return 3.0 * aA2 - 6.0 * aA1; }
function C (aA1)      { return 3.0 * aA1; }

// at为t,aA1表示x1或者x2,aA2表示y1或者y2
// 其形式和p0 * Math.pow(1 - t, 3) + p1 * 3 * t * Math.pow(1 - t, 2) + p2 * 3 * Math.pow(t, 2) * (1 - t) + p3 * Math.pow(t, 3)一致
function calcBezier (aT, aA1, aA2) { return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT; }

难点在于
getTForX
函数,其作用是根据X值计算t,涉及到曲线方程求根,而求根一般可通过
Newton-Raphson method
牛顿法、
Bisection method
二分法, 也就是我上面介绍的两种方法。

而Mozilla在求根时会根据斜率选择不同的方法,为了提升计算性能,在求根之前先采样,使用sampleValues缓存X轴方向当t=0,0.1,...,0.9, 1时对应的曲线值。

  // kSplineTableSize为11,kSampleStepSize为1.0 / (11 - 1.0) = 0.1;
  // sampleValues存储样本值的目的是提升性能,不用每次都计算。
  var sampleValues = float32ArraySupported ? new Float32Array(kSplineTableSize) : new Array(kSplineTableSize);
  // i从0到10,sampleValues长度为11
  for (var i = 0; i < kSplineTableSize; ++i) {
    // i * kSampleStepSize的范围0到1(10 * 0.1);
    // sampleValues[0] = calcBezier(0, mX1, mX2);
    // sampleValues[1] = calcBezier(0.1, mX1, mX2);
    // ...
    // sampleValues[9] = calcBezier(0.9, mX1, mX2);
    // sampleValues[10] = calcBezier(1, mX1, mX2);
    sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2);
  }

对于
getTForX
函数的实现,首先遍历
sampleValues
数组,找到小于目标值aX的最大值,为了减少求根的遍历次数,为了使guessForT尽量接近目标根,使用
(aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample])
计算出差值在两个样本区间的百分比dist,那么
intervalStart + dist * KSampleStepSize
即为根据样本值能找到最接近目标根
guessForT
的初始值。

  // 已知X值,根据X值求解T值
  var NEWTON_MIN_SLOPE = 0.001;
  
  function getTForX (aX) {
    var intervalStart = 0.0;
    var currentSample = 1;
    // lastSample为10
    var lastSample = kSplineTableSize - 1;
    // sampleValues[i]表示i从0以0.1为step,每一步对应的曲线的X坐标值,直到X坐标值小于等于aX
    // 假如aX=0.4,则sampleValues[currentSample]<=aX为止
    for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) {
      // intervalStart为到aX经过的step步骤
      intervalStart += kSampleStepSize; // kSampleStepSize为0.1
    }
    //TODO:currentSample为什么要减1?sampleValues[currentSample]大于了ax,所以要--,使得sampleValues[currentSample]<=ax
    --currentSample;

    // Interpolate to provide an initial guess for t
    // ax-sampleValues[currentSample]为两者之间的差值,而(sampleValues[currentSample + 1] - sampleValues[currentSample])一个步骤之间的总差值。
    var dist = (aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample]);
    // guessForT为预计的初始T值,很粗糙的一个值,接下来会基于该值求根(t值)。
    var guessForT = intervalStart + dist * kSampleStepSize;
    // 预测的T值对应位置的斜率
    var initialSlope = getSlope(guessForT, mX1, mX2);
    // 当斜率大于0.05729°时,使用newtonRaphsonIterate算法预测T值。0.05729是一个很小的斜率
    if (initialSlope >= NEWTON_MIN_SLOPE) {
      return newtonRaphsonIterate(aX, guessForT, mX1, mX2);
    } else if (initialSlope === 0.0) { // 当斜率为0,则直接返回
      return guessForT;
    } else { // 当斜率小于0.05729并且不等于0时,使用binarySubdivide
      // 求得的根t,位于intervalStart和intervalStart + kSampleStepSize之间, mX1、mX2分别对应p1、p2的X坐标
      return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2);
    }
  }

使用
getSlope
函数计算guessForT的斜率initialSlope,
NEWTON_MIN_SLOPE
为测试的一个斜率临界(0.001),当大于等于该斜率时使用牛顿法球根,否则使用二分法球根。牛顿法实现:

var NEWTON_ITERATIONS = 4;

function newtonRaphsonIterate (aX, aGuessT, mX1, mX2) {
 // NEWTON_ITERATIONS为4, 只进行了4次迭代, 根据精度和性能之间做了平衡。
 for (var i = 0; i < NEWTON_ITERATIONS; ++i) {
   // 计算t值对应位置的斜率
   var currentSlope = getSlope(aGuessT, mX1, mX2);
   if (currentSlope === 0.0) {
     return aGuessT;
   }
   // 假设f(t) = 0,求解方程的根。其f(t)=calcBezier(t) - ax
   // 牛顿-拉佛森方法: Xn-1 = Xn - f(t) / f'(t),应用到求贝塞尔曲线的根:Tn = Tn+1 - (calcBezier(t) - ax) / getSlope(t)
   var currentX = calcBezier(aGuessT, mX1, mX2) - aX;
   aGuessT -= currentX / currentSlope;
 }
 // 这里只迭代了4次,求得近似值
 return aGuessT;
}

牛顿法求根会不断根据上一个值
\(x_n\)
迭代下一个值
\(x_{n+1}\)
,而
newtonRaphsonIterate
函数仅仅迭代了4次,是根据精度和性能之间的平衡考量。根据牛顿法公式:

屏幕快照 2023-03-30 上午12.41.50.png

这里的f(t) = calcBezier(t) - aX, f'(t)即计算在t位置的斜率用
getSlope(aGuessT, mX1, mX2)
表示,所以aGuessT的迭代值可写为
aGuessT = aGuessT - f(t)/f'(t)
。当斜率为0时,aGuessT即为最终值;否则直到迭代4次结束。

当斜率小于
NEWTON_MIN_SLOPE
时,降级使用
Bisection method二分法
,要计算的t值肯定在[intervalStart、intervalStart + kSampleStepSize]之间,并且f(intervalStart)和f(intervalStart + kSampleStepSize)的乘积小于0,满足二分法条件。

binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2);

二分法会不断缩小[a,b]范围,直到计算的值和目标值aX差值小于
SUBDIVISION_PRECISION
,也即小于0.0000001。 或者大于最大迭代次数
SUBDIVISION_MAX_ITERATIONS
也会终止。

var SUBDIVISION_PRECISION = 0.0000001;
var SUBDIVISION_MAX_ITERATIONS = 10;

// 二分球根法:
// 求得的根t,位于aA和aB之间, mX1、mX2分别对应p1、p2的X坐标
// https://zhuanlan.zhihu.com/p/112845185
function binarySubdivide (aX, aA, aB, mX1, mX2) {
  var currentX, currentT, i = 0; 
  do {
    currentT = aA + (aB - aA) / 2.0;
    // 假设f(t) = 0,求解方程的根。其f(t)=calcBezier(t) - ax
    currentX = calcBezier(currentT, mX1, mX2) - aX;
    if (currentX > 0.0) {
      aB = currentT;
    } else {
      aA = currentT;
    }
    // 如果currentX小于等于最小精度(SUBDIVISION_PRECISION)或者超过迭代次数SUBDIVISION_MAX_ITERATIONS,则终止
  } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS);
  return currentT;
}

aA、aB为初始范围,每次迭代计算中点
currentT = aA + (aB - aA) / 2.0
, 然后使用其值计算currentX(
calcBezier(currentT, mX1, mX2)
)。当currentX大于aX时,说明currentT还大于目标t,则将aB赋值为currentT缩小右边界;否则将aA赋值为currentT缩小左边界。

至此,Mozilla的实现介绍完毕。

Chromium实现

实现和Mozilla类似,核心部分
SolveCurveX
也是对已知的X和曲线方程求解t。先迭代样本数组,找到第一个大于等于X的样本值,设置最接近的值为t2, 在区间[t0, t1]中。
在选择牛顿法和二分法时,Chromium和Mozilla有些区别,Chromium先使用牛顿法迭代
kMaxNewtonIterations
4次直到计算值小于精度
newton_epsilon
,或者一阶导(斜率)
SampleCurveDerivativeX(t2)
小于最小斜率
kBezierEpsilon
也会中止。
如果牛顿法未计算出满足精度要求的根,则继续使用二分法判断。和Mozilla的区别在于没限制迭代次数,一直根据
t0<t1
条件迭代,直到找到满足精度的t2。r

double CubicBezier::SolveCurveX(double x, double epsilon) const {
    double t0;
    double t1;
    double t2 = x;
    double x2;
    double d2;
    int i;

    // Linear interpolation of spline curve for initial guess.
    // 迭代样本值,找到和x最接近的初始t,这里为t2,在t0和t1之间。
    double delta_t = 1.0 / (CUBIC_BEZIER_SPLINE_SAMPLES - 1);
    for (i = 1; i < CUBIC_BEZIER_SPLINE_SAMPLES; i++) {
        if (x <= spline_samples_[i]) {
            t1 = delta_t * i;
            t0 = t1 - delta_t;
            // 根据x差值在所在区间的百分占比计算t在阶段间的百分占比,t2为接近x根的初始值
            t2 = t0 + (t1 - t0) * (x - spline_samples_[i - 1]) /
                            (spline_samples_[i] - spline_samples_[i - 1]);
            break;
        }
    }

    // Perform a few iterations of Newton's method -- normally very fast.
    // See https://en.wikipedia.org/wiki/Newton%27s_method.
    // 牛顿法的精度
    double newton_epsilon = std::min(kBezierEpsilon, epsilon);
    for (i = 0; i < kMaxNewtonIterations; i++) {
        // x2为t2所在x值和目标x差值
        x2 = SampleCurveX(t2) - x;
        // 如果差值小于精度则任务t2即为方程根
        if (fabs(x2) < newton_epsilon)
            return t2;
        // d2为t2位置的斜率
        d2 = SampleCurveDerivativeX(t2);
        // 如果斜率小于最小精度kBezierEpsilon,任务与x轴平行,无法继续计算
        if (fabs(d2) < kBezierEpsilon)
            break;
        // 否则使用牛顿法继续迭代
        t2 = t2 - x2 / d2;
    }
    if (fabs(x2) < epsilon)
        return t2;

    // Fall back to the bisection method for reliability.
    // 降级使用二分法
    while (t0 < t1) {
        x2 = SampleCurveX(t2);
        // 如果x2小于最小精度,则任务为要求的根
        if (fabs(x2 - x) < epsilon)
            return t2;
        // 下面的流程和二分法流程一致
        if (x > x2)
            t0 = t2;
        else
            t1 = t2;
        t2 = (t1 + t0) * .5;
    }

    // Failure.
    return t2;
}

效果对比

使用封装的动画函数
animate
执行动画,查看动画效果。easing为
bezier-easing(0.25, 0.1, 0.25, 1.0)
,和CSS指定贝塞尔曲线一致。

animate(bezieRef2.current, {
    duration: 2000,
    easing: 'bezier-easing(0.25, 0.1, 0.25, 1.0)',
    styles: [{ left: !ended ? '0%' : '100%' }, { left: !ended ? '100%' : '0%' } ]
})

animate函数内部调用
resolveEasing
解析传入的字符串
bezier-easing(0.25, 0.1, 0.25, 1.0)
,并生成贝塞尔曲线函数,生成的曲线函数形式为
function BezierEasing (x): number
,根据传入的X(时间百分比)求解t值,再根据t计算Y值(元素位置)。

/**
 * 元素动画
 * @param el DOM元素
 * @param props 动画属性
 */
function animate(el, props) {
    const duration = props.duration;
    const easingFunc = resolveEasing(props.easing);
    const styleFuncs = resolveStyles(el, props.styles);
    const start = Date.now();
    const animationHandle = () => {
        const timeRatio = (Date.now() - start) / duration;
        if (timeRatio <= 1) {
            const percent = easingFunc(timeRatio);
            for (const key in styleFuncs) {
                const elementStyle = el.style;
                elementStyle[key] = styleFuncs[key](percent);
            }
            requestAnimationFrame(animationHandle);
        }
    };
    animationHandle();
}

原始的CSS实现缓动动画,非常简单,直接设置样式即可:

.fade-in-cbezier {
    transition: all 2s;
    transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1.0); 
}

设置相同的曲线参数,自己实现的贝塞尔缓动动画和CSS对比,其效果几乎完全一致,图中
绿色
的为CSS,
黄色
为自己实现的动画效果。

4月-01-2023 13-55-44.gif

CSS 默认提供了几个固定的贝塞尔曲线
linear

ease-in

ease-out

ease-in-out

  • linear 匀速移动,cubic-bezier(0.0, 0.0, 1.0, 1.0)
  • ease-in 先慢后快 cubic-bezier(0.42, 0, 1.0, 1.0)
  • ease-out 先快后慢 cubic-bezier(0, 0, 0.58, 1.0)
  • ease-in-out 慢速、提速、减速三个阶段 cubic-bezier(0.42, 0, 0.58, 1.0)

可通过
在线预览
查看动贝塞尔曲线。

屏幕快照 2023-04-01 下午12.57.10.png

运行效果:

4月-01-2023 13-40-28.gif

动画库入门

标星44.8k的
animejs
,其核心部分也使用了Mozilla的贝塞尔曲线动画,另外一个重要的问题是如何转换单位,如将left设置为
50%

20em
或者
50vw
,只有将单位统一才能计算中间差值。
animejs将元素的初始值使用
convertPxToUnit
函数转换为目标单位,以
convertPxToUnit(el, '50', 'em') )
为例,
convertPxToUnit
为元素el的父元素附加一个临时子要素
tempEl
,目标单位为
em
,设置
tempEl
的宽度为
100em
,然后得到因子
factor=100em/offestWidth
,即
目标长度/像素
,那么转换后的值为
factor * parseFloat ('50')

/**
 * 将像素值转换为目标单位值
 * @param {*} el 
 * @param {*} value 
 * @param {*} unit 
 * @returns 
 */
 function convertPxToUnit(el: Element, value: string, unit: string) {
    const baseline = 100;
    // 创建一个和el类型一样的要素
    const tempEl = document.createElement(el.tagName);
    // 获取要素的parent
    const parentEl = (el.parentNode && (el.parentNode !== document)) ? el.parentNode : document.body;
    parentEl.appendChild(tempEl);
    tempEl.style.position = 'absolute';
    // 设置基线为100个目标单位
    tempEl.style.width = baseline + unit;
    // 宽度因子,目标长度/一个像素
    const factor = baseline / tempEl.offsetWidth;
    parentEl.removeChild(tempEl);
    // parseFloat会将最后的单位忽略得到数值
    const convertedUnit = factor * parseFloat(value);

    return convertedUnit;
}

在执行动画时,可同时设置多个属性,如opacity、height、width、left、top等,
resolveStyles
函数遍历每个属性并为其生成一个获取中间值的函数,如设置
{width: '30%'}
, 那么目标值destValue为30。如何获取width的当前值并转换单位为
%
?先调用
getComputedStyle(el).getPropertyValue(key.toLowerCase())
获取其像素值,再通过前面实现的
convertPxToUnit
将像素值转换为
%
。有了startValue和destValue,中间值即可通过
startValue + total * percent
计算。

export type StyleProperties = {
    [x: string]: string | number;
}

function resolveStyles(el: Element, styles: StyleProperties) {
    const keys = Object.keys(styles);
    const styleFuncs: { [x: string]: (t: number) => number | string } = {};

    for (const key of keys) {
        const value = styles[key] + '';
        const unit = <string>getUnit(value);

        const destValue = parseFloat(value);
        const styleValue = getComputedStyle(el).getPropertyValue(key.toLowerCase());
        const startValue = unit ? convertPxToUnit(el, styleValue, unit) : parseFloat(styleValue);

        const total = destValue - startValue;

        styleFuncs[key] = (percent: number) => {
            const curVal = startValue + total * percent;

            return unit ? curVal + unit : curVal;
        }
    }

    return styleFuncs;
}

对外提供的API格式为
animate(el: HTMLElement, props: AnimationProps)
,props包括duration、styles、easing三个参数,函数会根据
(Date.now() - start) / duration
计算动画执行进度,然后遍历styleFuncs设置每个样式属性的中间值。

export type AnimationProps = {
    duration: number;
    styles: StyleProperties;
    easing: string;
}

/**
 * 元素动画
 * @param el DOM元素
 * @param props 动画属性
 */
export function animate(el: HTMLElement, props: AnimationProps): { pause: () => void } {
    const duration = props.duration;
    const easingFunc = resolveEasing(props.easing);
    const styleFuncs = resolveStyles(el, props.styles);

    const cAniInstance = {
        paused: false,
    }

    const start = Date.now();
    const animationHandle = () => {
        if (cAniInstance.paused) {
            return;
        }
        const timeRatio = (Date.now() - start) / duration;
        if (timeRatio <= 1) {
            const percent = easingFunc(timeRatio);
            for (const key in styleFuncs) {
                const elementStyle = el.style as any;
                elementStyle[key] = styleFuncs[key](percent);
            }
            requestAnimationFrame(animationHandle);
        }
    }
    requestAnimationFrame(animationHandle);

    return {
        pause: () => {
            cAniInstance.paused = true;
        }
    }
}

使用比较简单,实现一个弹窗效果:

animate(ref1.current, {
    duration: 500,
    easing: 'cbezier(0.25, 0.1, 0.25, 1.0)',
    styles: {
        opacity: '1',
        width: '300px',
        height: '200px',
        left: '30%',
        top: '30%',
    }
})

4月-01-2023 17-56-32.gif

实现鼠标跟踪,由于gif图像有丢帧,效果不是很明显,Demo已上传到
cbezier

 const mouseMove = throttle((e) => {
    const x = e.offsetX, y = e.offsetY;
    if (aniPlay) {
        aniPlay.pause();
    }
    // console.log(ref1.current.style.left, ref1.current.style.top);
    aniPlay = animate(ref1.current, {
        duration: 15,
        // easing: 'ease-out',
        easing: 'cbezier(0.25, 0.1, 0.25, 1.0)',
        styles: {
            left: x + 'px',
            top: y + 'px',
        }
    })
}, 15);
parentRef.current.addEventListener('mousemove', mouseMove);

鼠标跟随效果.gif

下一步计划: 实现完整的动画库
hiani.js
。以上仅仅是一个入门版动画,要实现和animejs一样的效果,还需要支持
rotate

scale

translate

skew
等transform样式以及颜色变化,下一步将完善动画库支持更多样式属性、提供play、pause、reset等完整的函数控制动画。

参考

1、
Bezier Curve as Easing Function
2、
Bezier Curve based easing functions – from concept to implementation
3、
mozilla贝塞尔实现
4、
Chromium贝塞尔实现
5、
Newton Raphson method
6、
Newton Raphson formula
7、
牛顿法和二分法的区别
8、
二分法介绍

**写在最后,如果大家有疑问可直接留言,一起探讨!感兴趣的可以点一波关注, 文章也在
掘金
上同步。

Hutool 大家已经比较熟悉了,这是一个超全的 Java 工具库,深受国内开发者的喜爱。

我之前其实是不太喜欢使用这种功能太多的工具类的,也比较担心稳定性和安全性,后面慢慢接受了就感觉其实也还好。而且,我们还可以按需只引入自己需要的功能模块,相对也比较灵活。

Hutool 的官方文档介绍的已经比较清晰了,奈何其提供的功能实在太多,我这里列举一些我个人觉得比较实用的功能,供大家学习参考。

Hutool 介绍

Hutool 真心是一个不错的国产 Java 工具类库,功能全面,对文件、流、加密解密、转码、正则、线程、XML 等 JDK 方法进行了封装,开箱即用!

官方是这样介绍 Hutool 的:

Hutool 介绍

Hutool 包含的组件以及组件提供的功能如下表所示:

Hutool 包含的组件

你可以根据项目需求对每个模块单独引入,也可以通过引入
hutool-all
方式引入所有模块。不过,
还是不建议引入所有模块,因为绝大部分功能项目可能都用不上,建议只引入你需要的模块。

另外,Hutool 也有一个比较明显的缺点,很多功能实现的比较简单比如图片验证码、Excel 工具类,很可能无法满足项目的实际需求。像这样情况,还是建议你选择在某一方面更优秀的工具库比如 Excel 工具库MyExcel、EasyExcel、图片处理库Imglib。

Hutool 实战

引入依赖

Maven 仓库地址:
https://mvnrepository.com/artifact/cn.hutool

这里为了方便,我们直接引入所有模块,实际项目中还是建议只引入自己需要的模块。

Maven:

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.16</version>
</dependency>

Gradle:

implementation 'cn.hutool:hutool-all:5.8.16'

功能演示

Hutool 提供的功能实在太多,这里只列举一些我个人觉得比较实用的功能,供大家学习参考。

类型转换

Convert
类封装了针对Java常见类型的转换。

long[] b = {1,2,3,4,5};
String bStr = Convert.toStr(b);//"[1, 2, 3, 4, 5]"

double a = 67556.32;
String digitUppercase = Convert.digitToChinese(a);//"陆万柒仟伍佰伍拾陆元叁角贰分"

邮件

在 Java 中发送邮件主要品依靠
javax.mail
包,但是由于使用比较繁琐,因此 Hutool 针对其做了封装。

在classpath(在标准Maven项目中为
src/main/resources
)的config目录下新建
mail.setting
文件,完整配置如下(邮件服务器必须支持并打开SMTP协议):

# 邮件服务器的SMTP地址,可选,默认为smtp.<发件人邮箱后缀>
host = smtp.yeah.net
# 邮件服务器的SMTP端口,可选,默认25
port = 25
# 发件人(必须正确,否则发送失败)
from = hutool@yeah.net
# 用户名,默认为发件人邮箱前缀
user = hutool
# 密码(注意,某些邮箱需要为SMTP服务单独设置授权码,详情查看相关帮助)
pass = q1w2e3

发送邮件非常简单:

MailUtil.send("hutool@foxmail.com", "测试", "邮件来自Hutool测试", false);

支持群发:

ArrayList<String> tos = CollUtil.newArrayList(
    "person1@bbb.com", 
    "person2@bbb.com", 
    "person3@bbb.com", 
    "person4@bbb.com");

MailUtil.send(tos, "测试", "邮件来自Hutool群发测试", false);

支持添加一个或者多个附件:

MailUtil.send("hutool@foxmail.com", "测试", "<h1>邮件来自Hutool测试</h1>", true, FileUtil.file("d:/aaa.xml"));

除了使用配置文件定义全局账号以外,
MailUtil.send
方法同时提供重载方法可以传入一个
MailAccount
对象,这个对象为一个普通Bean,记录了邮件服务器信息。

MailAccount account = new MailAccount();
account.setHost("smtp.yeah.net");
account.setPort("25");
account.setAuth(true);
account.setFrom("hutool@yeah.net");
account.setUser("hutool");
account.setPass("q1w2e3");

MailUtil.send(account, CollUtil.newArrayList("hutool@foxmail.com"), "测试", "邮件来自Hutool测试", false);

唯一 ID

在分布式环境中,唯一 ID 生成应用十分广泛,生成方法也多种多样,Hutool 针对一些常用生成策略做了简单封装。

Hutool 提供的唯一 ID 生成器的工具类,涵盖了:

  • UUID
  • ObjectId
    (MongoDB)
  • Snowflake
    (Twitter)

拿 UUID 举例!

Hutool 重写
java.util.UUID
的逻辑,对应类为
cn.hutool.core.lang.UUID
,使生成不带-的 UUID 字符串不再需要做字符替换,
性能提升一倍左右

//生成的UUID是带-的字符串,类似于:a5c8a5e8-df2b-4706-bea4-08d0939410e3
String uuid = IdUtil.randomUUID();

//生成的是不带-的字符串,类似于:b17f24ff026d40949c85a24f4f375d42
String simpleUUID = IdUtil.simpleUUID();

HTTP 请求工具类

针对最为常用的 GET 和 POST 请求,HttpUtil 封装了两个方法,

  • HttpUtil.get
  • HttpUtil.post

GET请求:

// 最简单的HTTP请求,可以自动通过header等信息判断编码,不区分HTTP和HTTPS
String result1= HttpUtil.get("https://www.baidu.com");

// 当无法识别页面编码的时候,可以自定义请求页面的编码
String result2= HttpUtil.get("https://www.baidu.com", CharsetUtil.CHARSET_UTF_8);

//可以单独传入http参数,这样参数会自动做URL编码,拼接在URL中
HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("city", "北京");

String result3= HttpUtil.get("https://www.baidu.com", paramMap);

POST请求:

HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("city", "北京");

String result= HttpUtil.post("https://www.baidu.com", paramMap);

文件上传:

HashMap<String, Object> paramMap = new HashMap<>();
//文件上传只需将参数中的键指定(默认file),值设为文件对象即可,对于使用者来说,文件上传与普通表单提交并无区别
paramMap.put("file", FileUtil.file("D:\\face.jpg"));

String result= HttpUtil.post("https://www.baidu.com", paramMap);

缓存

Hutool 提供了常见的几种缓存策略的实现:

  1. FIFO(first in first out)
    :先进先出策略。
  2. LFU(least frequently used)
    :最少使用率策略。
  3. LRU(least recently used)
    :最近最久未使用策略。
  4. Timed
    :定时策略。
  5. Weak
    :弱引用策略。

并且,Hutool 还支持将小文件以
byte[]
的形式缓存到内容中,减少文件的访问,以解决频繁读取文件引起的性能问题。

FIFO(first in first out) 策略缓存使用:

Cache<String,String> fifoCache = CacheUtil.newFIFOCache(3);

//加入元素,每个元素可以设置其过期时长,DateUnit.SECOND.getMillis()代表每秒对应的毫秒数,在此为3秒
fifoCache.put("key1", "value1", DateUnit.SECOND.getMillis() * 3);
fifoCache.put("key2", "value2", DateUnit.SECOND.getMillis() * 3);
fifoCache.put("key3", "value3", DateUnit.SECOND.getMillis() * 3);

//由于缓存容量只有3,当加入第四个元素的时候,根据FIFO规则,最先放入的对象将被移除
fifoCache.put("key4", "value4", DateUnit.SECOND.getMillis() * 3);

//value1为null
String value1 = fifoCache.get("key1");

控制台打印封装

一般情况下,我们打印信息到控制台小伙伴们应该再熟悉不过了!

System.out.println("Hello World");

但是,这种方式不满足很多场景的需要:

  1. 不支持参数,对象打印需要拼接字符串
  2. 不能直接打印数组,需要手动调用
    Arrays.toString

为此,Hutool 封装了
Console
对象。

Console
对象的使用更加类似于 Javascript 的
console.log()
方法,这也是借鉴了 JS 的一个语法糖。

String[] a = {"java", "c++", "c"};
Console.log(a);//控制台输出:[java, c++, c]

Console.log("This is Console log for {}.", "test");//控制台输出:This is Console log for test.

加密解密

Hutool 支持对称加密、非对称加密、摘要加密、消息认证码算法、国密。

这里以国密为例,Hutool针对
Bouncy Castle
做了简化包装,用于实现国密算法中的SM2、SM3、SM4。

国密算法需要引入
Bouncy Castle
库的依赖:

<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk15to18</artifactId>
    <version>1.69</version>
</dependency>

SM2 使用自定义密钥对加密或解密

String text = "JavaGuide:一份涵盖大部分 Java 程序员所需要掌握的核心知识。准备 Java 面试,首选 JavaGuide!";
System.out.println("原文:" + text);

KeyPair pair = SecureUtil.generateKeyPair("SM2");
// 公钥
byte[] privateKey = pair.getPrivate().getEncoded();
// 私钥
byte[] publicKey = pair.getPublic().getEncoded();

SM2 sm2 = SmUtil.sm2(privateKey, publicKey);
// 公钥加密,私钥解密
String encryptStr = sm2.encryptBcd(text, KeyType.PublicKey);
System.out.println("加密后:" + encryptStr);

String decryptStr = StrUtil.utf8Str(sm2.decryptFromBcd(encryptStr, KeyType.PrivateKey));
System.out.println("解密后:" + decryptStr);

SM2 签名和验签

//加签
String sign = sm2.signHex(HexUtil.encodeHexStr(text));
System.out.println("签名:" + sign);
//验签
boolean verify = sm2.verifyHex(HexUtil.encodeHexStr(text), sign);
System.out.println("验签:" + verify);

输出结果:

线程池

Hutool 支持使用建造者的模式创建自定义线程池,这样看着更加清晰。

private static ExecutorService pool = ExecutorBuilder.create()
              .setCorePoolSize(10)//初始池大小
              .setMaxPoolSize(20) //最大池大小
              .setWorkQueue(new LinkedBlockingQueue<>(100))//最大等待数为100
              .setThreadFactory(ThreadFactoryBuilder.create().setNamePrefix("IM-Pool-").build())// 线程池命名
              .build();

实际项目中,如果一个对象的属性比较多,有限考虑使用建造者模式创建对象。

并且,Hutool 还提供一个全局的线程池,默认所有异步方法在这个线程池中执行。

  • ThreadUtil.execute
    : 直接在公共线程池中执行线程
  • ThreadUtil.execAsync
    : 执行异步方法
  • ......

Hutool 自身就大量用到了
ThreadUtil
,比如敏感词工具类
SensitiveUtil

public static void init(final Collection<String> sensitiveWords, boolean isAsync){
  if(isAsync){
    // 异步初始化敏感词树
    ThreadUtil.execAsync(new Callable<Boolean>(){
      @Override
      public Boolean call() throws Exception {
        init(sensitiveWords);
        return true;
      }
      
    });
  }else{
    // 同步初始化敏感词树
    init(sensitiveWords);
  }
}

相关地址

最近博客园在商业化上遇到了难题,他们在商业化上努力的三个方向其中一个刚好就是我无意间推动的:

博客园说的三个时间点的事情我其实也都经历了,21年正是我认识园子CEO那一年。

21年3月18号,博客园被迫关闭,直到3月25才恢复(有趣的是,我去X里的时间也是3月18号)

这里官方没有给到明确的对外说明,但是我是知道为啥的,大致就是内容安全这一块有一些历史遗留问题,某些内容没做好审核,在这之后他们进行了内容整改,内容的审核流程也重建了。

22年他们再受重创,这个从公告上看,大概原因是网站重构的容器化有点水土不服+外部的爬虫干扰引发的一系列故障。

我在19年入驻博客园写的文章,之前版本的博客园还不是现在这个UI,对比当时几家大点的开发者社区,特别是商业化方面,园子显得有点格格不入,但是又有那么点技术人的情怀在里面。

21年我刚到阿X云的时候我就以合作方的身份找到了他们,聊了一些简单的合作,当时的业务有点效果,虽然没到那种能帮彼此摆脱困境的地步,但算是认识了。

这次是我在云的朋友提到他们的有块业务,简单给我做了介绍,我就想到了开发者社区,当然我也知道一上来就去找大体量的社区不是很靠谱,所以园子是我第一个想到的,牵线搭桥阴差阳错的撮合了这次合作。

没想到再见面园子的商业化已经到这一步了,刚好我这两年基本上也是在跟开发者打交道,对于开发者社区的运营模式,商业化也探索了一些,所以就刚好给了他们一些新的思路。

我自己也找了一些合作方和对社区的思考,也给到他们了,希望能帮他们渡过难关。

说完和园子的故事,再说回我自己本身,之前大家不理解为啥我转运营,为啥去折腾一些花里胡哨的,其实我觉得这也是答案之一。

人生是旷野,如果当初的我不迈出写代码这条舒适的路,这两年也许我只会从一个写代码还行的人变成写代码还挺久的人,却不可能在这么短的时间内接触到如此多的人和事,学到不同的做事的逻辑和思考的方式。

只是成为一枚大厂程序员、一个技术博主、一个经常鸽的UP(划掉)主,这些对我来说还是不够的,我想要更多的了解这个热烈的世界,我想要去认识这个世界上形形色色的人。

也许这当中有的我永远无法理解,而有的会让我醍醐灌顶。但这些好的坏的,我可以越来越平静的接纳了,因为我站在旷野,四面八方都是前方。

现在的丙还是有很多不足的,这也是我一直没离开职场的原因,我不知道什么时候离开,但希望我离开的时候,我的行囊应该足够充实。

两年前种下的种子,今天结了果,简单随笔,大家也随便看看。