Fork me on GitHub

iOS-SM3加密算法N种集成

近期的一个项目需要用到SM3加密算法,需要在iOS中使用Objective-C实现SM3国密加密算法。

SM3:是中国国家密码管理局发布的密码杂凑算法标准,适用于商用密码应用中的数字签名和验证、消息认证码的生成与验证以及随机数的生成等

由于iOS系统并未内置SM3算法,我们需要使用第三方开源库或自己实现

GMObjC库: 是一个基于 OpenSSL 的国密(SM2、SM3、SM4)算法的 Objective-C 开源库,适用于 iOS 和 macOS 开发。它封装了中国国家密码管理局发布的多种加密算法,包括:

1. SM2: 支持基于椭圆曲线(ECC)的加解密,密钥协商(ECDH)和签名算法

2. SM3: 类似 SHA 系列的国密哈希算法,包含 SM3 和 HMAC 等

3. SM4: 实现对称分组加密算法

GmSSL库:GmSSL是由北京大学自主开发的国产商用密码开源库,实现了对国密算法、标准和安全通信协议的全面功能覆盖,支持包括移动端在内的主流操作系统和处理器,支持密码钥匙、密码卡等典型国产密码硬件,提供功能丰富的命令行工具及多种编译语言编程接口

方案一:使用第三方库(GMObjC)

集成GMObjC:集成GMObjC方法

因为我们的项目是SDK不便用CocoaPods方法,因此我只能选择直接集成和手动编译为 Framework。

1.直接集成 (demo)

1.从 Git 下载最新代码,找到和 README 同级的 GMObjC 文件夹,将 GMObjC 文件夹拖入项目

2.找到和 README 同级的 Frameworks 文件夹,将项目 Frameworks/OpenSSL.xcframework 拖入项目

3.在需要使用的地方导入头文件 GMObjC.h 即可使用 SM2、SM4 加解密,签名验签,计算 SM3 摘要等

注意事项

GMObjC 依赖 OpenSSL,可直接拖入 Frameworks/OpenSSL.xcframework 或通过pod GMOpenSSL安装 OpenSSL。

如果项目中已集成 OpenSSL 1.1.1l 以上版本,可共用同一个 OpenSSL;否则需要使用 Carthage 将 GMObjC 编译为动态库。

我按照以上步骤将文件导入后报错:
OpenSSL.xcframework 签名验证失败

OpenSSL.xcframework报错

终端执行强制重签名命令

codesign –force –deep –sign - 你的路径/OpenSSL.xcframework

返回:你的路径/OpenSSL.xcframework: replacing existing signature

现在就可以运行测试了:

1
2
3
4
5
#import "GMObjC.h"

NSString *str = @"123@1234";
NSString *digest = [GMSm3Utils hashWithText:str];
NSLog(@"%@", digest);

2.手动编译为 Framework (demo)

1.动态库‌:

从 GitHub 下载源码,打开项目GMObjC-master/Frameworks/GMObjC.xcframework把这个拖入项目

在 Xcode 的 General → Frameworks, Libraries, and Embedded Content 中需标记为 Embed & Sign

Embed & Sign

1
2
3
4
#import "GMObjC/GMObjC.h"

NSString *digest1 = [GMSm3Utils hashWithText:str];
NSLog(@"%@", digest1);

2.静态库:

从 GitHub 下载源码,打开项目 GMObjC.xcodeproj,设置 Build Settings - Linking-General - Mach-O Type 为 Static Library

手动编译为静态库 GMObjC.framework

合并为 XCFramework:通过xcodebuild -create-xcframework命令来合并为 XCFramework,通过合并 GMObjC 库的模拟器和真机版本来演示

1
2
3
4
5
6
# 创建合并包 GMObjC.xcframework

xcodebuild -create-xcframework \
-framework Release-iphoneos/GMObjC.framework \
-framework Release-iphonesimulator/GMObjC.framework \
-output GMObjC.xcframework

把生成的GMObjC.xcframework拖入项目即可

3.CocoaPods安装GMObjC (GMObjC-demo) (GMDynamic-demo)

GMObjC 和 GMDynamic 只能安装其中一个,二者不能同时安装。

GMObjC 为静态库,GMDynamic 为编译好的 GMObjC 动态库版本。

1
2
3
4
5
# 安装 GMObjC 的源码和 GMOpenSSL.xcframework (静态库)
pod 'GMObjC', '~> 4.0.3'

# 当 Podfile 中使用 use_frameworks! 时,安装 GMObjC.xcframework (动态库)
pod 'GMDynamic', '~> 4.0.3'

方案二:使用第三方库(GmSSL)(demo)

集成GmSSL:

集成GmSSL方法

但是我用这种方法不行,我用了其他的方法。

我们使用GmSSL 3.x(master分支)来编译iOS的静态库(libcrypto.a和libssl.a)。由于3.x版本采用了CMake构建系统,因此流程与2.x不同。

GmSSL 3.x 的构建系统已经发生了变化,生成的库文件名为 libgmssl.a 而不是传统的 libcrypto.a 和 libssl.a。

如果项目必须使用 libcrypto.alibssl.a,请回退到 GmSSL 2.x

  1. 克隆代码并切换到master分支(或最新的稳定标签)

  2. 配置CMake工具链文件(为iOS交叉编译)

  3. 分别编译arm64(真机)和x86_64(模拟器)架构

  4. 使用lipo合并成通用静态库

  5. 将生成的静态库和头文件集成到iOS项目中。

创建编译脚本: build_ios.sh(放在GmSSL根目录)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#!/bin/bash
set -e

# 确保使用正确的路径
export PATH="/usr/local/bin:$PATH"

# 设置环境变量
export XCODE_PATH=$(xcode-select -p)
export IOS_SDK=$XCODE_PATH/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk
export SIM_SDK=$XCODE_PATH/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk

# 创建输出目录
OUTPUT_DIR="build-ios"
rm -rf $OUTPUT_DIR
mkdir -p $OUTPUT_DIR

# 编译函数
compile_arch() {
ARCH=$1
SDK=$2

BUILD_DIR="${OUTPUT_DIR}/${ARCH}"
mkdir -p $BUILD_DIR
pushd $BUILD_DIR > /dev/null

echo "▸ 配置 $ARCH..."
cmake ../.. \
-DCMAKE_SYSTEM_NAME=iOS \
-DCMAKE_OSX_ARCHITECTURES=$ARCH \
-DCMAKE_OSX_SYSROOT=$SDK \
-DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DENABLE_SM2=ON \
-DENABLE_SM3=ON \
-DENABLE_SM4=ON \
-DENABLE_SM9=ON \
-G Ninja

echo "▸ 编译 $ARCH..."
ninja

# 关键修改:GmSSL 3.x 生成的库是 libgmssl.a
mkdir -p lib
cp bin/libgmssl.a lib/

popd > /dev/null
}

# 编译各架构
compile_arch "arm64" "$IOS_SDK"
compile_arch "x86_64" "$SIM_SDK"

# 合并通用库
UNIVERSAL_DIR="${OUTPUT_DIR}/universal"
mkdir -p $UNIVERSAL_DIR/lib

# 合并为单个库 (GmSSL 3.x 只生成一个库)
lipo -create \
"${OUTPUT_DIR}/arm64/lib/libgmssl.a" \
"${OUTPUT_DIR}/x86_64/lib/libgmssl.a" \
-output "$UNIVERSAL_DIR/lib/libgmssl.a"

# 复制头文件
if [ -d "${OUTPUT_DIR}/arm64/include" ]; then
cp -R "${OUTPUT_DIR}/arm64/include" "$UNIVERSAL_DIR/"
elif [ -d "../../include" ]; then
cp -R "../../include" "$UNIVERSAL_DIR/"
else
echo "⚠️ 警告: 找不到头文件目录"
fi

echo "✅ 编译成功!"
echo "库文件位置: $UNIVERSAL_DIR/lib/libgmssl.a"
echo "头文件位置: $UNIVERSAL_DIR/include"

# 验证文件
file "$UNIVERSAL_DIR/lib"/*.a
lipo -info "$UNIVERSAL_DIR/lib/libgmssl.a"

然后按照以下步骤进行执行:

1
2
3
4
5
6
7
8
9
10
11
12
# 安装构建工具
brew install cmake ninja pkg-config

# 获取最新代码
git clone https://github.com/guanzhi/GmSSL.git
cd GmSSL
git checkout master # 确保使用最新版本
git pull

# 2. 执行编译
chmod +x build_ios.sh
./build_ios.sh
  1. 将GmSSL/build-ios/universal/lib/libgmssl.a 拖入项目

  2. 将GmSSL/include/gmssl 拖入项目

  3. import “sm3.h”

封装方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@interface GmSSLEncryptorSM3 : NSObject

+ (NSString *)sm3HashWithString:(NSString *)input;
+ (NSData *)sm3HashWithData:(NSData *)data;

@end

@implementation GmSSLEncryptorSM3

+ (instancetype)encryptor {
return [[GmSSLEncryptorSM3 alloc] init];
}

+ (NSData *)sm3HashWithData:(NSData *)data {
// 初始化 SM3 上下文
SM3_CTX ctx;
sm3_init(&ctx);
// 添加数据到哈希计算
sm3_update(&ctx, data.bytes, data.length);
// 准备存储结果的缓冲区 (SM3 输出为 32 字节)
uint8_t dgst[SM3_DIGEST_SIZE];
// 完成哈希计算
sm3_finish(&ctx, dgst);
// 转换为 NSData
return [NSData dataWithBytes:dgst length:SM3_DIGEST_SIZE];
}

+ (NSString *)sm3HashWithString:(NSString *)input {
NSData *inputData = [input dataUsingEncoding:NSUTF8StringEncoding];
// 计算 SM3 哈希
NSData *hashData = [GmSSLEncryptorSM3 sm3HashWithData:inputData];
// 转换为十六进制字符串显示
NSMutableString *hexString = [NSMutableString string];
const uint8_t *bytes = (const uint8_t *)hashData.bytes;
for (NSUInteger i = 0; i < hashData.length; i++) {
[hexString appendFormat:@"%02x", bytes[i]];
}
return hexString;
}
@end

就可以在项目中使用了:

1
2
NSString *encryptor = [GmSSLEncryptorSM3 sm3HashWithString:str];
NSLog(@"%@", encryptor);

方案三:纯 Objective-C 实现(无依赖)(demo)

SM3本质上不是加密算法,它是是一种杂凑函数,是在[SHA-256]基础上改进实现的一种算法,它不是对数据进行加密然后再解密,而是生成一个256位的散列值,因此SM3适用于内容摘要,数字签名验证或密码验证等。

SM3算法的执行过程:

根据SM3标准文档(GM/T 0004-2012)

sm3流程.png

消息扩展:将16个32位字扩展为68个字(W)和64个字(W1),使用P1宏。
压缩函数:64轮迭代更新寄存器(A-H),每轮使用FF1/GG1等宏。
常量:压缩函数中的常量0x7A879D8A(TJ的固定值)。
结果输出:将最终状态寄存器转换为大端序字节流(256位)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
//
// SM3Encryptor.m
// testDemo
//
// Created by wt on 2025/6/12.
//

#import "SM3Encryptor.h"
#include <stdint.h>

// SM3 上下文结构
typedef struct {
uint32_t state[8]; // 8个32位寄存器(A-H)
uint64_t totalLength; // 总消息长度(位)
uint8_t buffer[64]; // 当前数据块缓存
uint32_t bufferLength; // 当前缓冲区长度
} SM3Context;

// 循环左移
static inline uint32_t ROTL(uint32_t x, uint8_t n) {
return (x << n) | (x >> (32 - n));
}

// 布尔函数 FF0(0≤j≤15)
static inline uint32_t FF0(uint32_t x, uint32_t y, uint32_t z) {
return x ^ y ^ z;
}

// 布尔函数 FF1(16≤j≤63)
static inline uint32_t FF1(uint32_t x, uint32_t y, uint32_t z) {
return (x & y) | (x & z) | (y & z);
}

// 布尔函数 GG0(0≤j≤15)
static inline uint32_t GG0(uint32_t x, uint32_t y, uint32_t z) {
return x ^ y ^ z;
}

// 布尔函数 GG1(16≤j≤63)
static inline uint32_t GG1(uint32_t x, uint32_t y, uint32_t z) {
return (x & y) | ((~x) & z);
}

// 置换函数 P0
static inline uint32_t P0(uint32_t x) {
return x ^ ROTL(x, 9) ^ ROTL(x, 17);
}

// 置换函数 P1
static inline uint32_t P1(uint32_t x) {
return x ^ ROTL(x, 15) ^ ROTL(x, 23);
}

// 初始化SM3上下文
void SM3Init(SM3Context *context) {
// SM3标准初始值
context->state[0] = 0x7380166F;
context->state[1] = 0x4914B2B9;
context->state[2] = 0x172442D7;
context->state[3] = 0xDA8A0600;
context->state[4] = 0xA96F30BC;
context->state[5] = 0x163138AA;
context->state[6] = 0xE38DEE4D;
context->state[7] = 0xB0FB0E4E;
context->totalLength = 0;
context->bufferLength = 0;
memset(context->buffer, 0, 64);
}

// 处理单个64字节块(压缩函数核心)
void SM3Compress(SM3Context *context, const uint8_t block[64]) {
// 1. 消息扩展:16字 → 68字(W) + 64字(W1)
uint32_t W[68], W1[64];

// 初始化前16字(大端序转换)
for (int i = 0; i < 16; i++) {
W[i] = (uint32_t)block[i*4] << 24 |
(uint32_t)block[i*4+1] << 16 |
(uint32_t)block[i*4+2] << 8 |
(uint32_t)block[i*4+3];
}

// 计算W[16]-W[67]
for (int j = 16; j < 68; j++) {
uint32_t temp = W[j-16] ^ W[j-9] ^ ROTL(W[j-3], 15);
W[j] = P1(temp) ^ ROTL(W[j-13], 7) ^ W[j-6];
}

// 计算W1[0]-W1[63]
for (int j = 0; j < 64; j++) {
W1[j] = W[j] ^ W[j+4];
}

// 2. 寄存器初始化(A-H)
uint32_t A = context->state[0];
uint32_t B = context->state[1];
uint32_t C = context->state[2];
uint32_t D = context->state[3];
uint32_t E = context->state[4];
uint32_t F = context->state[5];
uint32_t G = context->state[6];
uint32_t H = context->state[7];

// 3. 64轮迭代(严格遵循标准)
for (int j = 0; j < 64; j++) {
uint32_t SS1, SS2, TT1, TT2;

// 常量选择(关键修正)
uint32_t TJ = (j < 16) ? 0x79CC4519 : 0x7A879D8A;

// 计算SS1/SS2(修正了TJ参数)
SS1 = ROTL(ROTL(A, 12) + E + ROTL(TJ, j % 32), 7);
SS2 = SS1 ^ ROTL(A, 12);

// 计算TT1/TT2(使用内联函数)
if (j < 16) {
TT1 = FF0(A, B, C) + D + SS2 + W1[j];
TT2 = GG0(E, F, G) + H + SS1 + W[j];
} else {
TT1 = FF1(A, B, C) + D + SS2 + W1[j];
TT2 = GG1(E, F, G) + H + SS1 + W[j];
}

// 更新寄存器(严格顺序)
D = C;
C = ROTL(B, 9);
B = A;
A = TT1;
H = G;
G = ROTL(F, 19);
F = E;
E = P0(TT2);
}

// 4. 更新最终状态(与初始IV异或)
context->state[0] ^= A;
context->state[1] ^= B;
context->state[2] ^= C;
context->state[3] ^= D;
context->state[4] ^= E;
context->state[5] ^= F;
context->state[6] ^= G;
context->state[7] ^= H;
}

// 更新数据(可分多次调用)
void SM3Update(SM3Context *context, const uint8_t *data, size_t length) {
context->totalLength += length * 8; // 更新总位数(字节转位)

// 处理缓冲区中的剩余空间
if (context->bufferLength > 0) {
size_t copySize = MIN(64 - context->bufferLength, length);
memcpy(context->buffer + context->bufferLength, data, copySize);
context->bufferLength += copySize;
data += copySize;
length -= copySize;

if (context->bufferLength == 64) {
SM3Compress(context, context->buffer);
context->bufferLength = 0;
}
}

// 处理完整块
while (length >= 64) {
SM3Compress(context, data);
data += 64;
length -= 64;
}

// 缓存剩余数据
if (length > 0) {
memcpy(context->buffer, data, length);
context->bufferLength = length;
}
}

// 完成哈希计算
void SM3Final(SM3Context *context, uint8_t output[32]) {
// 计算填充长度(SM3标准:补位1 + k个0 + 64位长度)
size_t totalBits = context->totalLength;
size_t paddingBits = (context->bufferLength < 56) ?
(56 - context->bufferLength) :
(120 - context->bufferLength);

// 构建填充数据
uint8_t padding[128] = {0};
padding[0] = 0x80; // 补位起始位(二进制10000000)

// 添加填充
SM3Update(context, padding, paddingBits);

// 添加消息长度(大端序64位)
uint64_t bitCount = CFSwapInt64HostToBig(totalBits);
SM3Update(context, (uint8_t *)&bitCount, 8);

// 确保最后一个块被处理
if (context->bufferLength > 0) {
memset(context->buffer + context->bufferLength, 0, 64 - context->bufferLength);
SM3Compress(context, context->buffer);
}

// 输出最终哈希(256位,大端序)
for (int i = 0; i < 8; i++) {
output[i*4] = (uint8_t)(context->state[i] >> 24);
output[i*4 + 1] = (uint8_t)(context->state[i] >> 16);
output[i*4 + 2] = (uint8_t)(context->state[i] >> 8);
output[i*4 + 3] = (uint8_t)(context->state[i]);
}
}

// Objective-C 封装接口
@implementation SM3Encryptor

+ (NSData *)hashWithData:(NSData *)inputData {
SM3Context context;
SM3Init(&context);

// 处理输入数据
SM3Update(&context, inputData.bytes, inputData.length);

// 获取结果
uint8_t output[32];
SM3Final(&context, output);

return [NSData dataWithBytes:output length:32];
}

+ (NSString *)hexStringWithData:(NSData *)inputData {
NSData *hashData = [self hashWithData:inputData];
const uint8_t *bytes = (const uint8_t *)hashData.bytes;
NSMutableString *hex = [NSMutableString string];

for (NSUInteger i = 0; i < hashData.length; i++) {
[hex appendFormat:@"%02X", bytes[i]];
}

return [hex copy];
}

+ (NSString *)hexStringWithInput:(NSString *)inputStr {
NSData *inputData = [inputStr dataUsingEncoding:NSUTF8StringEncoding];
NSData *hashData = [self hashWithData:inputData];
const uint8_t *bytes = (const uint8_t *)hashData.bytes;
NSMutableString *hex = [NSMutableString string];

for (NSUInteger i = 0; i < hashData.length; i++) {
[hex appendFormat:@"%02X", bytes[i]];
}

return [hex copy];
}


@end

gitHub hexo 个人博客升级版

9年前自己开始学习gitHub hexo搭建个人博客,查了很多资料,最后用hexo 搭建一个个人博客,托管在gitHub上,前段时间换了一个电脑,我在新的电脑上想再发布一篇文章,才发现不行了。因为之前只在GitHub托管了hexo生成的静态文件(public),忘记备份Hexo的源文件。
source/_posts/(所有文章)
_config.yml(Hexo 主配置)
themes/(主题文件)
package.json(依赖列表)
如果你遇到这种情况,跟着我进入接下来的重新部署过程。

gitHub hexo 个人博客基础版

首先确认你本地已经不存在source/_posts文件夹,再查一查github是否有备份,如何远程和本地都没有,那就只能重新部署了。

1.静态文件(如 public/ 下的 HTML)可通过浏览器右键「查看页面源代码」复制正文,或使用工具解析 HTML 结构。

2.使用 Markdown 渲染,HTML 中的 “article ”标签内通常包含原始文本的转换结果。

3.生成.md文件放在_posts文件夹里面。

1.备份Hexo的源文件

到这里就可以重新发布文章了,但是为了下一次不要再出现这种情况,我们需要对Hexo的源文件进行备份。

备份方式共有两种:

1.在当前gitHub管理的hexo生成的静态文件仓库中再开一个分支,用于备份Hexo的源文件。

2.单独创建一个私有仓库用于备份Hexo的源文件。

因为博客需要对外展示,所以当前gitHub管理的hexo生成的静态文件仓库必须是公开的,所以如果你选择第一种方式你的原文件也只能放在公开的仓库分支。如果不想将自己的Hexo的源文件公开就可以选择第二种方式:单独创建一个私有的仓库用来备份Hexo的源文件。

2.自动化

上文创建完成后每次发布博客,都需要去git提交备份文件,这样太麻烦了。我们可以创建一个自动化脚本,将这些重复步骤自动化。

创建一个deploy.sh放在博客根目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 设置绝对路径
BLOG_DIR="实际路径/blog"

# 进入博客目录
cd "$BLOG_DIR" || exit 1

# Hexo操作
hexo clean
hexo generate
hexo deploy

# 源文件备份
git add .
git commit -m "自动备份: $(date +"%Y-%m-%d %H:%M")"
git push origin main

这样一个简单的发布博客并且源文件备份的自动化脚本就好了。
以后每一次写完文章后再在终端进入”实际路径/blog” 执行./deploy.sh 就行了

执行./deploy.sh

首次执行需要先设置执行权限:

1
chmod +x deploy.sh

3.多博客自动化

当我们有多个博客需要同步是就需要对Hexo的源文件进行再次修改。

我以github为例:

1.首先我们的有两个GitHub账号并且本地配置好SSH

本地SSH配置

2.添加配置文件

_config.yml:为其中一个GitHub账号的配置

url: https://gavincarter1991.github.io# 博客完整URL

source_dir: source # 这个值会被脚本覆盖,但需要存在

public_dir: public_kind

deploy

_config_other.yml:为另外一个GitHub账号的配置

url: https://kindyourself.github.io # 博客完整URL

source_dir: /Users/dianyin/Desktop/blog/temp_source_gavin

public_dir: public_gavin

deploy

考虑到多个博客内容可能不一样:在根目录新建一个scripts文件夹创建一个JS脚本multi-site.js进行内容配置,公共文章放在source/_posts,不同的文章各自放在自己的目录里面(source/_posts_gavin source/_posts_gavin _posts_kind)脚本会分开发布。

multi-site.js 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
const fs = require('fs-extra');
const path = require('path');
const { promisify } = require('util');
const exec = promisify(require('child_process').exec);

hexo.extend.filter.register('before_generate', async function() {
const baseDir = hexo.base_dir;
const currentSite = process.env.HEXO_SITE || 'kind';

const sites = {
kind: {
exclusiveDir: 'source/_posts_kind',
commonDir: 'source/_posts',
output: 'public_kind',
config: '_config.yml'
},
gavin: {
exclusiveDir: 'source/_posts_gavin',
commonDir: 'source/_posts',
output: 'public_gavin',
config: '_config_gavin.yml'
}
};

const site = sites[currentSite];
if (!site) return;

// 创建临时源目录
const tempSourceDir = path.join(baseDir, `temp_source_${currentSite}`);
fs.ensureDirSync(tempSourceDir);
fs.emptyDirSync(tempSourceDir);

// 1. 复制整个source目录(包括所有共享文件)
fs.copySync(path.join(baseDir, 'source'), tempSourceDir);

// 2. 清空临时源目录的_posts文件夹
const tempPostsDir = path.join(tempSourceDir, '_posts');
fs.ensureDirSync(tempPostsDir);
fs.emptyDirSync(tempPostsDir);

// 3. 复制共同文章
const commonPosts = path.join(baseDir, site.commonDir);
if (fs.existsSync(commonPosts)) {
fs.readdirSync(commonPosts).forEach(file => {
if (file.endsWith('.md')) {
fs.copySync(
path.join(commonPosts, file),
path.join(tempPostsDir, file)
);
}
});
}

// 4. 复制专属文章
const exclusivePosts = path.join(baseDir, site.exclusiveDir);
if (fs.existsSync(exclusivePosts)) {
fs.readdirSync(exclusivePosts).forEach(file => {
if (file.endsWith('.md')) {
fs.copySync(
path.join(exclusivePosts, file),
path.join(tempPostsDir, file)
);
}
});
}

// 5. 动态应用配置
hexo.config.source_dir = tempSourceDir;
hexo.config.public_dir = site.output;

// 6. 验证复制结果
const postCount = fs.readdirSync(tempPostsDir).length;
hexo.log.info(`✅ ${currentSite} 站点准备完成: ${postCount} 篇文章`);
});

// 添加部署后清理钩子
hexo.extend.filter.register('after_deploy', function() {
const baseDir = hexo.base_dir;
const currentSite = process.env.HEXO_SITE;

if (currentSite) {
const tempSourceDir = path.join(baseDir, `temp_source_${currentSite}`);
if (fs.existsSync(tempSourceDir)) {
fs.removeSync(tempSourceDir);
hexo.log.info(`🧹 清理临时目录: ${tempSourceDir}`);
}
}
});

当然deploy.sh也需要更改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#!/usr/bin/env bash

# 配置区
BLOG_DIR="/Users/dianyin/Desktop/blog"
SOURCE_BRANCH="master"
PRIVATE_REPO_KIND="git@github.com-kind:kindyourself/blog-source.git"
PRIVATE_REPO_GAVIN="git@github.com-gavin:gavincarter1991/blog-source.git"

cd "$BLOG_DIR" || { echo "❌ 无法进入博客目录"; exit 1; }

# 清理上次生成的临时文件
rm -rf temp_source_* public*
hexo clean

# 生成 Kind 站点
echo "===== 🚀 生成 Kind 站点 ====="
export HEXO_SITE="kind"
hexo generate --config _config.yml

# 验证生成结果
echo "Kind 站点内容:"
ls -lh public_kind
tree -L 2 public_kind | head -20

# 生成 Gavin 站点
echo "===== 🚀 生成 Gavin 站点 ====="
export HEXO_SITE="gavin"
hexo generate --config _config_gavin.yml

# 验证生成结果
echo "Gavin 站点内容:"
ls -lh public_gavin
tree -L 2 public_gavin | head -20

# 部署到GitHub Pages
echo "===== 🚀 部署到 GitHub Pages ====="
echo "部署 Kind 站点..."
export HEXO_SITE="kind"
hexo deploy --config _config.yml

echo "部署 Gavin 站点..."
export HEXO_SITE="gavin"
hexo deploy --config _config_gavin.yml

# 备份到私有仓库
echo "===== 📦 备份源文件到私有仓库 ====="
git add .
if git diff-index --quiet HEAD --; then
echo "🔄 无文件变更"
else
git commit -m "自动备份: $(date +"%Y-%m-%d %H:%M:%S")"

# 推送到两个私有仓库
git push "$PRIVATE_REPO_KIND" "$SOURCE_BRANCH" && \
echo "✅ kindyourself/blog-source 备份成功" || \
echo "❌ kindyourself/blog-source 备份失败"

git push "$PRIVATE_REPO_GAVIN" "$SOURCE_BRANCH" && \
echo "✅ gavincarter1991/blog-source 备份成功" || \
echo "❌ gavincarter1991/blog-source 备份失败"
fi

echo "===== ✅ 部署完成 ====="
echo "博客地址1: https://kindyourself.github.io"
echo "博客地址2: https://gavincarter1991.github.io"

以上就是gitHub hexo 个人博客升级版

Widget进阶

1.Widget 介绍

Everything is a widget 这是你学习 flutter 会听到的最多的一句话。因为在 Flutter 中几乎所有的对象都是一个 widget,在 flutter 中 UI 的构建和事件的处理基本都是通过 widget 的组合及嵌套来完成的。在 iOS 中我们经常提及的“组件”、“控件”在 flutter 中就是 widget,当然 widget 的范围比之更加广泛。如:手势检测 GestureDetector、主题 Theme 和动画容器 AnimatedContainer 等也是 widget。

Flutter 默认支持的两种设计风格:

1.Material components Design: 谷歌(android)的 UI 风格,主要为 Android 设计,但也支持跨平台使用。

2.Cupertino Design: 苹果(iOS)的 UI 风格,模仿苹果原生 UIKit 风格。高度还原 iOS 原生体验,适合需要与苹果生态一致的应用。

2.Widget 分类

1.按状态管理

一、StatelessWidget:

无状态组件,通过 build 方法返回静态 UI。不可变,属性(final)在创建后无法修改,适用于不需要内部状态变化的场景(如文本显示、图标),不依赖用户交互或数据变化的 UI 部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class IconTextButton extends StatelessWidget {
final String iconName;
final String label;
final VoidCallback onPressed;

const IconTextButton({
super.key,
required this.iconName,
required this.label,
required this.onPressed,
});

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image.asset(
"assets/images/$iconName.png",
width: 40,
height: 40,
),
const SizedBox(
height: 10,
), // 图标
Text(
label,
style: const TextStyle(color: ColorConstant.color33, fontSize: 10),
), // 文字
],
),
);
}
}

二、StatefulWidget:

有状态组件,通过 State 对象管理动态数据。当状态变化时调用 setState 触发 UI 更新,需要用户交互(如按钮点击、表单输入)和依赖实时数据变化(如计数器、动态列表)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// 上下滚动的消息轮播
class MarqueeWidget extends StatefulWidget {
/// 子视图数量
final int count;

///子视图构建器
final IndexedWidgetBuilder itemBuilder;

///轮播的时间间隔
final int loopSeconds;

const MarqueeWidget({
super.key,
required this.count,
required this.itemBuilder,
this.loopSeconds = 5,
});

@override
_MarqueeWidgetState createState() => _MarqueeWidgetState();
}

class _MarqueeWidgetState extends State<MarqueeWidget> {
late PageController _controller;
late Timer _timer;

@override
void initState() {
super.initState();
_controller = PageController();
_timer = Timer.periodic(Duration(seconds: widget.loopSeconds), (timer) {
if (_controller.page != null) {
// 如果当前位于最后一页,则直接跳转到第一页,两者内容相同,跳转时视觉上无感知
if (_controller.page!.round() >= widget.count) {
_controller.jumpToPage(0);
}
_controller.nextPage(
duration: const Duration(seconds: 1), curve: Curves.linear);
}
});
}

@override
Widget build(BuildContext context) {
return PageView.builder(
scrollDirection: Axis.vertical,
controller: _controller,
itemBuilder: (buildContext, index) {
if (index < widget.count) {
return widget.itemBuilder(buildContext, index);
} else {
return widget.itemBuilder(buildContext, 0);
}
},
itemCount: widget.count + 1,
);
}

@override
void dispose() {
super.dispose();
_controller.dispose();
_timer.cancel();
}
}

2.按功能分类

1.布局类 Widget: 控制子 Widget 的排列方式。

1
2
3
4
5
6
常见有:
Row/Column:水平/垂直排列子项(基于 Flexbox)。
Stack:子 Widget 堆叠(类似 CSS 的绝对定位)。
Expanded/Flexible:在 Row 或 Column 中分配剩余空间。
Container:结合布局、装饰、边距等功能

2.基础组件 Widget: 构成 UI 的基本元素。

1
2
3
4
5
常见有:
Text:显示文本。
Image:加载本地或网络图片。
Icon:显示图标(需引入 cupertino_icons 或自定义图标库)

3.滚动类 Widget: 处理内容超出屏幕时的滚动行为。

1
2
3
4
5
常见有:
ListView:垂直/水平滚动列表。
GridView:网格布局滚动视图。
SingleChildScrollView:包裹单个可滚动子组件。

4.交互类 Widget: 响应用户输入事件。

1
2
3
4
5
6
常见有:
ElevatedButton/TextButton:按钮交互。
TextField:文本输入框。
Checkbox/Switch:选择控件。
GestureDetector:自定义手势检测(点击、长按、拖动)。

5.平台风格类 Widget: 适配不同操作系统的视觉风格。

1
2
3
4
常见有:
Material Design:MaterialApp、AppBar、FloatingActionButton。
Cupertino(iOS 风格):CupertinoApp、CupertinoNavigationBar、CupertinoPicker。

6.动画类 Widget: 实现动态视觉效果。

1
2
3
4
5
常见有:
AnimatedContainer:自动过渡的容器(大小、颜色等属性变化)。
Hero:页面切换共享元素的过渡动画。
AnimatedBuilder:自定义复杂动画。

7. 导航与路由类 Widget: 管理页面跳转和导航结构。

1
2
3
4
5
常见有:
Navigator:管理页面堆栈(push/pop)。
PageView:实现滑动切换页面。
BottomNavigationBar:底部导航栏。

通过简单 Widget 组合实现复杂 UI(例如用 Row + Expanded 替代自定义布局)(优先组合而非继承)
局部状态使用 StatefulWidget
全局状态使用状态管理工具(如 Provider、Riverpod)
对频繁更新的部分使用 const 构造函数
长列表使用 ListView.builder 懒加载

3.Widget 生命周期

StatelessWidget 的生命周期

StatelessWidget 仅有一个 build() 方法,无状态管理逻辑,其生命周期完全由父组件控制。

StatefulWidget 主要生命周期方法

创建阶段
createState()

初始化阶段
initState()
didChangeDependencies()

更新阶段
didUpdateWidget(oldWidget)
build()

销毁阶段
deactivate()
dispose()

2025-05-22 18.38.22.png

1.createState()
当 StatefulWidget 被插入 Widget 树时调用,而且只执行一次。

主要用于创建与之关联的 State 对象(每个 Widget 对应一个 State 实例)。

1
2
3
4
5
class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}

2.initState()
在 State 对象创建后,首次调用 build() 之前触发该方法,而且只执行一次。

主要用于初始化依赖数据(如订阅事件、加载本地配置)和 创建动画控制器(AnimationController)等需与 dispose() 配对的资源。

1
2
3
4
5
6
7
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_fetchData(); // 初始化数据
}

需要注意的是:
必须调用 super.initState()。
在这里 View 并没有渲染,只是 StatefulWidget 被加载到渲染树里了。
避免在此处触发 setState(可能导致渲染未完成)。
StatefulWidget的 mount 的值变为了true(调用dispose()才会变为 false)。

3.didChangeDependencies()
initState() 后立即调用 didChangeDependencies()。
当 State 依赖的 InheritedWidget 发生变化时(如主题、本地化)也会调用 didChangeDependencies()。

主要用于处理依赖变化后的逻辑(如重新请求网络数据)。

1
2
3
4
5
6
7
8
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (Provider.of<Data>(context).hasChanged) {
_updateData();
}
}

4. didUpdateWidget(oldWidget)
在父组件重建时,若新旧 Widget 的 runtimeType 和 key 相同触发 didUpdateWidget(didUpdateWidget 我们一般不会用到)。

主要是:
对比新旧 Widget 的配置(如属性变化)。
根据变化调整状态(如重置动画、更新监听)。

1
2
3
4
5
6
7
8
@override
void didUpdateWidget(MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.color != widget.color) {
_updateColor(); // 颜色变化时执行逻辑
}
}

5. build()
初始化后、依赖变化后、setState() 调用后调用 build()。
父组件或祖先组件触发重建时调用 build()。

主要是根据当前状态构建 UI(不要在这里做除了创建 Widget 之外的操作)

1
2
3
4
5
6
7
8
@override
Widget build(BuildContext context) {
return Container(
color: widget.color,
child: Text('Count: $_count'),
);
}

需要注意的是:
必须返回一个 Widget
避免在此处修改状态或执行耗时操作

6. deactivate()
当 State 从树中暂时移除(如页面切换、组件被移除)触发 deactivate()。

清理临时资源或保存临时状态.

需要注意的是:
可能被重新插入树中(如页面返回时),需与 dispose() 区分

7. dispose()
State 被永久移除时调用 dispose()。

释放资源(如取消网络请求、销毁动画控制器)

1
2
3
4
5
6
7
@override
void dispose() {
_controller.dispose(); // 销毁动画控制器
_subscription.cancel(); // 取消事件订阅
super.dispose();
}

需要注意的是:
如果在 dispose() 中未释放资源(如动画控制器、Stream 订阅)可能造成内存泄漏
如果在 dispose() 后调用 setState 会导致异常

4.Widget 的渲染

渲染流程:
Flutter 的渲染系统基于三棵核心树结构,通过高度优化的管线(Pipeline)实现高效的 UI 更新。

Widget 重建 → Diff 新旧 Widget 树 → 更新 Element 树 → 更新 RenderObject 树 → 触发 Layer 合成 → 屏幕刷新

1.Widget 树的构建:

描述 UI 的不可变配置,由开发者创建,频繁重建,需轻量化。
开发者编写的 Widget 代码被转化为嵌套的 Widget 树(应用的入口是根 Widget,一般是 MaterialApp 或 CupertinoApp。根 Widget 会递归地构建其子 Widget,形成一棵树。)。
具有不可变性,每次重建生成全新的 Widget 树,但通过 Diff 算法可以优化实际更新范围。

2. Element 树的 Diff 与更新

根据 Widget 树生成一个 Element 树,Element 树中的节点都继承自 Element 类。
Element 是 Widget 的实例化对象,负责管理 状态(State) 和 子节点引用。
每个 Widget 都会有一个对应的 Element 对象,用于管理其生命周期。

Diff 算法:Flutter 对比新旧 Widget 树,仅更新变化的 Element 和 RenderObject,类似 React 的虚拟 DOM。
当 Widget 树重建时,Flutter 通过 Diff 算法 对比新旧 Widget 树,决定 Element 树的更新策略
Reuse:若新旧 Widget 的 runtimeType 和 key 相同,复用现有 Element。
Update:更新 Element 的配置(调用 Element.update(newWidget))。
Replace:类型或 Key 不同时,销毁旧 Element,创建新 Element。

1
2
3
4
5
6
7
8
// 旧 Widget 树
Container(color: Colors.red)

// 新 Widget 树
Container(color: Colors.blue)

// Diff 结果:Container 类型相同且无 Key → 复用 Element,更新 RenderObject 颜色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Element 更新逻辑
Element.updateChild()

Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
if (newWidget == null) {
// 移除子节点
return null;
}
if (child != null) {
if (child.widget == newWidget) {
// Widget 未变化 → 复用 Element
return child;
}
if (Widget.canUpdate(child.widget, newWidget)) {
// 更新 Element 配置
child.update(newWidget);
return child;
}
// 销毁旧 Element,创建新 Element
deactivateChild(child);
}
return inflateWidget(newWidget, newSlot);
}

3. RenderObject 树的更新

更新 RenderObject 树,计算布局和生成绘制指令。
运行在 UI Thread。

根据 Element 树生成 Render 树(渲染树),渲染树中的节点都继承自 RenderObject 类。
每个 Element 对应一个 RenderObject(通过 Element.createRenderObject() 创建)。

根据父 RenderObject 传递的 约束(Constraints),计算自身尺寸和位置。
递归调用子节点的 layout() 方法(深度优先遍历)。

布局(Layout)核心方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// RenderObject 布局流程
RenderObject.layout()

void layout(Constraints constraints, { bool parentUsesSize = false }) {
_constraints = constraints;
if (_relayoutBoundary != this) {
markNeedsLayout();
return;
}
performLayout(); // 1. 计算自身尺寸(调用 performLayout) 由子类实现具体布局逻辑
_needsLayout = false;
markNeedsPaint(); // 标记需要重绘
}

生成绘制指令(如形状、颜色、文本),写入 Layer(合成层)。

绘制(Paint)核心方法:

1
2
3
4
5
void paint(PaintingContext context, Offset offset) {
// 绘制逻辑,如画矩形
context.canvas.drawRect(rect, paint);
}

4. 合成与光栅化(Composition & Rasterization)

生成 Layer 树并光栅化。
运行在 Raster Thread(与 UI Thread 并行)

根据渲染树生成 Layer 树,然后上屏显示,Layer 树中的节点都继承自 Layer 类。
RenderObject 的绘制结果被组织为 Layer 树,每个 Layer 对应一个 GPU 纹理(Texture)。自此 Layer 树生成。
类型包括:PictureLayer(矢量绘制)、TextureLayer(图像纹理)、TransformLayer(变换效果)等。

将 Layer 树中的绘制指令转换为 GPU 可识别的位图数据。
通过 Skia 图形库(或 Impeller)完成,最终提交给 GPU 渲染。(完成光栅化(Raster Thread))。

1
2
3
4
5
6
7
8
9
10
11
void paintChild(RenderObject child, Offset offset) {
if (child.isRepaintBoundary) {
// 创建独立 Layer
stopRecordingIfNeeded();
child._layer = OffsetLayer();
appendLayer(child._layer);
} else {
child._paintWithContext(this, offset);
}
}

5. GPU 渲染与屏幕刷新

垂直同步(VSync):
由系统定时触发的信号,控制帧率(如 60Hz → 16.6ms/帧)。
Flutter 引擎在 VSync 信号到来时,提交光栅化后的帧数据到 GPU。

屏幕显示:
GPU 将帧数据写入帧缓冲区(Frame Buffer),屏幕硬件按刷新率读取并显示。

5.Widget 优化

高性能渲染 = 最小化 Widget Diff + 高效布局/绘制 + GPU 线程优化

Flutter 优化的本质是 减少无效计算 和 降低 GPU 负载
一般围绕四个方向: 1.最小化 Widget 树 Diff 范围 2.减少布局(Layout)和绘制(Paint)计算 3.优化 GPU 合成与光栅化(Rasterization) 4.高效管理状态与资源

性能分析工具
Flutter DevTools:
Performance 面板:分析 UI/Raster 线程的帧耗时。
Layer 查看器:检测 Layer 合成是否合理。
debugProfileBuildsEnabled:追踪 Widget 构建耗时
调试标记:
debugPrintMarkNeedsLayoutStacks:打印触发布局的堆栈信息。
debugPaintLayerBordersEnabled:可视化 Layer 边界。

1.Widget 树 Diff 优化

Diff 算法机制: 当父组件更新时,Flutter 递归对比新旧 Widget 树,判断是否需要更新 Element 和 RenderObject。

1
2
3
4
5
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}

复用条件: runtimeType 和 key 相同 → 复用 Element,仅更新配置。
替换条件: 类型或 Key 不同 → 销毁旧 Element,创建新 Element。

优化策略:
1.使用 const 构造函数: const Widget 在多次重建中引用同一内存地址,Widget.canUpdate 直接返回 true,跳过 Diff 计算。

1
2
3
const MyWidget(text: 'Hello'); // ✅ 优化
MyWidget(text: 'Hello'); // ❌ 非 const

2.合理使用 Key: ValueKey:在列表项中标识唯一性,避免错误复用导致状态混乱。
GlobalKey:跨组件访问状态(谨慎使用,破坏局部性)。

1
2
3
4
5
6
7
ListView.builder(
itemBuilder: (_, index) => ItemWidget(
key: ValueKey(items[index].id), // 唯一标识
data: items[index],
),
)

3.拆分细粒度 Widget: 将频繁变化的部分拆分为独立 Widget,缩小 setState 触发的 Diff 范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 父组件(仅传递静态数据)
class ParentWidget extends StatelessWidget {
const ParentWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
const StaticHeader(), // 静态部分
DynamicContent(data: _data), // 动态部分
],
);
}
}

2.布局(Layout)阶段优化

布局计算机制: 当某个 RenderObject 的尺寸变化不影响父节点布局时,可标记为布局边界,阻断布局计算向上传播。通过 RenderObject.isRepaintBoundary = true 设置(布局边界(Relayout Boundary))

父节点传递 约束(Constraints) 给子节点
子节点根据约束计算自身尺寸,并递归布局子节点(布局过程)

优化策略

1.避免过度嵌套: 多层 Row/Column 会导致布局计算复杂度呈指数增长。
我们可以使用 Flex、Wrap 或自定义布局逻辑替代嵌套。

2.预计算尺寸: 通过固定尺寸(SizedBox)或 LayoutBuilder 提前确定布局约束,减少计算量。

1
2
3
4
5
6
SizedBox(
width: 100,
height: 50,
child: Text('Fixed Size'),
)

3.使用 IntrinsicWidth/IntrinsicHeight 的替代方案: IntrinsicWidth 会触发多次子节点布局计算,性能低下。
我们可以手动计算子节点最大宽度,使用 ConstrainedBox 限制尺寸。

3.绘制(Paint)阶段优化

绘制机制: 当 RenderObject 的视觉属性(如颜色、位置)变化时,调用 markNeedsPaint() 标记需要重绘。

合成层(Layer): 每个 RenderObject 的绘制结果被组织为 Layer 树,最终由 GPU 光栅化。(PictureLayer(矢量绘制)、TextureLayer(图像)、TransformLayer(变换))。

优化策略

1.使用 RepaintBoundary: 将独立变化的 UI 部分包裹 RepaintBoundary,生成独立 Layer,减少重绘区域。
通过 RenderObject.isRepaintBoundary = true 标记。

1
2
3
4
RepaintBoundary(
child: MyAnimatedWidget(), // 独立重绘区域
)

2.避免高开销绘制操作: 使用 AnimatedOpacity 或直接设置颜色透明度(Color.withOpacity)替代 Opacity 。
优先使用 ClipRect 或 ClipRRect,减少路径裁剪的计算量。

3.自定义绘制优化: 在 CustomPainter 中精确控制重绘条件。

1
2
3
4
5
6
7
class MyPainter extends CustomPainter {
@override
bool shouldRepaint(MyPainter old) {
return old.color != color; // 仅颜色变化时重绘
}
}

4.GPU 合成与光栅化优化

1.光栅化机制: 通过上面的合成与光栅化可知道:光栅化运行在独立的 Raster Thread,与 UI Thread 并行。
Flutter 自动复用未变化的 Layer 对应的 GPU 纹理,减少数据传输。(纹理(Texture)复用)

优化策略

1.减少 Layer 数量: 过多的 Layer 会增加 GPU 合成开销,我们需要尽可能的合并相邻的 PictureLayer,避免不必要的 Opacity 或 Transform 嵌套。

2.使用硬件加速操作: 利用 GPU 的矩阵变换硬件加速(Transform 替代手动矩阵计算)。
对重复使用的图片提前解码(precacheImage) (Image 预加载)。

3.启用 Impeller 引擎: Flutter 3.0+ 引入的 Impeller 引擎针对 GPU 负载优化,减少光栅化抖动。

5.状态管理与资源优化

1.状态管理:
局部状态:使用 StatefulWidget 管理,确保 dispose() 释放资源。
全局状态:采用 Provider、Riverpod 或 Bloc,避免状态穿透和冗余重建。

2.资源释放:
必须释放动画控制器(AnimationController.dispose())、Stream 订阅(Subscription.cancel())等资源。

1
2
3
4
5
6
7
@override
void dispose() {
_controller.dispose();
_streamSubscription.cancel();
super.dispose();
}

2025-05-23 14.45.52.png

Flutter遇到的问题

1.Flutter In ios 14+,debug mode Flutter apps can only be launched from Flutter tooling。
原因:Debug模式下,Flutter也实现了热重载,默认编译方式为JIT而iOS 14+系统对这种编译模式做了限制,导致无法启动。

解决办法如下:用 [Xcode] 打开Flutter里面Runner工程项目,在 Build Settings 的最下方找到 User-Defined,点击 + 按钮,添加一个键为 FLUTTER_BUILD_MODE ,debug设置profile模式,release设置release 模式:截屏2024-03-14 11.27.00.png{target=”_blank”}

2.将 flutter 模块 嵌入iOS工程中,编译时报错:Failed to package 。。。。flutter代码路径。。。。。Command PhaseScriptExecution failed with a nonzero exit code
截屏2024-03-14 11.28.18.png{target=”_blank”}

解决办法如下:
1.确保flutter项目代码中没有错误
2.重新构建项目:
flutter clean
2.flutter pub get(获取远程库,确定当前应用所依赖的包,并将它们保存到中央系统缓存(central system cache)中)
3.flutter run

3.升级flutter:flutter upgrade –force 报错
截屏2024-09-12 15.23.05.png{target=”_blank”}

Flutter Channel版本选择
Flutter提供了Stable、Beta、Dev和Master四种版本,每种版本都有其特定的用途和稳定性:
Stable:最稳定的版本,推荐用于生产环境。
Beta:相对较稳定,但仍可能存在一些已知问题。
Dev:经过Google测试后的最新版本,包含新功能和改进。
Master:最新的代码主分支,更新速度非常快,几乎每天都有提交,新功能多但可能不稳定。
开发Flutter项目时,一般推荐使用Stable版本,以确保项目的稳定性和可靠性。如需使用某些尚未在Stable版本中支持的功能,可以考虑使用Beta或Dev版本。Master版本则更适合于那些希望尝试最新功能并愿意承受潜在不稳定性的开发者。

项目剖析04-swift 网络请求Moya+Alamofire(HTTPS)证书验证

SSL证书验证

一种加强App 和 Server 间通讯安全的方法。主要目标是确保 App 仅与预先验证的 Server 建立安全连接,防止中间人攻击(Man-in-the-Middle,MitM)等安全风险。一般常用的有两种方式进行验证,Certificate Pinning和Public Key Pinning。

Alamofire5.0 以后将证书验证类放于ServerTrustEvaluation这个类里面。一共有6种验证策略:

  1. DefaultTrustEvaluator - (默认验证策略)只要是合法证书就能通过验证。

  2. RevocationTrustEvaluator(验证注销策略)对注销证书做的一种额外设置,Alamofire从iOS10.1才开始支持吊销证书的策略。

  3. PinnedCertificatesTrustEvaluator(证书验证策略)app端会对服务器端返回的证书和本地保存的证书中的全部内容进行校验需要全部正确,此验证策略还可以接受自签名证书,安全性相对较高,此方法较为固定,如果 Server 更新证书,App 需要定期更新并重新上架。

  4. PublicKeysTrustEvaluator(公钥验证策略)app端只会对服务器返回的证书和本地保存的证书中的 PublicKey(公钥)进行校验,所以当证书需要更新时,只需确保公钥保持不变,不需要更新App。

  5. CompositeTrustEvaluator(自定义组合验证策略)以上多种策略组合一起,只有在所有数组中值都成功时才成功。

  6. DisabledTrustEvalutor(不验验证策略)无条件信任,所有都可以通过验证。正式环境不建议用此策略,多用于测试。

我们用的是PublicKeysTrustEvaluator(公钥验证策略)


后台提供证书,将正式放在项目目录中。

本地证书存放

获取本地证书,提取证书的公钥(获取公钥key数组)。证书后缀名一般有:.cer、.crt、.der等,我项目中用的cer,证书链必须包含一个固定的公钥。

1
2
3
4
5
6
7
8
9
10
11
12
struct WTCertificates {
static let rootCA = WTCertificates.certificate( )
static func certificate() -> [SecKey] {
var publicKeyArray:[SecKey] = []
for resource in ["xxx", "xxxx", "xxxxx"] {// 本地证书名称
if let filePath = Bundle.main.path(forResource: resource, ofType: "cer"), let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) as CFData, let certificate = SecCertificateCreateWithData(nil, data),let publicKey = certificate.af.publicKey {
publicKeyArray.append(publicKey)
}
}
return publicKeyArray
}
}

给Session添加策略(接受质询)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var requestManagerSession: Session = {
if WTCertificates.rootCA.count > 0, verifyCert {
let certificates: [SecKey] = WTCertificates.rootCA
let trustPolicy = PublicKeysTrustEvaluator(keys: certificates, performDefaultValidation: false, validateHost: false)
let manager = ServerTrustManager(evaluators: [
"xxx.xxx.com": trustPolicy,
"xx.xx.jftplus.com": trustPolicy,
"xxx.xx.com": trustPolicy])// base url 如何域名过多,可以子类化 ServerTrustManager,并用自己的自定义实现重写 serverTrustEvaluator(forHost:) 方法
let configuration = URLSessionConfiguration.af.default
return Session(configuration: configuration, serverTrustManager: manager)
}
return MoyaProvider<ApiManager>.defaultAlamofireSession()
}()

在Moya中添加requestManagerSession

1
var JKOtherApiManagerProvider = MoyaProvider<JKOtherApiManager>(endpointClosure: endpointMapping, requestClosure: requestTimeoutClosure, session:requestManagerSession, plugins:[RequestAlertPlugin(), networkPlugin])

OC HTTPS 证书配置验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//1 将证书拖进项目

//2 获取证书路径
NSString *certPath = [[NSBundle mainBundle] pathForResource: @"cetus" ofType:@"cer"];
//3 获取证书data
NSData *certData = [NSData dataWithContentsOfFile:certPath];
//4 创建AFN 中的securityPolicy
AFSecurityPolicy *securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModePublicKey withPinnedCertificates:[[NSSet alloc] initWithObjects:certData,nil]];
//5 这里就可以添加多个server证书
NSSet *dataSet = [[NSSet alloc]initWithObjects:certData, nil];
//6 绑定证书(不止一个证书)
[securityPolicy setPinnedCertificates:dataSet];
//7 是否允许无效证书
securityPolicy.allowInvalidCertificates = YES;
//8 是否需要验证域名
securityPolicy.validatesDomainName = YES;
uploadManager.securityPolicy = securityPolicy;

我们后台给的证书格式后缀是.pem,以下是用OpenSSL命令将.pem证书转换为cer格式证书方法

  1. 打开命令行工具,进入存放xxx.pem证书的目录
  2. 输入以下命令,将.pem证书转换为cer格式
    1
    openssl x509 -in xxx.pem -inform PEM -out xxx.cer -outform DER
  3. 执行完毕后,您将在当前目录下看到生成的xxx.cer文件

注意:转换后的cer证书文件只包含公钥,不包含私钥信息

项目剖析03-swift 网络请求Moya+HandyJSON+RxSwift

项目第一版网络框架用的是siesta,它的缓存与自动刷新确实很好用而且代码很简洁,但是在文件的上传与下载以及对返回类型需要精确匹配要求这方面就很不友好,所以在第二版的我选择了Moya,它是一个网络抽象层,它在Alamofire基础上提供了一系列的抽象接口方便维护。关于Moya的使用介绍很多,我就不再赘述了。我主要记录一下我在使用过程中学到的处理方式。我的网络框架是搭着HandyJSONRxSwift一起构建的。

Moya

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import Foundation
import enum Result.Result
import Alamofire

//设置请求超时时间
private let requestTimeoutClosure = { (endpoint: Endpoint, done: @escaping MoyaProvider<ApiManager>.RequestResultClosure) in
do {
var request = try endpoint.urlRequest()
request.timeoutInterval = 60
done(.success(request))
} catch {
return
}
}
let ApiManagerProvider = MoyaProvider<ApiManager>(endpointClosure: endpointMapping, requestClosure: requestTimeoutClosure, plugins:[])

// MARK: 取消所有请求
func cancelAllRequest() {
WTOtherProvider.manager.session.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in
dataTasks.forEach { $0.cancel() }
uploadTasks.forEach { $0.cancel() }
downloadTasks.forEach { $0.cancel() }
}

WTLoginProvider.manager.session.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in
dataTasks.forEach { $0.cancel() }
uploadTasks.forEach { $0.cancel() }
downloadTasks.forEach { $0.cancel() }
}
……
}

public func endpointMapping<Target: TargetType>(target: Target) -> Endpoint {
WTDLog("请求连接:\(target.baseURL)\(target.path) \n方法:\(target.method)\n参数:\(String(describing: target.task.self)) ")
return MoyaProvider.defaultEndpointMapping(for: target)
}

final class RequestAlertPlugin: PluginType {

func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
return request
}

func willSend(_ request: RequestType, target: TargetType) {
//实现发送请求前需要做的事情
}

public func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {

switch result {
case .success(let response):
guard response.statusCode == 200 else {
if response.statusCode == 401 {
if isJumpLogin == false {
cancelAllRequest()
// 退出登录
if let nvc = (WTNavigationManger.Nav as? WTMainViewController) {
nvc.login()
}
}
}
return
}
var json = try? JSON(data: response.data)
WTDLog("请求状态码\(json?["status"] ?? "")")

guard let codeString = json?["status"] else {return}
if codeString == 401 {// 退出登录
if isJumpLogin == false {
cancelAllRequest()
if let nvc = (WTNavigationManger.Nav as? WTMainViewController) {
nvc.login()
}
}
break
}

case .failure(let error):
WTDLog(error)
let myAppdelegate = UIApplication.shared.delegate as! AppDelegate
myAppdelegate.listenNetwork()
break
}
}
}

struct AuthPlugin: PluginType {
let token: String
}


enum ApiManager {
}

extension ApiManager: TargetType {
var headers: [String : String]? {
var dict = ["ColaLanguage": ("common.isChinese".L() == "YES") ? "CN" : "EN"]
if let authToken = WTLoginInfoManger.shareDataSingle.model?.accessToken {
dict["Authorization"] = authToken
}
return dict
}

var baseURL: URL {
return URL.init(string: AppURLHOST.MyPublicBaseURL)!
}

var path: String {
return ""
}

var method: Moya.Method {
return .get
}

var task: Task {
return .requestPlain
}

var validate: Bool {
return false
}
var sampleData: Data {
return "".data(using: String.Encoding.utf8)!
}
}
/// 数据 转 模型
extension ObservableType where E == Response {
public func mapHandyJsonModel<T: HandyJSON>(_ type: T.Type) -> Observable<T> {
return flatMap { response -> Observable<T> in
return Observable.just(response.mapHandyJsonModel(T.self))
}
}
}
/// 数据 转 模型
extension Response {
func mapHandyJsonModel<T: HandyJSON>(_ type: T.Type) -> T {
let jsonString = String.init(data: data, encoding: .utf8)
if let modelT = JSONDeserializer<T>.deserializeFrom(json: jsonString) {
return modelT
}
return JSONDeserializer<T>.deserializeFrom(json: "{\"msg\":\"\("common.try".L())\"}")!
}
}

/// 自定义插件
public final class NetworkLoadingPlugin: PluginType {
public func willSend(_ request: RequestType, target: TargetType) {
}
public func didReceive(_ result: Result<Moya.Response, MoyaError>, target: TargetType) {
}
}

模式Target -> Endpoint -> Request

来自GitHub图片

Moya虽然是基于Alamofire的但是我们在代码中却不会和Alamofire打交道,它是通过枚举来管理API的。我在项目中定义来一个API基类,然后为每一个模块定义了一个API管理类。

1
2
3
4
enum HomeApiManager {
case getBanner // 获取轮播
case getAnnouncement(per_page: String) // 获取公告
}

对于请求类型的改变和对于URL的改变也是通过枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var method: Moya.Method {
switch self {
case .orderCreate:
return .post
case .orderCancelById, .orderCancelByPair:
return .delete
default:
return .get
}
}

var path: String {
switch self {
case .getKline:
return "/api/kline"
case .transGetByID(let orderId):
return "/api/\(orderId)"
}
}

请求任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var task: Task {
switch self {
case .securityPostGoogleAuth(let tokenKey, let oldGoogleCode, let googleCode, let captcha):
return .requestParameters(parameters: ["captcha": captcha], encoding: JSONEncoding.default) // post请求

case .getReward(let type, let cursor, let limit):
return .requestParameters(parameters: ["type": type], encoding: URLEncoding.default) // 其它请求

case .uploadImage(let imageArry):
let formDataAry:NSMutableArray = NSMutableArray()
for (index,image) in imageArry.enumerated() {
//图片转成Data
let data:Data = image.jpegData(compressionQuality: 0.7)!
//根据当前时间设置图片上传时候的名字
var dateStr: String = "yyyy-MM-dd-HH:mm:ss".timeStampToString(timeStamp: Date().timeIntervalSince1970)
//别忘记这里给名字加上图片的后缀哦
dateStr = dateStr.appendingFormat("-%i.jpg", index)
let formData = MultipartFormData(provider: .data(data), name: "file\(index)", fileName: dateStr, mimeType: "image/jpeg")
formDataAry.add(formData)
}
return .uploadCompositeMultipart(formDataAry as! [MultipartFormData], urlParameters: [
:])

default:
return .requestPlain
}
}

插件机制

Moya的另一个强大的功能就是它的插件机制,提供了两个接口,willSendRequest 和 didReceiveResponse,它可以在请求前和请求后做一些额外的操作而和主功能是解耦的,比如可以在请求前开始加载动画请求结束后移除加载动画,还可以自定义插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
final class RequestAlertPlugin: PluginType {
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
return request
}
func willSend(_ request: RequestType, target: TargetType) {
现发送请求前需要做的事情
if target.headers?["isHiddentLoading"] != "true" {
currentView?.addSubview(activityIndicatorView)
activityIndicatorView.center = currentView!.center
activityIndicatorView.startAnimating()
}
}
public func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
if activityIndicatorView.isAnimating {
activityIndicatorView.stopAnimating()
activityIndicatorView.removeFromSuperview()
}
}
}

/// 自定义插件
public final class NetworkLoadingPlugin: PluginType {
public func willSend(_ request: RequestType, target: TargetType) {
}
public func didReceive(_ result: Result<Moya.Response, MoyaError>, target: TargetType) {
}
}

Moya默认有4个插件

  1. AccessTokenPlugin // 管理AccessToken的插件

  2. CredentialsPlugin // 管理认证的插件

  3. NetworkActivityPlugin // 管理网络状态的插件

  4. NetworkLoggerPlugin // 管理网络log的插件

RxSwift

这里的RxSwift不是完整的RxSwift,而是为Moya定制的一个扩展(pod ‘Moya/RxSwift’)在数据请求回来后进行处理。

  1. request() 传入API

  2. asObservable() 是Moya为RxSwift提供的扩展方法,返回可监听序列

  3. mapHandyJsonModel() 也是Moya RxSwift的扩展方法进行自定义的,可以把返回的数据解析成model

  4. subscribe() 是对处理过的 Observable 订阅一个 onNext 的观察者,一旦得到JSON格式的数据,就会经行相应的处理

  5. disposed() 是RxSwift的一个自动内存处理机制,类似ARC,会自动处理不需要的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/// 数据 转 模型
extension ObservableType where E == Response {
public func mapHandyJsonModel<T: HandyJSON>(_ type: T.Type) -> Observable<T> {
return flatMap { response -> Observable<T> in
return Observable.just(response.mapHandyJsonModel(T.self))
}
}
}
/// 数据 转 模型
extension Response {
func mapHandyJsonModel<T: HandyJSON>(_ type: T.Type) -> T {
let jsonString = String.init(data: data, encoding: .utf8)
if let modelT = JSONDeserializer<T>.deserializeFrom(json: jsonString) {
return modelT
}
return JSONDeserializer<T>.deserializeFrom(json: "{\"msg\":\"\("common.try".L())\"}")!
}
}
extension WTApiManager {
class func NetExchangeRequest<T: BaseModel>(disposeBag: DisposeBag,type: ExchangeApiManager, model: T.Type, isBackFail: Bool = false, Success:@escaping (T)->(), Error: @escaping ()->()) {
WTExchangeProvider.rx.request(type)
.asObservable()
.mapHandyJsonModel(model)
.subscribe { (event) in
switch event {
case let .next(data):
if isBackFail {
Success(data)
break
}
guard data.status == 200 else {
WTProgressHUD.show(error: data.message ?? "common.try".L(), toView: nil)
Error()
break
}
Success(data)
break
case let .error(error):
WTDLog(error)
Error()
break
default:
break
}
}.disposed(by: disposeBag)
}
}

HandyJSON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class BaseModel: HandyJSON {
var status: Int = 0
var message: String? = nil // 服务端返回提示
required init(){}
}

class WTBaseModel<T: HandyJSON>: BaseModel {
var data: T? // 具体的data的格式和业务相关,故用泛型定义
}
struct WTCurrencyBalanceModel: HandyJSON {
var coinCode: String = ""
let balanceAvailable: Double = 0.0
let balanceFrozen: Double = 0.0
let worth: Double = 0.0
}
// 网络请求 传入对应model
WTApiManager.NetOtherRequest(disposeBag: disposeBag, type: .getMarketsPrice, model: WTBaseModel<WTRateModel>, Success: {(model) in
}) {}

以上就是我在项目中使用

Moya+HandyJSON+RxSwift的方法,如果有错误或者不足之处还望指正,谢谢

项目剖析02-swift 轻松实现动画效果-Lottie

LottieAirbnb开源的一套跨平台的动画效果解决方案,它能够同时支持iOSAndroidWebReact Native的开发,设计师只需要用 AdobeAfterEffects(AE) 设计出需要的的动画之后,使用 Lottie 提供的 Bodymovin 插件将设计好的动画导出成JSON格式(文件很小不会象GIF那么庞大)给你即可,可以让设计师实现所见即所得的动画再也不用和设计师争论动画设计了。本文只是展示在swift中如何简单使用Lottie ,详细的使用方法请参考官方文档

github例图

用法举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
lazy var lottieAnimationView: AnimationView = {
// 加载本地资源
let path : String = Bundle.main.path(forResource: "data", ofType: "json")!
let lottieAnimationView = AnimationView.init(filePath: path)
WTNavigationManger.Nav?.view.addSubview(lottieAnimationView)
lottieAnimationView.constrain(toSuperviewEdges: nil)
lottieAnimationView.isUserInteractionEnabled = true
lottieAnimationView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(removeLottieAnimationViewFromParent)))
return lottieAnimationView
}()

// 调用
lottieAnimationView.play {[weak self] (complete) in
guard let mySelf = self else {return}
mySelf.removeLottieAnimationViewFromParent()
}

@objc func removeLottieAnimationViewFromParent() {
lottieAnimationView.removeFromSuperview()
}

将设计师给你的文件导入项目,然后通过Bundle.main.path(forResource:找到json文件,然后将AnimationView添加到视图,在需要展示动画的地方调用play() 方法,这样动画就可以加载了。

引入json的方式

1
2
3
4
5
6
7
8
/// json所在的文件,默认为Bundle.main
let animation = Animation.named("StarAnimation")
/// 默认为Bundle.main
let animation = Animation.named("StarAnimation", bundle: myBundle)
/// subdirectory 为动画所在的包中的子目录(可选的)
let animation = Animation.named("StarAnimation", subdirectory: "Animations")
/// animationCache 为保存加载动画的缓存(可选的)
let animation = Animation.named("StarAnimation", animationCache: LRUAnimationCache.sharedCache)

指定加载路径

1
Animation.filepath(_ filepath: String, animationCache: AnimationCacheProvider?) -> Animation?

从绝对文件路径加载动画模型。如果没有找到动画,则返回nil
filepath:要加载的动画的绝对文件路径
animationCache:用于保存加载的动画的缓存(可选的)

播放动画

基本播放(Basic Playing)

1
2
3
// 播放动画从它的当前状态到它的时间轴结束。在动画停止时调用completion代码块
// 如果动画完成,则completion返回true。如果动画被中断,则返回false
AnimationView.play(completion: LottieCompletionBlock?)

利用进度时间(Play with Progress Time)

1
2
3
// 指定一个时间到另一个时间的播放
AnimationView.play(fromProgress: AnimationProgressTime?, toProgress: AnimationProgressTime, loopMode: LottieLoopMode?, completion: LottieCompletionBlock?)

时间帧播放(Play with Marker Names)

1
2
// 动画播放从一个时间帧到另一个时间帧
AnimationView.play(fromFrame: AnimationProgressTime?, toFrame: AnimationFrameTime, loopMode: LottieLoopMode?, completion: LottieCompletionBlock?)

时间帧播放(Play with Marker Names)

1
2
3
// 将动画从命名标记播放到另一个标记。标记是编码到动画数据中并指定名称的时间点
AnimationView.play(fromMarker: String?, toMarker: String, loopMode: LottieLoopMode?, completion: LottieCompletionBlock?)

其它操作

  1. AnimationView.pause() // 暂停

  2. AnimationView.stop() // 停止

  3. var AnimationView.backgroundBehavior: LottieBackgroundBehavior { get set} // app进入后台

  4. var AnimationView.contentMode: UIViewContentMode { get set } // 循环播放模式。默认是playOnce,还有autoReverse无限循环

  5. var AnimationView.isAnimationPlaying: Bool { get set } // 判断动画是否在播放

  6. var AnimationView.animationSpeed: CGFloat { get set } // 动画速度

  7. func AnimationView.forceDisplayUpdate() // 强制重绘动画视图

以上就是我在项目中使用Lottie的方法,如果有错误或者不足之处还望指正,谢谢

项目剖析01-swift WebSocket

已经很长一段时间没有总结项目了,正好最近完成项目第二版的改版(新项目完全是用swift写的),就把项目中一些有意义的知识块在此记录一下, 项目中有实时的交易需要展示,所以用到了socket长链接,我用的是Starscream这个第三方库,集成方法很简单去网站看看就知道。

先上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import UIKit
import Reachability
import Starscream
import zlib

let reachability = Reachability()! // 判断网络连接
let webSocket = WTWebsocket.shared
var reConnectTime = 0 // 设置重连次数
let reConnectMaxTime = 1000 // 设置最大重连次数
let reConnectIntervalTime: TimeInterval = 15 // 设置重连时间间隔(秒)
var websocketTimer: Timer? = nil
var reConnectSubscribeDict:[String : Any] = [:]
var page = "home"
var isReconnect = true

final class WTWebsocket: NSObject,WebSocketDelegate {

var isPingBack = true
var myWebsocket: WebSocket? = nil
// socket连接上函数
func websocketDidConnect(socket: WebSocketClient) {
//设置重连次数,解决无限重连问题
reConnectTime = 0
if reConnectSubscribeDict.count > 0 {
self.subscribe(subscribeDict: reConnectSubscribeDict)
}
self.hearJump()
if websocketTimer == nil {
websocketTimer = Timer.scheduledTimer(timeInterval: reConnectIntervalTime, target: self, selector: #selector(sendBrandStr), userInfo: nil, repeats: true)
}
isReconnect = true
}

//发送文字消息
@objc func sendBrandStr(){
self.checkPing()
let json = getJSONStringFromDictionary(dictionary: ["topic":"PING"])
SingletonSocket.sharedInstance.socket.write(string: json)
}

// 发送ping
func hearJump() {
let json = getJSONStringFromDictionary(dictionary: ["topic":"PING"])
SingletonSocket.sharedInstance.socket.write(string: json)
}

// socket断开执行函数
func websocketDidDisconnect(socket: WebSocketClient, error: Error?) {
//执行重新连接方法
socketReconnect()
}

// 接收返回消息函数
func websocketDidReceiveMessage(socket: WebSocketClient, text: String) {
}

func websocketDidReceiveData(socket: WebSocketClient, data: Data) {
guard let newStr = String(data: data.gzipUncompress(), encoding: .utf8) else {return}
if newStr == "PONG" {
isPingBack = true
return
}
// 处理收到的信息
}

// 添加注册
func subscribe(subscribeDict: [String : Any]) {
var subscribeDicts = subscribeDict
reConnectSubscribeDict = subscribeDicts
page = subscribeDicts["type"] as! String
subscribeDicts.removeValue(forKey: "type")
let json = getJSONStringFromDictionary(dictionary:
subscribeDicts as NSDictionary)
SingletonSocket.sharedInstance.socket.write(string: json)
}

//检测
@objc func checkPing() {
if !isPingBack {
// 重新连接
socketReconnect()
}else {
isPingBack = false
}
}
//构造单例数据
static let shared = WTWebsocket()
private override init() {
}
}

//socket 重连逻辑
func socketReconnect() {
//判断网络情况,如果网络正常,可以执行重连
if reachability.connection != .none {
//设置重连次数,解决无限重连问题
reConnectTime = reConnectTime + 1
if reConnectTime < reConnectMaxTime {
//添加重连延时执行,防止某个时间段,全部执行
let time: TimeInterval = 2.0
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + time) {
SingletonSocket.sharedInstance.socket.connect()
SingletonSocket.sharedInstance.socket.disconnect()
}
} else {
//提示重连失败
}
} else {
//提示无网络
}
}

//socket主动断开,放在app进入后台时,数据进入缓存。app再进入前台,app出现卡死的情况
func socketDisConnect() {
if !SingletonSocket.sharedInstance.socket.isConnected {
websocketTimer?.invalidate()
websocketTimer = nil
SingletonSocket.sharedInstance.socket.disconnect()
}
}

// initSocket方法
func initWebSocketSingle () {
SingletonSocket.sharedInstance.socket.delegate = webSocket
}

//声明webSocket单例
class SingletonSocket {
let socket:WebSocket = WebSocket(url: URL(string: AppURLHOST.SocketURL)!)
class var sharedInstance : SingletonSocket{
struct Static{
static let instance:SingletonSocket = SingletonSocket()
}
if !Static.instance.socket.isConnected{
Static.instance.socket.connect()
}
return Static.instance
}
}

整个代码很简单,基本都有注释,大概聊一聊里面的一些关键点

发送ping-俗称发送心跳,这个主要是判断socket是否断开,链接成功后每次间隔固定时间发送一次请求,然后在返回中修改isPingBack,在下一次发送请求前检查isPingBack判断上一次的请求是否返回,这样就可以判断socket是否断开,这个间隔时间可以自由设定,但是最好不要太短,太短有可能是socket连接了但是没有来得及返回。当然太长也不行,这可能导致发现socket断开不及时。

app在后台需要断开socket,当 app重新进入前台需要重新连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func applicationWillResignActive(_ application: UIApplication) {
//进入后台模式,主动断开socket,防止出现处理不了的情况
if SingletonSocket.sharedInstance.socket.isConnected {
reConnectTime = reConnectMaxTime
socketDisConnect()
}
}
func applicationDidBecomeActive(_ application: UIApplication) {
//进入前台模式,主动连接socket
//解决因为网络切换或链接不稳定问题,引起socket断连问题
//如果app从无网络,到回复网络,需要执行重连
if !isFirstApplicationDidBecomeActive {
reConnectTime = 0
socketReconnect()
WTBasicConfigManager.shareDataSingle.getHash()
}
isFirstApplicationDidBecomeActive = false
}

一定要设置最大重新连接的次数,不然app会无限重新连接

连接成功或者重连成功都需要对需要推送的数据进行一次网络请求,确保数据的准确性。

以上就是我在项目中使用WebSocket的方法,如果有错误或者不足之处还望指正,谢谢

iOS集成融云SDK即时通讯整理

iOS集成融云SDK即时通讯整理

最近很少写一下项目总结了,最近项目虽然做了很多,但是都是一些外包项目,做下来也没有什么值得总结的。最近一个项目用到了融云即时通讯,以前基本都是用环信,所以还遇到了一些问题,在此总结一下记录一下。

头像、昵称等用户信息(融云对这个问题有两种处理方式)

用户信息提供者

实现步骤(以下代码放在单例中,可以是AppDelegate,最好单独写一个单例)

首先遵守RCIMUserInfoDataSource这个协议

然后是要设置代理

1
[[RCIM sharedRCIM] setUserInfoDataSource:self]; 

最后实现代理方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- (void)getUserInfoWithUserId:(NSString *)userId completion:(void (^)(RCUserInfo *))completion {
NSLog(@"getUserInfoWithUserId ----- %@", userId);
RCUserInfo *user = [RCUserInfo new];
if (userId == nil || [userId length] == 0) {
user.userId = userId;
user.portraitUri = @"";
user.name = @"";
completion(user);
return;
}

if ([userId isEqualToString:[UserInfo shareInstance].uid]) {
NSString *urlSelf = [BASIC_URL_image stringByAppendingString:[UserInfo shareInstance].photo];
return completion([[RCUserInfo alloc] initWithUserId:userId name:[UserInfo shareInstance].nickname portrait:urlSelf]);
}else {

//根据存储联系人信息的模型,通过 userId 来取得对应的name和头像url,进行以下设置
[WTBaseHttpRequst postRequstWithURL:getUserHttp params:@{@"uid":[UserInfo shareInstance].uid, @"api_token":[UserInfo shareInstance].api_token, @"k_uid":userId} successBlock:^(NSDictionary *returnData) {
if ([returnData[@"status"] integerValue] == 1) {
NSString *urlStr = [BASIC_URL_image stringByAppendingString:returnData[@"data"][@"user"][@"photo"]];
return completion([[RCUserInfo alloc] initWithUserId:userId name:returnData[@"data"][@"user"][@"nickname"] portrait:urlStr]);
}else {
completion(user);
}
} failureBlock:^(NSString *error) {
completion(user);
} showHUD:NO];
}
}

这个方法不需要你自己手动调用,只是当你在修改用户信息时调用方法即可

1
[[RCIM sharedRCIM] refreshUserInfoCache:user withUserId:[UserInfo shareInstance].uid]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
WS(weakSelf);
// 修改用户信息调用
[WTBaseHttpRequst postRequstWithURL:modifyInfoHttp params:dict successBlock:^(NSDictionary *returnData) {
[weakSelf MBProgressHudShowWithTextOnlyWithText:returnData[@"msg"]];
if ([returnData[@"status"] integerValue] == 1) {
RCUserInfo *user = [RCUserInfo new];
user.userId = [UserInfo shareInstance].uid;
user.portraitUri = [BASIC_URL_image stringByAppendingString:[UserInfo shareInstance].photo];
user.name = weakSelf.nickNameTextField.text;
[[RCIM sharedRCIM] refreshUserInfoCache:user withUserId:[UserInfo shareInstance].uid];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.navigationController popViewControllerAnimated:YES];
});
}
} failureBlock:^(NSString *error) {
[weakSelf MBProgressHudShowWithTextOnlyWithText:error];
} showHUD:YES];

在扩展消息中携带用户信息

设置发送消息时在消息体中携带用户信息(从2.4.1 之后附加用户信息之后cell默认会显示附加的用户信息的头像,即用户信息不会取用户信息提供者里提供的用户信息)

1
2
3
4
5
[RCIM sharedRCIM].enableMessageAttachUserInfo = YES; 
```

**你设置了enableMessageAttachUserInfo之后,可以取到**

/**

  • 发送者信息
  • **/
    @property(nonatomic, strong) RCUserInfo *senderUserInfo;
    1
    2
    3
    4
    5
    6
    7
    8
       
    **当然我觉得还可以从后台获取好友关系后,我们在每次登陆后,开一个线程把好友关系请求下来存起来然后根据环信ID查找好友的昵称和头像**


    ### 给输入框添加提示语(这个我一直觉得环信应该给了方法修改,只是我一直没有找到这个方法,所以只有自己去写了)

    **创建提示的label**

    _lab = [[UILabel alloc] initWithFrame:self.chatSessionInputBarControl.inputTextView.bounds];
    _lab.text = @”请输入文字信息…”;
    _lab.textColor = [UIColor colorWithHexColor:@”dddddd”];
    _lab.font = [UIFont systemFontOfSize:15];
    _lab.center = CGPointMake(_lab.center.x + 15, _lab.center.y);
    1
    2
    3

    **判定是否有草稿来显示和隐藏提示的label**

    [self.chatSessionInputBarControl.inputTextView addSubview:_lab];
    if (self.chatSessionInputBarControl.draft == nil || self.chatSessionInputBarControl.draft.length == 0) {
    _lab.hidden = NO;
    }else {
    _lab.hidden = YES;
    }
    1
    2
    3

    **根据输入数据来判定显示隐藏提示label**

  • (void)inputTextView:(UITextView *)inputTextView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
    if (((inputTextView.text.length == 1 && [text isEqualToString:@””]) || (inputTextView.text.length == 0 && text.length > 0)) && range.length == 1 && range.location == 0) {
    _lab.hidden = NO;
    }else {
    _lab.hidden = YES;
    }
    }
    1
    2
    3
    4
    5

    ### 取消输入@弹出好友列表界面,保留长按头像@方法

    **首先在AppDelegate中开启消息@功能(只支持群聊和讨论组, App需要实现群成员数据源groupMemberDataSource)**

    [RCIM sharedRCIM].enableMessageMentioned = YES;

然后在继承RCConversationViewController的控制器中调用
-(void)showChooseUserViewController:(void (^)(RCUserInfo *selectedUserInfo))selectedBlock
cancel:(void (^)())cancelBlock {
}

1
2
3
4


### 在会话列表中添加一些固定的cell(继承RCConversationListViewController)

// 对自定义cell赋值

  • (RCConversationBaseCell *)rcConversationListTableView:(UITableView *)tableView
    cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    RCCustomCell *cell = (RCCustomCell *)[[RCCustomCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@”RCCustomCell”];
    RCConversationModel *model = self.conversationListDataSource[indexPath.row];
    cell.nameLabel.text = model.conversationTitle;
    return cell;
    }

// 添加自定义cell的数据源

  • (NSMutableArray *)willReloadTableData:(NSMutableArray *)dataSource{
    NSArray *arr = @[@”论坛回复和@我的”, @”陌生人私信”, @”幸存者部落@我的”, @”问卷调查”];
    for (int i = 0; i<arr.count; i++) {
    RCConversationModel *model = [[RCConversationModel alloc]init];
    model.conversationModelType = RC_CONVERSATION_MODEL_TYPE_CUSTOMIZATION;
    model.conversationTitle = arr[i];
    model.isTop = YES;
    [dataSource insertObject:model atIndex:i];
    }
    return dataSource;
    }

// 点击cell跳转

  • (void)onSelectedTableRow:(RCConversationModelType)conversationModelType
    conversationModel:(RCConversationModel *)model
    atIndexPath:(NSIndexPath *)indexPath{
    if (indexPath.row == 0) {
    WTForumAndConnectListViewController *chatList = (WTForumAndConnectListViewController *)[WTStoryBoardSegment instantiateViewControllerWithStoryBoardName:@”Main” identifier:@”WTForumAndConnectListViewController”];
    chatList.title = @”回复和@我的”;
    [self.navigationController pushViewController:chatList animated:YES];
    }else if (indexPath.row == 1) {
    WTChatListViewController *chatList = [[WTChatListViewController alloc] init];
    chatList.title = @”陌生人私信”;
    chatList.isEnteredToCollectionViewController = YES;
    chatList.type = 1;
    chatList.friendArray = self.friendArray;
    [self.navigationController pushViewController:chatList animated:YES];
    }else if (indexPath.row == 2) {
    WTChatListViewController *chatList = [[WTChatListViewController alloc] init];
    chatList.title = @”幸存者部落@我的”;
    chatList.isEnteredToCollectionViewController = YES;
    chatList.type = 2;
    [self.navigationController pushViewController:chatList animated:YES];
    }else if (indexPath.row == 3) {
    WTQuestionnaireViewController *questionnaire = (WTQuestionnaireViewController *)[WTStoryBoardSegment instantiateViewControllerWithStoryBoardName:@”Main” identifier:@”WTQuestionnaireViewController”];
    [self.navigationController pushViewController:questionnaire animated:YES];
    }else {
    //点击cell,拿到cell对应的model,然后从model中拿到对应的RCUserInfo,然后赋值会话属性,进入会话
    if (model.conversationType == ConversationType_PRIVATE) {//单聊
    WTMyConversationLisViewController *_conversationVC = [[WTMyConversationLisViewController alloc]init];
    _conversationVC.conversationType = model.conversationType;
    _conversationVC.targetId = model.targetId;
    _conversationVC.title = model.conversationTitle;
    [self.navigationController pushViewController:_conversationVC animated:YES];
    }else if (model.conversationType == ConversationType_GROUP){//群聊
    WTMyConversationLisViewController *_conversationVC = [[WTMyConversationLisViewController alloc]init];
    _conversationVC.conversationType = model.conversationType;
    _conversationVC.title = model.conversationTitle;
    _conversationVC.targetId = model.targetId;
    [self.navigationController pushViewController:_conversationVC animated:YES];
    }
    }
    }
    1
    2
    3
    4
    5

    ### 在任意地方获取聊天列表数量及删除列表

    **获取聊天列表**

    NSArray *privateArr = [[RCIMClient sharedRCIMClient] getConversationList:@[@(ConversationType_PRIVATE)]];
    1
    2
    3

    **在ConversationList添加对应类型的聊天就可以获取对应类型的聊天列表删除方法类似**

    [[RCIMClient sharedRCIMClient] clearConversations:@[@(ConversationType_PRIVATE)]];

### 背景图

> 融云聊天列表没有数据的默认图片下面有点击右上角加入聊天,可是不是所有的聊天都有这个功能(我的就没有)如何没有就可以在资源文件中找到 no\_message\_img 这张图片用ps去掉下面的那一行字

### 其它

> 以上就是我在使用融云过程中遇到的一些问题及解决方法,如果有错误或者不足之处还望指正,谢谢!

UISearchBar详解

今天公司的项目测试的差不多了,基本可以上架了,又有时间来分享一下最近遇到的一些问题了,公司的项目进行了大改版(应该是全改了,基本是一个新的项目了),老大决定用swift重写。之前一直在自学swift,终于这一次可以实战了。项目中搜索用的比较多,但是搜索框的样式与默认的差别太大了,所以只能自定义了。

The UISearchBar class implements a text field control for text-based searches. The control provides a text field for entering text, a search button, a bookmark button, and a cancel button. The UISearchBar object does not actually perform any searches. You use a delegate, an object conforming to the UISearchBarDelegate protocol, to implement the actions when text is entered and buttons are clicked.

以上是苹果对UISearchBar的解释,可以看见UISearchBar提供了类似UITextField的输入(其实它的组成中就有UITextField,下面会讲到),右边有搜索按钮、标签按钮、关闭按钮可供选择,搜索都是在协议UISearchBarDelegate中进行。

自定义外观

默认搜索外观

项目搜索外观

UISearchBar的层级很是复杂主要由UISearchBarBackgroud、UISearchBarTextField、
UINavigationButton组成,其中UISearchBarTextField就是输入框,主要是由——UISearchBarSearchFieldBackgroundView、UIButton(❌)、UIImageView(?)等组成,

获取TextField方法:

1
2
let searchFiled:UITextField = self.searchBar.value(forKey: "_searchField") as! UITextField

这样就可以通过修改 searchFiled来修改输入样式(圆角、字体等)。

UISearchBar的直接子控件是UIVIew,其上的子控件UISearchBarBackgroud的frame与UISearchBar的bounds相等,UISearchBarTextField的高度默认为28与UISearchBar左右有8像素的固定间距,上下间距为直接子控件UIView的高度 - UISearchBarTextField的默认高度28 再除以2。因此UISearchBar的输入框始终与设置的frame不一样,不便于布局,我们可以添加一个子类继承UISearchBar,可以更改其内边距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MySearchBar: UISearchBar {

// 监听是否添加了该属性
var contentInset: UIEdgeInsets? {
didSet {
self.layoutSubviews()
}
}

override func layoutSubviews() {
super.layoutSubviews()
// 便利寻找
for view in self.subviews {
for subview in view.subviews {
// 判定是否是UISearchBarTextField
if subview.isKind(of: UITextField.classForCoder()) {
if let textFieldContentInset = contentInset {
// 修改UISearchBarTextField的布局
subview.frame = CGRect(x: textFieldContentInset.left, y: textFieldContentInset.top, width: self.bounds.width - textFieldContentInset.left - textFieldContentInset.right, height: self.bounds.height - textFieldContentInset.top - textFieldContentInset.bottom)
} else {
// 设置UISearchBar中UISearchBarTextField的默认边距
let top: CGFloat = (self.bounds.height - 28.0) / 2.0
let bottom: CGFloat = top
let left: CGFloat = 8.0
let right: CGFloat = left
contentInset = UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
}
}
}
}
}

}

让实例化的UISearchBar继承MySearchBar,然后就可以很方便的直接控制内边距了

1
self.searchBar.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)

接下来就是处理placeholder靠左,这个就比较麻烦了,查询了一大堆办法都挺麻烦的,最后找到了一个很投机的办法:先判定手机宽度,然后在placeholder右边加上空格做成靠左的假象。

1
2
3
4
5
6
7
8
if SCREEN.WIDTH == 320 {
self.searchBar.placeholder = "搜索位置 "
}else if SCREEN.WIDTH == 373\5 {
self.searchBar.placeholder = "搜索位置 "
}else if SCREEN.WIDTH == 414 {
self.searchBar.placeholder = "搜索位置 "
}

然后在storyboard中设置searchBar的BarStyle为Minimal就可以很方便的控制UISearchBar的外观了。
到这里就剩一个问题了:UISearchBar上下的两根黑线了,

去除方法:

1
self.searchBar.setBackgroundImage(UIImage.init(), for: .any, barMetrics: .default)

搜索的使用

如苹果官方文档所说,与搜索相关的都是在其代理方法中完成。UISearchBar有很多的代理方法,感兴趣的可以点击进入查看

UISearchBarDelegate我就介绍几个常用的:

当搜索内容变化时,执行该方法,可以实时监听输入框的变化,可以实现时实搜索。

1
2
- (BOOL)searchBar:(UISearchBar *)searchBar shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)textNS_AVAILABLE_IOS(3_0);                 // called before text changes

也行你想把搜索事件放在点击搜索以后再触发,那就选用这个方法,它就是点击搜索后的代理方法

1
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar;

结束

当然如果你觉得这样太麻烦了,你还可以选择用UITextField来实现UISearchBar的功能,但是最终哪一个更加的麻烦还需要试一试才知道。

  • Copyrights © 2015-2025 kindyourself@163.com
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信