框架设计模式-⑦组件化

[toc]

一、概念了解

1、组件化

就是“基础库”或者“基础组件”,意思是把代码重复的部分提炼出一个个组件供给功能使用。

​ 使用:Dialog,各种自定义的UI控件、能在项目或者不同项目重复应用的代码等等。

​ 目的:复用,解耦。

​ 依赖:组件之间低依赖,比较独立。

​ 架构定位:纵向分层(位于架构底层,被其他层所依赖)。

2、模块化

就是“业务框架”或者“业务模块”,也可以理解为“框架”,意思是把功能进行划分,将同一类型的代码整合在一起,所以模块的功能相对复杂,但都同属于一个业务。

​ 使用:按照项目功能需求划分成不同类型的业务框架(例如:注册、登录、外卖、直播…..)

​ 目的:隔离/封装 (高内聚)。

​ 依赖:模块之间有依赖的关系,可通过路由器进行模块之间的耦合问题。

​ 架构定位:横向分块(位于架构业务框架层)。

3、总结

​ 其实组件相当于,把一些能在项目里或者不同类型项目中可复用的代码进行工具性的封装。

​ 而模块相应于业务逻辑模块,把同一类型项目里的功能逻辑进行进行需求性的封装。

二、组件化的必要性/产生背景

1、产生背景:

所有模块代码都编写在一个项目中,在项目越来越大后,测试/使用某个模块或功能,需要编译运行整个项目,麻烦

2、组件化思路:

将每个模块作为一个组件,加一个中间层来协调各个模块间的调用,所有的模块间的调用都会经过中间层中转。只让其他模块对中间层产生耦合关系,中间层不对其他模块发生耦合。)

3、组件化好处:

业务划分更佳清晰,新人接手更佳容易,可以按组件分配开发任务。

项目可维护性更强,提高开发效率。

更好排查问题,某个组件出现问题,直接对组件进行处理。

开发测试过程中,可以只编译自己那部分代码,不需要编译整个项目代码。

方便集成,项目需要哪个模块直接通过CocoaPods集成即可。

三、组件化设计思路1:有管理中心

完整的项目:https://github.com/dvlproad/033-Data-Notification-iOS

1、核心思想

启动时候注册关系表,执行时候遍历关系表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 核心思想1:注册,以统一管理,后续调用时候,从注册表中查询并执行对应的操作
void register(url, handle) {
model.url = url;
model.handle = handle;
models.add(model); // 问题1:用什么管理所有数据
}

// 核心思想2:执行,遍历在注册表中的所有操作,找到对应的操作后进行执行
void exec(url) {
for (var model in models) {
if (model.url == url) {
model.handle();
break;
}
}
}

2、实现方案

2.1、方案1:Url+block 路由器

缺点:入参,取参不明显。

2.2、方法2:protocal - class

将要执行的方法统一放到类中,通过protocal或者类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 根据 protocal 查找 class
// 方法1(可一对多):为 protocal 添加监听者数组属性(通过runtime,形成类似于 protocal.listeners ),
NSHashTable *protocalListeners = objc_getAssociatedObject(protocol, CJ_BROADCAST_PROTOCOL_LISTENER); // 找到之前绑定到这个protocal上的监听者列表。
objc_setAssociatedObject(protocol, CJ_BROADCAST_PROTOCOL_LISTENER, protocalListeners, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

注册的时候 [protocalListeners addObject:listener];

// 方法2(只能一对一):以 protocal 为 key , 通过 protocal 存取 module 模块(模块自身遵循协议)
// 创建一个NSMapTable实例,其中键是强引用,值是弱引用
NSMapTable<NSString *, NSObject *> *weakRefHashTable = [NSMapTable strongToStrongObjectsMapTable];

// 向哈希表中添加一个键值对
NSString *key = @"someKey";
NSObject *value = [[NSObject alloc] init];
[weakRefHashTable setObject:value forKey:key];

模块协议拆分为多个子协议,用户

网络处理协议、数据管理协议(单独)、用户界面更新协议

改进思想

1
2
3
4
5
6
7
8
9
10
// 1、发起请求(LoginViewController)
id<OrderModulePublic> moduler = [[CJMoudleManager shared] modulerForProtocal:@protocal(OrderModulePublic)];
UIViewController *vc = [moduler orderViewControllerWithPageType:xxx];

// 2、实现请求(TSOrderModuleImpl)
[CJProtocolCenter addModule:self forProtocol:@protocol(OrderModulePublic)];
#pragma mark - OrderModulePublic
- (UIViewController *)orderViewControllerWithPageType:(int)pageType

}

上述代码还可以演变成实现通知的一种形式(当注入的类中,有多个类都遵循了(实际上不应该出现)模块协议时候)

1
2
3
4
5
6
7
8
9
10
11
12
// 1、发起请求(LoginViewController)
NSMutableSet<id<CJUserServiceProtocolForModule>> listener = [[CJMoudleManager shared] listenersForProtocal:@protocal(CJUserServiceProtocolForModule)];
for (id<CJUserServiceProtocolForModule> listener in listeners) {
[listener userNameUpadte:userName];
}

// 2、实现请求(OrderHomeViewController)
[CJProtocolCenter addModule:self forProtocol:@protocol(CJUserServiceProtocolForModule)];
#pragma mark - CJUserServiceProtocolForModule
- (void)userNameUpadte:(NSString *)userName

}

下列图片来源于 《架构分层.graffle》 中的【三、模块间设计】

跳转的使用示例:

modular_CJProtocalManager 跳转

通知的实现示例:

modular_CJProtocalManager 通知

跳转 + 数据变化后内部通知的实现示例:

modular_CJProtocalManager 跳转 + 数据变化后内部通知

四、组件化设计思路2:无管理中心

项目示例:CJStandardProject 中 CTMediator+CJDemoModuleMain.h

1、核心思想

核心思想:直接执行,让系统通过反射来找到要响应事件的类和方法

1
2
3
void exec(target, action, params) {

}

2、实现方案

2.1、方案1:CTMediator target+action+param

实现示例:

modular_CTMediator target+action+param

2.2、方案2:XXXService+Implemention

modular_XXXService+Implemention

五、组件化实施过程的思考

1、架构分层

整个APP架构上从上到下分为三层,独立于APP的通用层,通用业务层,业务层。

架构分层1

1.1、独立于APP的通用层

此层常为:一些Cocoapods公有库或者自己编写的独立于APP的库。

举例:MJExtension、CJNetwork、分享、CJOverlayKit(Toast、HUD、ActionSheet)、CJShareList

1.2、每个APP自己的通用层

此层常为:前面的独立于APP的通用层的二次封装。若公司内部引用了第三方库,按照依赖倒置的原则,建议封装一层之后放到Basic Specs供业务方使用。好处:跟外部环境有效隔离,第三方库发生问题,公司内部可控。

如:CQNetwork、CQOverlayKit、CQShareKit、网络库CQNetwork、数据库(FMDB/WCDB)、缓存库等

依赖倒置原则告诉我们要面向接口编程;通过抽象(接口或抽象类)使各个类或模块实现彼此独立,互不影响,实现模块间的松耦合。

1.3、业务模块

业务层的模块应该按照模块化的设计思想,尽量做到高度的“高内聚,低耦合”。

因模块高度独立,且高频使用,若公司内部有多个App同时需要依赖,建议单独创建私有库Specs。

iOS端APP架构设计心得

END

日志系统

[toc]

前言:主要事项

1、异常数据的发现及补充、日志分类优化
2、日志数据的文件保存、滚动存储
3、日志文件上传
4、日志回捞

一、日志收集

1、日志分类

1.1、日志目标

序号 目标(Target)
1 app
2 sdk 三方库
3 h5 网页

1.2、日志类型

序号 类型 描述
1 api_app app中的网络请求
2 api_app_cache app中的网络请求的网络缓存请求
3 api_buriedPoint 埋点的网络请求
4 sdk_other sdk的各种事件(初始化等)
5 sdk_api sdk中的网络请求
6 dart 语法
7 widget 视图(布局像素越界等)
8 click_other 点击、
9 click_share 分享
10 native_route 路由/跳转
11 h5_route 与网页跳转有关
12 h5_js js交互
13 monitor_network 监控:网络类型变化
14 monitor_lifecycle 监控:生命周期变化
15 buriedPoint_other 埋点数据生成等
16 im IM
17 heartbeat 心跳
18 other 其他

2、日志等级

序号 目标(Level) 描述
1 Normal 正常信息(目前用于请求开始)
2 Success 成功信息(目前用于请求结束:成功)
3 Warning 警告信息(目前用于请求结束:报错)
4 Error 错误日志(目前用于请求结束:失败)
5 Dangerous 危险(处理白屏等) 一般会进行额外的埋点

3、日志整合归类

序号 列表 标志 包含
1 全部 all 所有
2 警告 warning 所有的警告
3 错误 error 所有的错误
4 接口 api api_app、api_cache(不包括sdk_api、api_buriedPoint)
5 点击 click click_share、click_other、h5_js
6 路由 route navite_route、h5_route
7 网页 H5 h5_route、h5_js
8 sdk sdk sdk_api、sdk_other
9 code code dart、widget
10 埋点 buriedPoint api_buriedPoint、buriedPoint_other
11 监控 monitor monitor_lifecycle、monitor_network
12 其他 other other
13 api结果 api_result type=api_app & level != Normal
14 im im 本地缓存消息、历史消息、收到的消息等
15 心跳 heartbeat

devtool_log_home_page

4、日志的补充收集及其策略

要收集的数据已在上述《1.2、日志类型》中说明。

4.1、页面跳转

详见上述 《3、日志整合归类》中的”路由”

4.2、截断的收集

序号 关键字 场景
1 break for 、 swtich
2 return

4.3、用户关键行为

序号 行为 用途的场景说明
1 用户的资源选择 判断选择的本地资源是否上传到错误的存储桶

二、日志数据的文件保存、滚动存储

1、数据的文件保存

写入xlog文件中。

2、数据的文件滚动存储

2.1、日志的过期清理

1、本地日志文件结构

1
2
3
4
5
6
7
-- Document
-- log
-- 2020-10-01
-- xlog_2020-10-01_1.xlog
-- xlog_2020-10-01_2.xlog
-- 2020-10-18
-- xlog_2020-10-18_1.xlog

2、日志保留清理的配置信息(摘自/同 下文的日志接口中的《日志文件配置信息》)

序号 说明 字段 示例
接口回值1 最多保留几个天的日志目录 maxDayDirCount 7
接口回值2 最多每天保留几个文件 maxDayFileCount 10
接口回值3 每个日志文件超过多少后创建新文件 perFileMaxMB 5

3、清理方案

清理时机:在切换前后台的时候,

  • 日志配置信息的更新
  • 根据最新的日志配置信息,延迟10s后(避免影响启动),进行清理判断及清理
    • 遍历总日志目录 Document/log 下的所有日志文件夹
    • 如果天日志文件夹个数超过保留个数(避免是用保留时间,导致保留时间内只有一个日志,也被删掉),则按顺序删除早期日志
      • 遍历天日志目录 Document/log/yyyy-MM-dd 下的所有日志文件夹
      • 如果天日志文件夹个数超过保留个数(避免是用保留时间,导致保留时间内只有一个日志,也被删掉),则按顺序删除早期日志

2.2、日志文件的滚动写入

要增加的日志记录,如果添加后会超过日志文件的大小,则使用新文件写入。

细节点
1、日志文件的大小判断,不要每次都去读取文件,而是记录住大小变化
2、日志不要有一条写一条,而是使用写入缓冲区+定时器,5s尝试写入一次

3、数据的加密、解密

1、加密:

写入日志字符串时候,对编码后的字节数据,额外增加一个三位随机值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// 使用 GZip 压缩算法将字符串进行压缩,并返回压缩后的字节数据(List<int> 类型)。
List<int> _gzipCompressString(String inputString) {
final codec = GZipCodec();

// 将输入字符串 inputString 编码为 UTF-8 格式的字节数据
final encoded = utf8.encode(inputString);

// 对编码后的字节数据进行压缩,并返回压缩后的字节数据。
final compressedData = codec.encode(encoded);

return compressedData;
}

/// 加密日志字符串
String _encryptLogString(String logString) {
final compressedData = _gzipCompressString(logString); // 压缩字符串,并返回压缩后的字节数据(List<int> 类型)
final randomNumber = Random().nextInt(800) + 100;
final confound = compressedData.toString().substring(0, compressedData.toString().length - 1) + ", $randomNumber]"; // 在压缩后的字符串尾部加上一个随机字符串

return confound;
}

2、解密:

使用python脚本对log文件进行解密

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
import sys
import os
import gzip

# 读取.clog文件并解析数据
def parse_clog_file(input_file):
parsed_data = []

with open(input_file, 'r') as clog_file:
current_data = ""
for line in clog_file:
if current_data:
parsed_data.append(current_data)
binary_data = line[:-6] + ']' # 去除加密时候额外添加的三位随机数
byte_data = bytes(eval(binary_data))
decompressed_data = gzip.decompress(byte_data)
# current_data = byte_data.decode('utf-8')
print(decompressed_data)
current_data = decompressed_data.decode('utf-8')

# 添加最后一个数据条目
if current_data:
parsed_data.append(current_data)

return parsed_data

# 将解析后的数据写入与输入文件同名的.log文件
def write_to_log_file(parsed_data, input_file):
base_name = os.path.splitext(input_file)[0]
output_file = base_name + ".log"

with open(output_file, 'w') as log_file:
for data in parsed_data:
log_file.write(data + "\n")
log_file.write("\n")
log_file.write("=================================================================================================" + "\n")
log_file.write("\n")

if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python script.py <clogFileName>")
sys.exit(1)

input_clog_file = sys.argv[1]

# 解析.clog文件
parsed_data = parse_clog_file(input_clog_file)

# 将解析后的数据写入与输入文件同名的.log文件
write_to_log_file(parsed_data, input_clog_file)

print(f"Log data parsed from {input_clog_file} and written to {input_clog_file}.log.")

三、日志文件上传

1、日志文件命名及目录规范

以用户10000012020-01时候上传声音文件 beautiful_scenery.aac为例,其路径完整示例如下:

1
https://media.xxx.com/app1_test1/audio/1/1000001/2020-01/beautiful_scenery.aac

要上传的存储桶:

序号 说明 参数 约定值示例
1 存储桶region
regionGetFunction
网络环境 ap-shanghai
2 存储桶bucket
bucketGetFunction
网络环境 xxx-pro-image-1302324914
3 上传成功后,桶的映射路径值
cosFileUrlPrefixGetFunction
1、图片桶:https://images.xxx.com/
2、媒体桶:https://media.xxx.com/
3、日志桶:https://static.xxx.com/
4 要上传到桶的哪个相对路径下
cosFileRelativePathGetFunction
上传成功后完整路径=以上桶值+此相对路径
见下文 见下文
eg:app1_test1/audio/1/1000001/2020-01/beautiful_scenery.aac

《要上传到桶的哪个相对路径下 cosFileRelativePathGetFunction 》的文件路径分批说明:

序号 层次说明 层次值算法 层次值描述
1.1 存储桶里相对路径的前缀 app标志_环境标志
eg:app1_pro、app1_test1、app1_dev1
文件区分
1.2 是否在存储桶里进行进一步路径区分(可选) 根据场景决定是否对桶进行进一步分类。
if ( mediaType == xxx ) {
if(mediaScene == yyy) {

}
}

1、都是图片,但自拍图片要求独立出来
2、都是多媒体,但音视频要区分开
2.1 用户1级 int.parse(uid) % 1000; // 取余数 用户名取余,有效减少同层上文件夹个数
2.2 用户2级 uid 用户名
3 年月 DateTime.now().toString().substring(0, 7); 年-月
4 文件名 ${fileOriginName}_
${DateTime.now().microsecondsSinceEpoch}
.$fileExtensionType
文件名

附1:MediaType 媒体类型

序号 UploadMediaType 媒体类型 描述
1 unkonw 未知(默认值)
2 image 图片
3 audio 音频
4 video 视频
5 xlog 日志文件

附2:MediaScene场景 说明

序号 UploadMediaScene场景 描述
1 unkonw 未知(默认值)
2 selfie 自拍(安全等级较多)
3 im 会话聊天
4 live 直播

四、日志接口

1、日志记录接口(区别于回捞接口,需要在回捞前就有日志信息)

1、是否记录日志的开关及其配置信息

序号 说明 字段 示例
网页配置1 是否开启日志记录 isLogOn 1:开启、其他:关
网页配置2 要开启日志的用户(灰度上线)
(当且仅当开启上述日志记录时有效)
📢:未配置但开关为开时为全记录
logUserids [“101”, “102”]
网页配置3 要开启日志的设备(灰度上线)
(当且仅当开启上述日志记录时有效)
📢:未配置但开关为开时为全记录
logDeviceIds [“udid001”, “udid002”]
接口回值1 是否进行日志记录(灰度上线) needLog 1:记录、其他:不记录

2、不需要记录什么日志信息

序号 说明 字段 示例
接口回值1 不需要记录的日志类型 ignoreLogLevel [“Normal”, “Success”]
接口回值2 不需要记录的日志等级 ignoreLogType [“api_buriedPoint”, “buriedPoint_other”]

3、日志文件配置信息

序号 说明 字段 示例
接口回值1 最多保留几个天的日志目录 maxDayDirCount 7
接口回值2 最多每天保留几个文件 maxDayFileCount 10
接口回值3 每个日志文件超过多少后创建新文件 perFileMaxMB 5

4、日志文件问题回滚

序号 说明 字段 示例
网页配置1 强制删除什么时间前的日志 removeLogBeforeDate 2020-12-31
网页配置2 强制删除哪个用户的日志 removeUserids [“101”, “102”]
网页配置2 强制删除哪个设备的日志 logDeviceIds [“udid001”, “udid002”]
接口回值1 强制删除什么时间前的日志(不删除时空) removeLogBeforeDate 2020-12-31 或 空值

接口名定义:

1
function getLoggerConfig(userid, deviceId)

接口结果的json示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"logConfig": {
"needLog": 1,
"ignoreLogLevel": ["Normal", "Success"],
"ignoreLogType": ["api_buriedPoint", "buriedPoint_other"],
"maxDayDirCount": 7,
"maxDayFileCount": 10,
"perFileMaxMB": 5
},
"logRevert": {
"removeLogBeforeDate": "2020-12-31"
}
}

2、日志回捞及问题回滚接口

1、日志回捞

序号 说明 字段 示例
网页配置1 要回捞日志的用户 salvageUserids [“101”, “102”]
网页配置2 要回捞日志的设备 salvageDeviceIds [“udid001”, “udid002”]
网页配置3 日志回捞的开始时间 salvageStartDate 2020-01-01
网页配置4 日志回捞的结束时间 salvageEndDate 2020-01-07
接口回值1 此用户此设备日志回捞的开始时间 salvageStartDate 2020-01-01
接口回值1 此用户此设备日志回捞的结束时间 salvageEndDate 2020-01-07

2、日志文件问题回滚

序号 说明 字段 示例
网页配置1 强制删除什么时间前的日志 removeLogBeforeDate 2020-12-31
网页配置2 强制删除哪个用户的日志 removeUserids [“101”, “102”]
网页配置2 强制删除哪个设备的日志 logDeviceIds [“udid001”, “udid002”]
接口回值1 强制删除什么时间前的日志(不删除时空) removeLogBeforeDate 2020-12-31 或 空值

接口名定义:

1
function getLoggerSalvageAndRevert(salvageUserids, salvageDeviceIds)

接口结果的json示例如下:

1
2
3
4
5
6
{
"salvageId": "1001",
"salvageStartDate": "2020-01-01",
"salvageEndDate": "2020-01-07",
"removeLogBeforeDate": "2020-12-31"
}

3、日志文件上传到cos后同步给后台

用途:日志文件上传到cos后同步给后台,后台数据库记录每个用户,每个设备都捞到了什么数据。

接口名定义:

1
function addLoggerFile(userid, deviceId)

接口结果的json示例如下:

1
2
3
{

}

四、日志回捞

1、后台通过接口返回要回捞的信息。

2、前端用户在下次使用,收到后台信息时候,进行本地日志的上传。在腾云存储桶能收到对应日志,即代表成功。

五、方案实施

灰度方案:请参照 灰度系统 。(附:日志记录开关里目前已有灰度策略。)

目的:避免功能异常,出现集体性问题。

End

总目录

[toc]

Architecture架构

安全与破解

管理相关

混编

数据结构

算法与数学

iOS部分

Android

Flutter部分

React Native 部分

React 部分

H5-APP

HTML

Weex

上架相关

Script

阿里云

腾讯云

实用工具部分

代码管理

电脑使用

常识类

编程工具部分

iOS知识库

Android知识库

随笔

专利申请

网络接口数据安全的【攻与防大全】

[toc]

一、对数据加密的概述

1、安全的数据传输是什么样的

数据传输:都是使用密文(客户端提供加密的参数,服务端提供加密的结果)。

【客户端提供加密的参数】:需要在客户端加密后,上传到服务端,并在服务端对其进行解密,以确认传过来的参数。

【服务端提供加密的结果】:需要在服务端加密后,返回给客户端,并在客户端对其进行解密,以确认传过来的结果。

数据安全传输(AES)

若要了解加密方式可查看下文【附1:加密的方式有哪些

2、一端的AESKey怎么【安全的】传给另一端 / 为什么要对AES的密钥进行RSA公钥加密后传输??

答:为了安全,一端的AESKey必须先加密,然后才能传给另一端,这样才能防止用户从请求中抓取到AESKey。同时另一端需要对传过来的数据进行解密得到AESKey原值,后续两端才能都通过AESkey原值进行两边数据的对称加解密。即数据加密采用AES,而把AES的加密秘钥用RSA加密。(附:为什么AESKey不是使用md5方式加密?答:因为另一端要解密来使用原值。)。

问题:为什么要对AES的密钥进行RSA公钥加密后传输?

AES是对称加密算法,加密和解密的密钥都是同一个,为了防止被别人恶意获取到该密钥,然后对我们的业务请求参数进行解密,我们需要将AES密钥进行非对称加密后再进行传输。

2.1、密钥来源于客户端(类似Https)

数据安全传输(AES+RSA)

上图和Https原理有点相像。

2.1.1、客户端密钥保存的安全问题

如果密钥是来源于客户端,那如何在客户端安全的保存密钥?请查看下文中的【附2:如何在客户端安全的保存密钥AESKey

2.2、密钥来源于服务端(为了密钥能够像版本那样进行控制)

实际过程中,可能你的AESKey为了能够像版本那样进行控制,可能你的AESKey是由服务端分发的,则其过程可能如下:

数据安全传输(AES+RSA)

空 ==> getServicePublicKey ==> servicePublicKey

clientPublicKey(使用ServicePublicKey加密) ==> sendClientPublicKey ==> 使用servicePrivateKey解密得到clientPublicKey

空 ==> getServiceAESKey ==> aesKey(使用clientPublicKey加密)

参考文章:

最终网络数据传输,可能就只剩下类似下面一样,只有一个参数和

1
2
3
4
5
只剩一个参数:
encryptedParametersString: xxxx

只剩一个返回:
encryptedResultString: yyyy

二、被破解后,篡改参数值,进行请求攻击,怎么办?(二次加密:签名)

如果上面的加密过程被破解了,用户就能自己还原原始参数和原始返回值了。这时候如果他篡改参数值进行请求攻击怎么办?

1、请求参数值被篡改的场景

购物车多商品结算时候,本需共100美元的商品。在生成订单的时候,携带了所要支付的各个商品的id,以及总费用。结果总费用被篡改成以0.01美元的价格生成了订单Id。而后服务端却没有校验这些商品是不是0.01美元,从而导致平台损失。

以购物车结算为例,传统的传给服务器的参数一般是如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
goods: [
{
id: xxx
title:
price:
},
{
id: yyy
title:
price:
}
],
totoalPrice: 100

当用户破解了我们之前的加密算法后,其就能还原原始参数结果,并篡改参数值,然后再利用破解所得的加密算法得到一个新的加密参数如下:

1
2
3
4
5
篡改前所传参数:
encryptedParametersString: xxxx1

篡改后所传参数:
encryptedParametersString: xxxx2

之后,如果服务器接收和处理的就是被修改过的值了。

那这种情况下怎么办法呢?答:再加一层加密算法,即签名。

2、请求参数值被篡改的解决/防篡改方案

为了防篡改,通常有以下三个步骤:

1、我们通过上述原始参数计算得出一个签名值。然后将签名值添加一起传给服务端。

1
2
3
4
5
6
7
8
9
篡改前所传参数:
encryptedParametersString: xxxx1
signatureValue: xxxx1的签名值
signatureMethod: xxx1的签名方式(如果客户端有md5、sha256等多种加密方式)

篡改后所传参数:
encryptedParametersString: xxxx2
signatureValue: xxxx1的签名值(用户不知道此签名值的计算方法)
signatureMethod: xxx1的签名方式(如果客户端有md5、sha256等多种加密方式)

由于用户不知道签名值的计算方法,所以即使其篡改了原原始参数中的部分参数值并生成加密参数,其也仍需要再破解出这个签名值的计算方法,如果其不修改签名值,而是把签名值直接上传到服务端,则服务端会进行以下操作。

2、然后服务端接收到所传的参数(含签名值)后,也按照和客户端一样的方式对除签名值外的其他参数再计算一遍签名值。

3、如果所得的签名值和客户端传过来的签名值不一样,则是参数被篡改了,服务端放弃该请求。(注:为了保证正常情况下计算出的客户端和服务端的签名一样,我们会对请求参数进行字典排序后再签名。

所以,防篡改的解决办法是客户端加签,服务端验签。

2.1、加签的方法

对上述的 encryptedParametersString: xxxx1 进行md5、sha256或其他加密算法。

2.1.1、签名算法的防猜测

如果签名的加密算法被猜到是用md5进行的签名。则用户可以也跟着对encryptedParametersString: xxxx1 进行md5加密即可得到签名会被服务端认证通过的请求了。那是不是md5不能用呢?如果用怎么用?答:md5加盐,进一步增加破解的难度。

为什么加盐?
避免用户直接使用参数值进行md5加密,发现签名值就是由参数值md5加密而来。所以为了让MD5码更加安全 ,我们现在都采用加盐,盐要越长越乱。

1
2
3
4
5
加盐前:
digest = hash(input)

加盐后:用户拿参数值直接md5发现和签名值匹配不上,则一般就不会发现是用md5加密了。
digest = hash(salt + input)

这样加盐后,当用户再拿参数值直接进行md5后,发现和签名值匹配不上,则一般就不会想到算法是用md5加密了。

这个salt可以看作是一个额外的“认证码”,可以是固定的值,也可以是动态的值,如每个用户自己的userid。

更多MD5及其加盐可查看下文中的【附3:签名 –> md5(加盐))】

2.1.2、签名算法关注的内容

除了请求参数外,签名算法还关注请求的接口和方法,即其一般是对含参数在内、外加其他值(如URL、Method)一起进行签名。

参考文章:

三、不破解,但进行二次请求/API重放攻击(Replay Attacks)怎么办?

加密后,破解难度增加了,但是如果用户不破解,而是通过API重放攻击你的服务器呢,怎么办?

1、重放攻击(Replay Attacks)概述及危害介绍

API重放攻击(Replay Attacks)又称为重播攻击、回放攻击。它的原理就是把之前窃听到的数据原封不动的重新发送给接收方。

1.1、即使有幂等性情况下,重放请求的危害

追问:服务端不是应该有做幂等性了吗,为什么还需要处理防重放问题?

答:虽然如果一个系统操作是幂等的,即使攻击者重放了一个请求,系统也不会因为重复执行而产生不同的结果或状态。在一定程度上可以减少重放攻击的潜在损害(例如,一个幂等的HTTP GET请求,无论请求多少次,服务器都会返回相同的结果,而不会对服务器的状态产生影响)。但是即使请求本身是幂等的,也需要防止恶意用户发送大量重放请求来耗尽系统资源,从而实现拒绝服务攻击(DoS)。

若要了解幂等性知识,可查看

1.2、没有幂等性情况下,重放请求的危害

如果服务端没有幂等性,则假设有一个在线投票系统,用于选举或表决,每个用户只能投票一次。那么攻击者可能会尝试重放投票请求,进行重复投票,以增加他们支持的候选人或选项的票数。

2、重放攻击(Replay Attacks)的解决方案

防止重放攻击必须要保证请求仅一次有效。需要通过在请求体中携带当前请求的唯一标识,并且进行签名防止被篡改。所以,防止重放攻击需要建立在添加签名且防止签名被篡改的基础之上。

2.1、措施一:添加请求的时效性参数 Timestamp

即某个请求,其请求时间戳Timestamp,和服务端的当前时间在规定时间内(如1分钟内)则为合法请求,反之,则视为无效请求。

服务端时间戳的获取时机和服务端的RSA公钥一致,你可理解为网络库的初始化阶段(虽然这是真正初始化之后的请求的”二次初始化”)。

数据安全传输_防重放_1加timestamp

客户端与服务端的时间戳差值 diff = 服务器返回的时间戳 - 客户端请求结束的时间戳

校准服务端后的客户端的时间戳 = 当前客户端时间戳 + 客户端与服务端的时间戳差值 diff

2.2、措施二:添加一个唯一的随机数Nonce

2.2.1、Nonce的核心思路:

调用者每次调用时:

​ A:调用者生成并带上一个随机数Nonce(可以考虑用用户id+当前时间戳+接口url)

​ B:服务端该随机数是否已出现,有则拒绝,无则存储该随机数并放过请求。

则请求参数变为如下:

1
2
3
4
5
6
7
8
9
10
11
未加 timestamp 和 nonce 前:
encryptedParametersString: xxxx1
signatureValue: xxxx1的签名值
signatureMethod: xxx1的签名方式(如果客户端有md5、sha256等多种加密方式)

加了 timestamp 和 nonce 后:
encryptedParametersString: xxxx1
signatureValue: xxxx1的签名值,📢:要把timestamp和nonce也包括进去,避免它们被篡改
signatureMethod: xxx1的签名方式(如果客户端有md5、sha256等多种加密方式)
nonce: xxx1的nonce
timestamp: xxx1的timestamp

这里注意:计算xxxx1的签名值要把timestamp和nonce也包括进去,避免它们被篡改。

2.2、Nonce的优化思路:

这里服务端要保证Nonce唯一,就得存储已经用过的Nonce,但长期保持会带来两个问题

​ (1)存储成本增加,日积月累,这里要存储的Nonce会越来越多,需要的存储空间就越大

​ (2)碰撞概率增加,正常服务被拒绝概率增大;这里随着生成Nonce值越来越多,碰撞的概率一定越来越大,若通过增加Nonce值的长度,有增加存储成本。

​ 那么,另一个可行的办法,就是调用者每次请求时带上当前请求时间点Timestamp,然后由服务端限制请求的时效性。

​ 如此,上面提到的Nonce值存储成本可能比较大的问题,在结合Timestamp后,可大大降低存储成本,如Timestamp=1min,则仅需存储1min内的请求Nonce值即可,大大减少存储的量级。

至此,客户端发送的参数为:

1
2
3
4
5
encryptedParametersString: xxxx1
signatureValue: xxxx1的签名值,📢:要把timestamp和nonce也包括进去,避免它们被篡改
signatureMethod: xxx1的签名方式(如果客户端有md5、sha256等多种加密方式)
nonce: xxx1的nonce
timestamp: xxx1的timestamp

3、扩展:客户端自己如何防止接口重复请求?

1、界面添加 loading

2、进行节流(最后执行)和防抖(立即执行)中的防抖。

3、请求队列:类似SDWebImage加载相同地址的图片。

如果重复请求的话,客户端的优化:

1、缓存

其他可参考:功能问题:如何防止接口重复请求?


##

附1:加密的方式有哪些

对称加密 非对称加密
概念 加密密钥和解密密钥相同 加密密钥和解密密钥不相同
算法 DES、AES、RC2、RC4、RC5 等 RSA、ECC 等
缺点 速度快,安全性低。使用同一个密钥,如果一方密钥泄露,数据就不安全了 非对称加密的安全性更高,但加密速度较慢。

注意:非对称加密的安全性更高,但加密速度较慢。一般我们在项目中采用的是 ”结合对称和非对称加密,数据加密采用AES,而把AES的加密秘钥用RSA加密,这样兼顾速度及安全性“https://juejin.cn/post/7002058547395559438

1.1、对称加密

加密方式_对称

1、李雷想要给韩梅梅发送消息,他们约定使用对称加密的方式把消息进行加密

2、李雷用密钥把消息加密然后发送给韩梅梅

3、韩梅梅用同一个密钥解密,然后看到李雷发送给自己的消息

1.2、非对称加密

加密方式_非对称

1、李雷想要给韩梅梅发送消息,他们约定使用非对称加密的方式把消息进行加密

2、李雷首先要得到韩梅梅的公钥

2、李雷用韩梅梅的公钥把消息加密然后发送给韩梅梅

3、韩梅梅用自己的私钥解密,然后看到李雷发送给自己的消息

韩梅梅给李雷发送消息同理。

附2:如何在客户端安全的保存密钥AESKey

如果密钥是来源于客户端,那如何在客户端安全的保存密钥?

1、密钥在代码上(内部)

1.1、存放密码的常见的代码

1
2
3
4
#define kSecret "abcd1234"

// 或者
const char* kSecret = "abcd1234";

这是非常危险的,因为常量会被直接编译到可执行文件的data段,只要对生成的可执行文件使用stringsotool等命令就可以dump出原始字符串。

1.2、对密码加密

仍然存

1、类的代码层面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 方法1:密码在可执行文件的data段,且是明文
#define kSecret "abcd1234"
void getSecret {
return kSecret
}

// 方法2:密码在可执行文件的data段,虽是明文,但取到的值要再经过一层你自己知道的方法解密
#define kSecret "\x7e\x77\x64\x3c\xa7\xd4\x6d\x46\x29\x8b\xe3\x23\x9f\x1a\x5c\xdb"; // 使用aes加密后的
#define kAESKey "abcdefgh12345678";
char* getSecret() {
return decryptSecretByAESKey(kSecret, kAESKey);
}
// 方法2的优化:内部代码使用额外包在宏(kAppSecret)上,由于宏没有明确的入口,使得静态分析相比直接调用函数形式的getSecret更加不容易被破解。
#define kAppSecret \
({ \
char* buf = decryptSecretByAESKey(kSecret, kAESKey); \
[NSString stringWithUTF8String:buf]; \
})

附:带返回值的宏的写法 见宏定义的两个技巧:

1
2
3
4
#define SOME_MACRO \ 
({ \
expression; \
})

最后一个表达式的值就是宏的返回值,使用时更像函数的返回值。

2、类本身(可包含类中的函数名)

因为objc代码的动态性,编译器会在binary中留下类名、函数名等信息,这些信息是可以被class-dump-z等工具提取的,友好的命名让程序猿更方便,但同时也方便了破解者。对安全相关的重要模块类,可以故意混淆类名,让人不容易轻易联想到该的真实目的。

PS、更多混淆知识可看我写的另一篇文章:iOS APP安全加固方案(一、代码混淆CodeObfuscation)

2、密钥保存在外部

2.1、密钥保存在Keychain

密钥保存在Keychain并不安全,iOS越狱后可以导出Keychain的内容。应该尽量避免存放重要信息(如:token、用户名、密码等)在Keychain中,即使要存放,也一定要加密后存放。参考:iOS安全攻防(九):使用Keychain-Dumper导出keychain数据

2.2、密钥保存在文件

保存在app bundle、plist等配置文件更不安全,但可以使用隐写术等方式迷惑hackers。

2.2.1、隐写术

隐写术的载体:相比视频、音乐等文件,通常选择使用图片这种占用空间小的文件,但因为Xcode打包时会对png图片做特殊处理,如果将密码携带在png中,可能会在使用的时候无法复原。所以若非确定的隐写方法,则建议用bmp图片。

本节点【如何防止客户端被破解/密钥的保护】部分内容摘自/参考:如何防止客户端被破解

LSB算法:LSB全称为 Least Significant Bit(最低有效位),是一种常被用做图片隐写的算法。为了避免我们对图片进行的处理可能会在压缩的过程中被破坏,建议在无压缩的bmp图片上实现。(png图片是无损压缩,jpg图片是有损压缩。)

steganography_lbs图片来源于:《网络加密.graffle》中的【隐写术】

bmp的隐写,相比lsb隐写,bmp简单的多,并且达到了无损隐写。

bmp了解:BMP位图隐写

ctf杂项中图片隐写拿到一张图片常见的解题方法

附3:签名 –> md5(加盐)

md5是哈希函数。SHA-256或SHA-3也是哈希函数,相比md5更安全。

Mac:在终端上实现将abcd1234进行MD5(Message Digest algorithm 5 ,信息摘要算法)加密。

1
2
3
echo -n 'abcd1234' | md5

得到结果:e19d5cd5af0378da05f63f891c7467af

使用MD5就是将一串字符串通过某特定的算法来将其变成另一种形式,这样子就在外观上起到了加密的效果,但是由于背后的算法(md5是一种常见的hash算法)是固定的,所以每一个字符串都有固定的MD5格式。(反过来,一个hash值是否只能是一个值才可能?答:不是)

因为每一个字符串就只有一种特定的MD5格式,所以md5的破解(从c=hash(m)逆向得到原始明文m)容易想到的方式有:

方法 优缺点 推荐程度
暴力破解法 时间成本太高 ⭐️
字典法 提前构建一个“明文->密文”对应关系的一个大型数据库,破解时通过密文直接反查明文。但存储一个这样的数据库,空间成本是惊人的 ⭐️⭐️⭐️
构建彩虹表 在字典法的基础上改进,以时间换空间。是现在破解哈希常用的办法。
彩虹表:彩虹表记录了几乎所有字符串的MD5对照表。
⭐️⭐️⭐️⭐️⭐️

md5在线解密网站

md5在线解密

因为已经有了类似彩虹表的破解方法,所以如果不‘加盐’,则当用户知道是 e19d5cd5af0378da05f63f891c7467af 时候,就很容易知道原值是 ‘abcd1234’了。

所以Md5加密方式的进化之路小结:

密码存储的种方式
1 直接存储密码明文m 明文存储,无安全性可言。
2 存储密码明文的哈希值hash(m) 虽然是入侵者得到的是hash值,但由于彩虹表的存在,也很容易批量还原出密码明文来。
3 存储密码明文的加盐哈希 hash(m+salt)。
这里的salt可以是用户名,手机号等,但必须保证每个用户的salt都不一样才是安全的。
相对前两种安全

网路本身的安全

HTTPS是应用层的安全协议。TCP是传输层的协议,但是它不安全,因为它是明文传输的,所以SSL的诞生就是给TCP加了一层保险,使HTTPS和TCP之间使用加密传输。而TLS只是SSL的升级版,他们的作用是一样的。

以下内容摘自HTTPS、SSL/TLS、TCP之间错综复杂的情感纠葛

SSL(Secure Sockets Layer) 安全套接层:为网络通信提供安全及数据完整性的一种安全协议。

TLS(Transport Layer Security)安全传输层协议:用于在两个通信应用程序之间提供保密性和数据完整性。该协议由两层组成: TLS 记录协议(TLS Record)和 TLS 握手协议(TLS Handshake),是更新、更安全的SSL版本。

SSL/TLS协议的基本思路是采用公钥加密法,也就是说,客户端先向服务器端索要公钥,然后用公钥加密信息,服务器收到密文后,用自己的私钥解密。==> 类似 RSA

但是,这里有两个问题。

(1)如何保证公钥不被篡改?

解决方法:将公钥放在数字证书中。只要证书是可信的,公钥就是可信的。

(2)公钥加密计算量太大,如何减少耗用的时间?

解决方法:每一次对话(session),客户端和服务器端都生成一个”对话密钥”(session key),用它来加密信息。由于”对话密钥”是对称加密,所以运算速度非常快,而服务器公钥只用于加密”对话密钥”本身,这样就减少了加密运算的消耗时间。 ==> 类似 AES

HTTPS(超文本传输安全协议)是建立在HTTP(超文本传输协议)之上,通过SSL/TLS(安全套接字层/传输层安全协议)进行加密的安全协议。在HTTPS的握手过程中,SSL/TLS握手会先于HTTP握手发生。以下是HTTPS连接建立的基本步骤:

  1. TCP握手:客户端首先通过TCP(传输控制协议)与服务器建立一个普通的连接。这个过程包括三次握手,确保双方都准备好进行数据传输。
  2. SSL/TLS握手:一旦TCP连接建立,SSL/TLS握手就开始了。这个过程包括:
  3. 应用层协议:一旦SSL/TLS握手完成,客户端和服务器就可以使用这个安全的连接来传输加密的数据。这时,HTTP请求和响应就会在SSL/TLS层之上进行传输。

因此,HTTPS的连接建立过程中,是先进行TCP握手,然后进行SSL/TLS握手,最后才开始HTTP通信。

其他参考文章

End

灰度系统

[toc]

目的:避免功能异常,出现集体性问题。

一、灰度接口

1、灰度开关接口

1、是否进行功能的灰度使用的开关

序号 说明 字段 示例
网页配置1 是否开启功能 isOpen 1:开启、其他:关
网页配置2 要开启功能的用户(灰度上线)
(当且仅当开启上述功能时有效)
openUserids [“101”, “102”]
网页配置3 要开启功能的设备(灰度上线)
(当且仅当开启上述功能时有效)
未配置但开关为开时为全记录
openDeviceIds [“udid001”, “udid002”]
接口回值1 是否进行功能的使用(灰度上线) needOpen 1:使用、其他:不使用

接口名定义:

1
function getGrayscaleSwitchConfig(userid, deviceId)

接口结果的json示例如下:

1
2
3
4
5
6
7
8
{
"logFuture": {
"needOpen": 1,
},
"cacheOptimize": {
"needOpen": 1
}
}

End

请求规范

[toc]

一、JS请求结果结构规范(JSResponseModel)

json举例

1
2
3
4
5
{
"resultCode": 0,
"message": "成功",
"result": map 或 array
}

点击跳转到附文查看:JSResponseModel 代码示例

二、JS调用规范

1、规范标准

1、js需要回传值时候,回传值方法统一由h5的callbackMethod告知app(而非app写死)

2、js需要回传值时候,一定要有回传值,且回传值结果必须如《本文》中的【一、JS请求结果结构】所示

2、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
/// 调起微信小程序去支付
static JavascriptChannel pay(
BuildContext context, {
required WebViewController? Function() webViewControllerGetBlock,
}) {
return BJJavascriptChannel(
name: 'h5CallBridgeAction_pay',
onMessageReceived: (JavaScriptMessage message) async {
Map<String, dynamic>? map = json.decode(message.message.toString());
if (map == null) {
return;
}

Map<String, dynamic> jsCallbackMap = JSResponseModel.initMap();
if (map['payType'] == null || map['payType'].isEmpty) {
jsCallbackMap = JSResponseModel.errorMap(message: '缺少 payType 参数');
} else {
MiniProgramPayBean payParams = MiniProgramPayBean.fromJson(map);
bool isSuccess = false;
if (map['payType'] == 'wx_mp') {
isSuccess = await WXMPPay.jumpWechatMP2Pay(payParams);
} else {
isSuccess = await WXMPPay.jumpWechatMP2Pay(payParams);
}
jsCallbackMap = JSResponseModel.successMap(isSuccess: isSuccess);
}

String? jsMethodName = map["callbackMethod"];
if (jsMethodName == null) {
WebViewController? webViewController = webViewControllerGetBlock();
WebViewWays.runJavaScriptMap(
jsMethodName!,
params: jsCallbackMap,
controller: webViewController,
);
}
},
);
}

三、测试JS的JS接口

描述 JS方法 其他
测试 h5 调用 app 方法,并将返回值回调给 h5 h5CallBridgeAction_test_h5CallAppAndCallBackToH5
测试浏览器中的链接显示,且链接可打开app的_原生页面 h5CallBridgeAction_test_openBrowser
原生调用h5提供的显示回传值的方法 bridgeCallH5Action_showCallbackJsonString

四、公共的JS接口

1、app设备相关

描述 JS方法 其他
获取app的公共信息,并将返回值回调给 h5 h5CallBridgeAction_getFixedAppInfo 改用user-agent
获取埋点monitor的公共信息,并将返回值回调给 h5 h5CallBridgeAction_getFixedMonitorInfo
更新状态栏颜色 h5CallBridgeAction_updateAppStatusBarStyle
是否是模拟器 h5CallBridgeAction_isSimulator

2、用户相关

描述 JS方法 其他
获取用户token(用于安全的告知h5用户信息),并将返回值回调给 h5 h5CallBridgeAction_getCurrentUserToken
退出登录 h5CallBridgeAction_logout

3、基础提示相关

描述 JS方法 其他
显示app的toast样式 h5CallBridgeAction_showAppToast

4、路由相关

描述 JS方法 其他
跳转到app指定页面_Url h5CallBridgeAction_jumpAppPageUrl
跳转到app指定页面_Name h5CallBridgeAction_jumpAppPageName

5、分享相关

描述 JS方法 其他
分享内容到指定的分享方式(微信聊天页面等) h5CallBridgeAction_share

6、网页相关

描述 JS方法 其他
刷新网页(到新地址) h5CallBridgeAction_reloadAppWebView
关闭webview h5CallBridgeAction_closeWebView

五、业务相关的JS接口

入参 JS方法 其他
发送IM的各种信息 h5CallBridgeAction_sendIMText

六、JS安全

附1:JSResponseModel代码示例

class JSResponseModel 示例:

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
// h5CallBridge_responseModel.dart
class JSResponseModel {
final int resultCode;
final String? message;
final dynamic result;

JSResponseModel({
this.resultCode = 0,
this.message,
this.result,
});

// js默认的map(默认是成功)
static Map<String, dynamic> initMap({
String? message,
}) {
return JSResponseModel(
resultCode: 0,
).toMap();
}
// js失败时候的map
static Map<String, dynamic> errorMap({
String? message,
}) {
return JSResponseModel(resultCode: 1, message: message).toMap();
}
// js成功时候的map
static Map<String, dynamic> successMap({
required bool isSuccess,
dynamic result,
}) {
return JSResponseModel(
resultCode: isSuccess ? 1 : 0,
result: result,
).toMap();
}

// Model 转 map 以回传结果给h5
Map<String, dynamic> toMap() {
Map<String, dynamic> responseMap = {};

responseMap.addAll({"resultCode": resultCode});
if (message != null) {
responseMap.addAll({"message": message});
}
if (result != null) {
responseMap.addAll({"result": result});
}
return responseMap;
}

bool get isSuccess => resultCode == 0;
}

End

请求规范

[toc]

一、各种示例

1、request的HEADER公共入参(固定+可变的)示例

1
2
3
4
5
6
7
8
9
10
11
12
13
- HEADER(REQUEST):
{
"version":"1.16.30",
"buildNumber":"16301610",
"platform":"iOS",
"appType":"monkey",
"appFeatureType":"dev",
"packageChannel":"general",
"trace_id":"1683158692451101/2023-05-04 08:04:52/_sy_]onw^w^",
"Authorization":"bearer clientApp247deea3-da67-47bc-94f7-04b83ca0fb07",
"content-type":"application/json",
"content-length":"28"
}

参数详情介绍见下文《公共入参介绍》。

2、request的BODY公共入参(固定+可变的)示例

示例可详见 《埋点规范》–【埋点入参】–固定的公共入参 。附:其实改部分也可考虑放在HEADER中

3、response的返回结构示例

1
2
3
4
5
6
7
8
{
"status": 200,
"message": "请求成功,但是业务处理失败",
"errorCode": 1001,
"errorMessage": "参数name不能为空",
"data": null,
"timestamp": "2023-05-04T09:00:00Z"
}

二、设置request的HEADER公共入参(固定不变7个+可变N个)

网络库初始化所需的header参数

header形参 header形参名 描述 示例
必填 Map headerCommonFixParams header 中公共但【不变】的参数 如上
可选 block headerCommonChangeParamsGetBlock header 中公共但【会变】的参数 { ‘trace_id’: TraceUtil.traceId() }
可空 String headerAuthorization header 中公共但会变的参数
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
// 1、初始化网络库的时候定义
required Map<String, dynamic> headerCommonFixParams, // header 中公共但不变的参数
String? headerAuthorization, // header 中公共但【基本不会变】的参数--Authorization
Map<String, dynamic> Function()? headerCommonChangeParamsGetBlock, // header 中公共但会变的参数
// 示例:
headerCommonChangeParamsGetBlock: () {
return {
'trace_id': TraceUtil.traceId(),
};
},
// 1.2、初始化网络库的时候使用 headerCommonFixParams
// headers 的所有参数处理
Map<String, dynamic> headers = {};
// ① header 中公共但【固定不变】的参数
headers.addAll(headerCommonFixParams);
// ② header 中公共但【基本不会变】的参数--Authorization--策略:因为这只是一次性取值,后面的没法自动变化,需要调用update接口
if (headerAuthorization != null && headerAuthorization.isNotEmpty) {
headers.addAll({'Authorization': headerAuthorization});
}
// 将 header 参数设置到网络单例中
dio!.options = dio!.options.copyWith(
baseUrl: baseUrl,
connectTimeout: connectTimeout,
receiveTimeout: receiveTimeout,
contentType: contentType,
headers: headers,
);

// 2、具体发起网络请求里进行: headers 的所有参数处理
if (options.headers != null) {
if (optionsHeaderCommonChangeParamsGetBlock != null) {
Map<String, dynamic> customHeaders =
optionsHeaderCommonChangeParamsGetBlock();
options.headers!.addAll(customHeaders);
}
}

1、【固定的】公共请求参数 headerCommonFixParams(7个)

获取方法形如:

1
2
3
4
headerCommonFixParams: await CommonParamsHelper.commonHeaderParams(
appFeatureType: appFeatureType,
channel: channelName,
);

参数介绍如下:

序号 类型 字段 值示例 用途
1 平台系统 platform iOS/Android
2 app的唯一标识 appType 区分出同一公司的多个app
3 版本号(1.月.日) version 1.04.30
4 版本编译号(月日时分)
安卓隔年需加+12
buildNumber 04301610
5 功能类型 appFeatureType dev、inner、formal 区分出内测、正式功能
6 渠道 packageChannel appstore
7 唯一设备码 deviceId 未登录时的关闭检查更新

1.1、平台(iOS/Android)

1.2、app类型(同一公司多个app)/App的唯一标识

1.3、版本号(1.月.日)

1.4、版本编译号(月日时分)

1.5、功能类型(蒲公英dev、内测inner、正式formal)

1.6、渠道

平台 应用商店 渠道名
iOS App Store general
Android 华为应用市场 huawei
Android 小米应用商店 Xiaomi
Android Vivo应用商店 vivo
Android ColorOS应用商店 oppo
Android 应用宝 yingyongbao
Android 360手机助手 360
Android 百度手机助手 baidu
Android 酷安应用市场 kuan
Android 阿里应用市场 alibaba
Android 官网包 general

1.7、DeviceId 唯一设备码

Android 端主要取 Android ID

iOS 端先尝试获取 IDFA,如果获取不到,则取 IDFV

2、【变化的】公共请求参数 headerCommonChangeParamsGetBlock

获取方法形如:

1
2
3
4
5
6
headerCommonChangeParamsGetBlock: () {
return {
'trace_id': TraceUtil.traceId(),
'Authorization': headerAuthorization,
};
},

参数介绍如下:

序号 类型 字段 值示例 其他
1 用户token Authorization bearer clientApp247deea3-da67-47bc-94f7-04b83ca0fb07
2 跟踪id trace_id 1683158692451101/2023-05-04 08:04:52/_sy_]onw^w^

2.1、用户 Authorization

该值为登录时候,后台接口返回。且需要在用户状态变更的时候同步更新。

2.2、跟踪 trace_id

组成部分:时间戳/时间字符串/10位随机字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import 'dart:math';
import 'package:flutter/foundation.dart';

class TraceUtil {
static String traceId() {
String timeStamp = DateTime.now().microsecondsSinceEpoch.toString();
String timeString = DateTime.now().toString().substring(0, 19);
String randomString = TraceUtil.generateRandomString(10);
String traceId = "$timeStamp/$timeString/_$randomString";
return traceId;
}

// Declare a fucntion for reusable purpose
static String generateRandomString(int len) {
final _random = Random();
final result = String.fromCharCodes(
List.generate(len, (index) => _random.nextInt(33) + 89));
return result;
}
}

三、设置request的BODY公共入参(当不放header时候)

详见 《埋点规范》–【埋点入参】–固定的公共入参 其实可考虑放在HEADER中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1、初始化网络库的时候定义
required Map<String, dynamic> bodyCommonFixParams, // body 中公共但不变的参数
Map<String, dynamic> Function()? bodyCommonChangeParamsGetBlock, // body 中公共但会变的参数(一般可考虑放在header中)

// 2、具体发起网络请求里进行: body 的所有参数处理
// ① body 中公共但不变的参数
Map<String, dynamic> allParams = Map.from(_bodyCommonFixParams);
// ② body 中公共但会变的参数
if (_bodyCommonChangeParamsGetBlock != null) {
allParams.addAll(_bodyCommonChangeParamsGetBlock!());
}
// ③ body 中自定义的其他参数
if (customParams != null && customParams.isNotEmpty) {
allParams.addAll(customParams);
}

四、response返回结构介绍

为了准确判断业务是否成功,通常需要同时判断status和errorCode两个字段。

1
2
3
4
5
6
7
8
{
"status": 200,
"message": "请求成功,但是业务处理失败",
"errorCode": 1001,
"errorMessage": "参数name不能为空",
"data": null,
"timestamp": "2023-05-04T09:00:00Z"
}
  1. 状态码(status code):表示请求的处理结果,常见的状态码包括200(请求成功)、400(请求参数错误)、401(未授权)、403(禁止访问)、404(请求的资源不存在)等。
  2. 消息(message):对请求处理结果的简要描述,可以用于前端展示错误信息或者提示。
  3. 数据(data):请求处理成功后返回的数据,例如json格式的数据、图片、文件等。
  4. 时间戳(timestamp):表示服务器处理请求的时间,可以用于前端调试和日志记录。
1
2
3
4
5
6
7
8
9
10
if (response.status >= 200 && response.status < 300) {
// 请求成功,继续处理业务逻辑
if (response.errorCode) {
// 业务逻辑处理失败,根据errorCode和errorMessage进行相应的处理
} else {
// 业务逻辑处理成功,根据data字段获取返回的数据
}
} else {
// 请求失败,根据错误码和错误信息进行相应的处理
}

附1:不建议把status和errorCode用同一个字段表示

将状态码(status code)和业务错误码(error code)合并成一个字段可能会带来一些问题,建议不要采用这种方法。

首先,将状态码和业务错误码合并在一个字段中会导致客户端处理逻辑复杂化。客户端需要在处理请求结果时先判断状态码,然后再解析业务错误码,这会增加客户端代码的复杂度,降低代码的可维护性。

其次,将状态码和业务错误码合并在一个字段中还会带来一些歧义。状态码是HTTP协议定义的标准字段,具有固定的含义,例如200表示请求成功,400表示请求参数错误,500表示服务器内部错误等。而业务错误码是根据具体业务定义的,具有一定的灵活性。将这两种含义合并在一起会导致状态码的含义变得模糊,不利于代码的可读性和维护性。

因此,建议在response结构中分别使用状态码和业务错误码两个字段来表示请求的处理结果。这样可以使客户端处理逻辑更加清晰,并且可以使状态码和业务错误码的含义保持清晰和统一。

五、埋点的入参

详见埋点规范

五、请求接口的整理规范

背景:为了能够将请求接口的用途和页面关联起来。

目的:①便于省去后端频繁询问该页面该位置调用的是什么接口(虽然已有日志,但是仍有部分后端人员懒到不愿去自己排查)。

②完成前端依赖服务关系的梳理,并一直到后台各级服务-数据层。

序号 参数 必填 描述与其目的 purpose 策略
1.1 caller_page 必填 接口调用者(页面、管理器等)
①多页面调用接口:告知后台,以对业务进行逻辑区分处理
②日志使用:方便查看与统计每个页面调用的接口
可全局记录获当前页面
eg: goods_detail_page
1.2 caller_view 可空 接口调用者(视图)
同上(有时候一个page下有多个tab)
必须请求单独设置
eg: sku_choose_window
2 caller_times 必填 调用场景
①页面打开:open_page
②页面输入:input
②页面点击:click
open_page\ input\ click
3 purpose 必填 接口用途中文
①日志使用:便于快速/直接筛选出问题根源接口
eg: 商品搜索
4 byApper 必填 前台具体开发者的id eg:byQian
5 byApier 必填 后端具体开发者的id eg:byApier2

方案:

将 purpose 添加到 body 的参数中

1
_params.addPurpose(caller_page: "商城页", caller_time:open_page|click, purpose: "商品搜索").byQian.byApier2;

请求接口的整理产出效果示例如下:

请求接口整理

该图来源于脑图: 请求接口的整理规范.xmind

附:多处页面调用的公共接口提取出,描述在页面中调用的公共接口的部分添加颜色或者其他标记来标识使用的是哪个部分的公共接口。

六、网络库的加强

1、网络日志加强

目的:查看日志的时候能清晰的知道接口的中文含义,以及调用其的页面或者其他类或实例。

方法:每个接口请求时候添加对应页面。

2、网络时长统计

目的:统计加载每个页面的过程中,所消耗的网络时长。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
NetworkUtil.requestApi1(caller:aPage, belongLoadPage:true)

requestUrl(caller, belongLoadPage) {
if (belongLoadPage != true) {
return;
}

PageRequestManager.
map[caller]["apiStartCount"] = map[caller]["apiStartCount"] + 1
dio.request({
url: url,
completeCallback: () {
int callerApiStartCount = map[caller]["apiStartCount"];
int callerApiEndCount = map[caller]["apiEndCount"];
callerApiEndCount++;
map[caller]["apiEndCount"] = callerApiEndCount;
if (callerApiEndCount == callerApiStartCount) {
_pageLoadingEnd();
}
},
);
}

End

妙用注解

[toc]

妙用注解

前言

一、 为什么要使用注解开发

在日常开发过程中,我们经常会面临以下挑战:大量重复的模板代码、臃肿的代码降低可读性,以及JSON序列化和反序列化问题。为应对这些问题,各种编程语言都有自己的解决方案。在Dart语言中,采用了注解这一高效手段来解决这些问题。

注解就像是一把神奇的钥匙,为我们解锁了许多高效开发的大门。以下是使用注解的一些原因:

  1. 自动生成代码:注解就像会魔法的小精灵,能够在编译时自动生成代码,减轻了我们手动编写重复代码的负担,让开发变得更加轻松。
  2. 简化代码:注解可以减少重复代码,提高代码质量。
  3. 提高可读性:注解可以将元数据与实际代码分离,使代码更易于理解。
  4. 扩展功能:通过注解和注解处理器,我们可以在编译时执行一些额外操作,例如代码检查、代码优化等

二、什么是注解?

注解(Annotation)是一种编程语言特性,它不属于特定的架构或框架,而是可以在多种编程语言和开发框架中使用的一种工具。注解提供了一种方式,允许开发者在代码中添加元数据,这些元数据可以由编译器、运行时环境或其他工具在不同阶段使用。

在Dart语言中,注解是一种特殊的语法,用于在编译时或运行时向代码添加额外的元数据。注解以@符号开头,后跟一个编译时常量表达式。这种元数据可以用于指导工具(如静态分析器、编译器和构建器)执行特定操作,例如代码生成、静态检查和优化。注解不会影响程序的执行过程,但可以在编译时或运行时被工具和库访问,以实现各种目的。

三、注解实践:使用注解为每个页面类添加页面描述信息,埋点中经常需要

1、iOS:使用宏定义实现注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 定义宏,用于创建页面信息注解
#define CreatePageInfoAnnotation(pageClass, pageKey, pageDes) \
@interface pageClass (PageInfo) \
@property (nonatomic, readonly) NSString *pageKey; \
@property (nonatomic, readonly) NSString *pageDescription; \
@end \
@implementation pageClass (PageInfo) \
- (NSString *)pageKey { \
return pageKey; \
} \
- (NSString *)pageDescription { \
return pageDes; \
} \
@end


// LoginViewController.m
@PageInfoAnnotation(LoginViewController, @"login_page", @"登录页面") // 调用宏,为类添加注解
@implementation LoginViewController
@end

2、Flutter:使用source_gen库生成文件实现注解

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
// page_info_generator.dart
import 'package:source_gen/source_gen.dart';
class TestGenerator extends GeneratorForAnnotation<CJPageInfoMetadata> {
@override
String? generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
String className = element.name;
String? pageKey = annotation.read("pageKey");
String? pageDesc = annotation.read("pageDesc");
return """
extension ${className}APTExtension on ${className} {
String? apt_pageKey() {
return ${pageKey};
}
String? apt_pageDesc() {
return ${pageDesc};
}
}
""";
}
}

// TestModel.dart
@CJPageInfoMetadata("我是页面描述", 'home_page')
class TestModel {
// 属性
int age;
int bookNum;

// 方法
void fun1() {}
void fun2(int a) {}
}

结果就会生成一堆dart文件。

四、进入页面前的登录判断

其他参考文章:自定义iOS注解

1、最差做法

1
2
3
4
5
6
7
8
9
10
11
12
// HomeViewController.m
- (void)goMineHomePage {
// 进入个人主页前,需要先判断是否登录
if (!UserManager.isLogin) {
LoginViewController *viewController = [[MineHomeViewController alloc] init];
[self.navigationController pushViewController:viewController animated:YES];
return;
}

MineHomeViewController *viewController = [[MineHomeViewController alloc] init];
[self.navigationController pushViewController:viewController animated:YES];
}

2、统一到路由中优化:在路由中拦截+拦截器(可选)

所有跳转使用 RouterManger,在RouterManager中限制未登录允许的页面。

2.1、常规路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// HomeViewController.m
- (void)goMineHomePage {
[RouterManger push:RouterNames.mine_home_page argument:nil];
}

// RouterManger.m
- (void)push:(String *)pageName argument:(NSDictionary *)argument {
if (!UserManager.isLogin) { // 进入个人主页前,需要先判断是否登录
if ([RouterNames.mine_home_page].contains(pageName)) {
LoginViewController *viewController = [[MineHomeViewController alloc] init];
[topVC pushViewController:viewController animated:YES];
return;
}
}

getVCHandle = [HandleManager.handleMap objectForKey:pageName];
UIViewController *viewController = getVCHandle(argument:argument);
[topVC pushViewController:viewController animated:YES];
}

2.2、优化路由,引进拦截器:对路由中的跳转进行改进,使用拦截器

改进后的代码如下:

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
// HomeViewController.m
- (void)goMineHomePage {
[RouterManger push:RouterNames.mine_home_page argument:nil];
}

// RouterManger.m 优化为拦截器
- (void)push:(String *)pageName argument:(NSDictionary *)argument {
for (var interceptor in interceptors) {
bool canPush = [interceptor canPushPageName:pageName arguments: arguments];
if (!canPush) return;
}

getVCHandle = [HandleManager.handleMap objectForKey:pageName];
UIViewController *viewController = getVCHandle(argument:argument);
[topVC pushViewController:viewController animated:YES];
}

// LoginInterceptor.m
- (BOOL)canPushPageName:(NSString *)pageName arguments:(NSDictionary *)arguments {
if (!UserManager.isLogin) { // 进入个人主页前,需要先判断是否登录
if ([RouterNames.mine_home_page].contains(pageName)) {
LoginViewController *viewController = [[MineHomeViewController alloc] init];
[topVC pushViewController:viewController animated:YES];
return NO;
}
}
return YES;
}

3、在调用原始方法之前插入额外的执行逻辑(不推荐,不使用)

《在调用原始方法之前插入额外的执行逻辑》

三、常见的注解示例

以下内容摘自:Flutter 注解开发

  1. @override:这个注解表示一个方法覆盖了父类的方法。它可以帮助我们检查是否正确地实现了方法覆盖,如果没有正确实现,编译器会给出警告。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Animal {
    String makeSound() {
    return "makeSound";
    }
    }

    class Dog extends Animal {
    @override
    void makeSound() {
    print('makeSound');
    }
    }
  2. @JsonSerializable():这个注解就是我们项目中使用到的json_annotation库的@JsonSerializable()注解。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @JsonSerializable()
    class ConversationListEntity {
    String? nextSeq;
    late List<ConversationListResList> resList;

    ConversationListEntity();

    factory ConversationListEntity.fromJson(Map<String, dynamic> json) => $ConversationListEntityFromJson(json);

    Map<String, dynamic> toJson() => $ConversationListEntityToJson(this);

    @override
    String toString() {
    return jsonEncode(this);
    }
    }
  3. 我们项目中的@RootView(jumpId: "1005"):用来为订单详情页面生成路由代码。

    1
    2
    3
    4
    5
    6
    7
    @RootView(jumpId: "1005")
    class OrderDetail extends BaseWidget {
    OrderDetail({Key? key}) : super();

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

附1、iOS注解原理介绍

1、在oc中实现注解一样的东西肯定是通过宏来实现的。

2、在oc中实现注解主要有4个方向:代码中注解,类注解,属性注解,方法注解。

其中代码中注解的形式应该是最常见的。你说没见过?libextobjc中那一套@weakify和@strongify你们总用得很爽吧。拆开看看这就是做代码中注解的标准形式了。而类注解,考虑了很久暂时还是误解状态,因此也没法讲了。因此实现属性注解和方法注解是关键。

3、既然是宏实现的,因此注解宏展开后应该是实际能够在对应的段落实际有效的语法才对。另外为了和面向对象的oc类型进行关联,因此在oc中可以随便乱写的c代码当然很难办了。因此我们可以做的宏很快就限定下来了,在属性中宏展开新的属性,在方法中宏展开新的方法。

在oc中实现注解一样的东西

实现

首先实现一下方法注解。由于我们知道我们需要展开方法,因此我们很快就能写出这样的宏:

1
2
3
4
5
6
7
8
// __COUNTER__ 这个宏每次使用都会自动+1
// ## 是宏的直接串接
#define path(x) \
- (id)__klmurl_path_##__COUNTER__() { \
return x ;\
}

所以结果是 __klmurl_path_1

@是哪儿来的,毕竟宏里面本来是不允许有这样的符号的。原来强项在展开的内容前加了无用的带@的表达式:参考 @weakify 其也只是宏定义weakify

1
2
3
4
5
6
#define weakify(...) \
rac_keywordify \
metamacro_foreach_cxt(rac_weakify_,, __weak, __VA_ARGS__)

#define rac_keywordify autoreleasepool {}
#define rac_keywordify try {} @catch (...) {}

这样才能很自然的串一个@。

优化考虑有什么带@的而且没有什么卵用的标示呢。@compatibility_alias

这一段展开后的代码是:

1
2
3
4
5
6
7
8
9
// __COUNTER__ 这个宏每次使用都会自动+1
// ## 是宏的直接串接
#define path(x) \
compatibility_alias _KLMURL_0 KLMURL;
- (id)__klmurl_path_##__COUNTER__() { \
return x ;\
}

所以结果是 __klmurl_path_1

附1、Flutter 注解

参考文章:Flutter 注解处理及代码生成

1、注解的调用和执行结果

对 TestModel 类进行注解

1
2
3
4
5
6
7
8
9
10
@CJPageInfoMetadata("我是页面描述", 'home_page')
class TestModel {
// 属性
int age;
int bookNum;

// 方法
void fun1() {}
void fun2(int a) {}
}

以要生成如下 TestModel.g.dart 文件为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// GENERATED CODE - DO NOT MODIFY BY HAND

// **************************************************************************
// CJPageInfoMetadata
// **************************************************************************

extension TestModelAPTExtension on TestModel {
String? apt_pageKey() {
return "home_page";
}
String? apt_pageDesc() {
return "我是页面描述";
}
}

2、注解内部的实现

2.1、注解及生成器

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
// 注解
class CJPageInfoMetadata {
final String pageDesc;
final String pageKey;

const CJPageInfoMetadata(this.pageDesc, this.pageKey);
}

// 生成器
import 'package:source_gen/source_gen.dart';
class TestGenerator extends GeneratorForAnnotation<CJPageInfoMetadata> {
@override
String? generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
String className = element.name;
String? pageKey = annotation.read("pageKey");
String? pageDesc = annotation.read("pageDesc");
return """
extension ${className}APTExtension on ${className} {
String? apt_pageKey() {
return ${pageKey};
}
String? apt_pageDesc() {
return ${pageDesc};
}
}
""";
}
}

2.2、使用注解生成代码的运行结果

命令执行成功后将会生成一个新的文件:TestModel.g.dart 其内容:

最后生成的文件为

2.2、生成器中 Element element, ConstantReader annotation, BuildStep buildStep 各值

2.2.1、Element element:要注解的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
element.toString: class TestModel
element.name: TestModel // 要注解的对象的【名】:TestModel
element.metadata: [@CJPageInfoMetadata("我是页面描述", 'home_page')]
element.kind: CLASS // 元素类型有 CLASSFIELDFUNCTION 等,
element.displayName: TestModel
element.fields: [int age, int bookNum] // 要注解的对象的【属性】:[int age, int bookNum]
element.methods: [fun1() → void, fun2(int a) → void] // 要注解的对象的【方法名】:[fun1() → void, ...]
element.documentationComment: null
element.enclosingElement: flutter_annotation|lib/demo_class.dart
element.hasAlwaysThrows: false
element.hasDeprecated: false
element.hasFactory: false
element.hasIsTest: false
element.hasLiteral: false
element.hasOverride: false
element.hasProtected: false
element.hasRequired: false
element.isPrivate: false
element.isPublic: true
element.isSynthetic: false
element.nameLength: 9
element.runtimeType: ClassElementImpl
...
2.2.2、ConstantReader annotation:对注解对象添加的注解信息
1
2
3
4
annotation.runtimeType: _DartObjectConstant
annotation.read("pageDesc"): '我是页面描述'
annotation.read("pageKey"): 'home_page'
annotation.objectValue: CJPageInfoMetadata (pageDesc = String ('我是页面描述'); pageKey = String ('home_page'))
2.2.3、BuildStep buildStep :提供的是该次构建的输入输出信息:
1
2
3
4
5
6
7
8
9
buildStep.runtimeType: BuildStepImpl
buildStep.inputId.path: lib/demo_class.dart
buildStep.inputId.extension: .dart
buildStep.inputId.package: flutter_annotation
buildStep.inputId.uri: package:flutter_annotation/demo_class.dart
buildStep.inputId.pathSegments: [lib, demo_class.dart]
buildStep.expectedOutputs.path: lib/demo_class.g.dart
buildStep.expectedOutputs.extension: .dart
buildStep.expectedOutputs.package: flutter_annotation

3、使用注解生成代码前的准备

注解的生成器 Generator 的执行需要 Builder 来触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1、使用Generator 创建一个Builder
Builder testBuilder(BuilderOptions options) => LibraryBuilder(TestGenerator());

// 2、创建 build.yaml 文件,将我们要执行的 builder 配置进去
builders:
testBuilder:
import: "package:flutter_annotation/test.dart"
builder_factories: ["testBuilder"]
build_extensions: {".dart": [".g.part"]}
auto_apply: root_package
build_to: source

// 3、运行 Builder,命令执行成功后将会生成一个新的文件:TestModel.g.dart 其内容:
flutter packages pub run build_runner build

Flutter注解的Builder的原理很像使用shell或者python脚本遍历文件的方式。

已知@Description是注解,其作用为提取 @Description 的方法描述到一个文件。

请说说@Description注解的内部实现(使用shell或者python脚本遍历文件的方式,不在本次讨论中)

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
// my_class.dart
class MyClass {
@Description('This method does something important.') // 注解
void myImportantMethod() {
print('Doing something important.');
}
}

// description_generator.dart
import 'package:source_gen/source_gen.dart';
import 'package:code_builder/code_builder.dart';
import 'package:annotations/annotations.dart'; // 引入上面创建的注解库

class DescriptionGenerator extends GeneratorForAnnotation<Description> {
@override
String? generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) { {
// 检查注解是否应用在方法上
if (element.isMethod) {
final description = annotation.read('value').stringValue;
final method = element.asMethod();

// 创建一个方法调用的描述文本
final code = Block.of([
Directive.code("print('Method ${method.name} description: $description');")
]);

// 返回生成的代码
return code.toString();
}

// 如果注解没有应用在方法上,返回空字符串
return '';
}
}

其他参考文章

https://pub.dev/packages/mustache_template

mustache_template 是一个用于Flutter的Dart模板库,它支持dart2jsdart2native

End

必备知识架构-算法

[toc]

稳定与不稳定:如果a原本在b的前面并且a=b,排序之后a仍然在b的前面那就是稳定排序,如果排序之后a可能会出现在b的后面则是不稳定排序。所以冒泡排序是稳定的。选择排序可能不稳定。

空间复杂度:是指算法在计算机内执行时所需的存储空间与n的规模之间的关系。

时间复杂度:排序的的总操作次数,与n的规模之间的关系。

以下是我们在面试中要问的主要问题:

1.数据结构:
a、 链表
b、 堆叠
c、 排队
d、 二叉树
e、 哈希表

2.算法:
a、 排序(像快速排序和归并排序),二分搜索法,贪婪算法。
b、 二叉树(比如遍历、构造、操作…)

iOS 开发中常用的排序(冒泡、选择、快速、插入、希尔、归并、基数)算法

排序算法 平均时间复杂度 其他时间复杂度 平均空间复杂度 是否是稳定性的
冒泡排序 O(n^2) 最好:O(n),已是有序的
最坏:O(n^2),是逆序的
O(1) 稳定
选择排序 O(n^2) —— O(1) 不稳定
快速排序 O(nlogn) 最好:O(nlogn),每次划分都非常平衡时
最坏:O(n^2),每次划分非常不均匀时
O(nlogn) 不稳定
不稳定发生在交换的时刻;
归并排序 O(nlogn) —— O(n) 稳定

因为冒泡和选择排序都是原地排序算法,不需要额外的存储空间。所以其空间复杂度都是O(1)。

归并排序需要额外的空间来存储合并过程中的临时数组,其空间复杂度O(n)。

11.5.3 快速排序为什么快

在Object-C中学习数据结构与算法之排序算法

二分搜索法,是一种【在有序数组中】查找某一特定元素的搜索算法。

iOS查找算法之二分查找

漫画:五分钟学会贪心算法

有一个背包,最多能承载150斤的重量,现在有7个物品,
重量分别为[35, 30, 60, 50, 40, 10, 25],
价值分别为[10, 40, 30, 50, 35, 40, 30],
应该如何选择才能使得我们的背包背走最多价值的物品?

局部最优解

1、每次都尽量选择当前【价值最高】的物品
重量分别为[35, 30🚗, 60, 50🚗, 40🚗, 10🚗, 25], 130
价值分别为[10, 40②✅, 30, 50①✅, 35④✅, 40③✅, 30], 165

2、每次都尽量选择当前【重量最小】的物品
重量分别为[35④, 30③, 60, 50, 40⑤, 10①, 25②], 140
价值分别为[10✅,40✅, 30, 50, 35✅, 40✅, 30✅], 155

3、每次都尽量选择当前【价值密度最高】的物品
重量分别为[35🚗, 30🚗, 60, 50🚗, 40, 10🚗, 25🚗], 150
价值分别为[10✅, 40✅, 30, 50✅, 35, 40✅, 30✅], 170
价值密度 [0.285⑤, 1.333②, 0.5, 1.0④, 0.875, 4.0①, 1.2③]

想象一下下列场景:

  1. 从通讯录中寻找某个联系人
  2. 从一大堆文件中寻找某个文件
  3. 到了影厅之后,寻找电影票上指定的座位

如果以上情况中,联系人、文件、影厅座位这些“数据”没有按照需要的顺序组织,如何找到想要的特定“数据”呢?会非常麻烦!所以说,对于需要搜索的数据,往往应该先排个序!

排序比较 & 对比归并排序与快速排序

image-20240902163103782

前言

1
2
3
4
5
6
// 交换数组中的两个元素
void swap(NSMutableArray *array, NSInteger i, NSInteger j) {
NSNumber *temp = array[i];
array[i] = array[j];
array[j] = temp;
}

算法图解

034-Data-AlgorithmCollect-iOS

Data-AlgorithmCollect-1

一、冒泡排序

实现一个冒泡排序或者快速排序

从小到大排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
假设45132,按从小到大排序
i = 0 的时候,j的相邻两个位置都要比较排一下位置:
j = 0 的时候:arr_M = 45132(a[0]和a[1]比得到的结果)
j = 1 的时候:arr_M = 41532(a[1]和a[2]比得到的结果)
j = 2 的时候:arr_M = 41352(a[2]和a[3]比得到的结果)
j = 3 的时候:arr_M = 41325(a[3]和a[4]比得到的结果)
i=0已经将第一个最大的值放到最后了。

i = 1 的时候,j的相邻两个位置都要比较排一下位置:
j = 0 的时候:arr_M = 14325
j = 1 的时候:arr_M = 13425
j = 2 的时候:arr_M = 13245
i=1已经将第二个最大的值放到最后了。

冒泡排序代码实现

二、选择排序

从数组的第a[i+1]个元素开始到第n个元素,寻找最小的元素(的位置)。(具体过程为:先设最小位置为i=0,从该位置往后逐一比较,若遇到比之小的则记下该最小值的位置,结束后交换);

先为a[0]取到最小值,再为a[1]取到最小值,再继续…..

从小到大排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
假设45132,按从小到大排序
i = 0 的时候,j的相邻两个位置都要比较排一下位置:
j = i+1=1 的时候:arr_M = 45132(此时a[0]的4和a[1]的5比较:得到的结果)
j = 2 的时候:arr_M = 15432(此时a[0]的4和a[2]的1比较:得到的结果)
j = 3 的时候:arr_M = 15432(此时a[0]的1和a[3]的3比较:得到的结果)
j = 4 的时候:arr_M = 15432(此时a[0]的1和a[4]的2比较:得到的结果)
i=0已经将第一个最小的值放到第一位了。

i = 1 的时候,j的相邻两个位置都要比较排一下位置:
j = 2 的时候:arr_M = 14532(a[1]和a[2]比得到的结果)
j = 3 的时候:arr_M = 13542(a[1]和a[3]比得到的结果)
j = 4 的时候:arr_M = 12543(a[1]和a[4]比得到的结果)
i=1已经将第二个最小的值放到最第二位了。

选择排序代码实现

三、快速排序

漫画:什么是快速排序?(完整版)

快速排序(Quicksort)是对冒泡排序的一种改进。

同冒泡排序一样,快速排序也属于交换排序,通过元素之间的比较和交换位置来达到排序的目的。

不同的是,冒泡排序在每一轮只把一个元素冒泡到数列的一端,而快速排序在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成了两个部分。这种思路就叫做分治法

1
2
3
4
5
6
7
8
// 快速排序函数 quickSort(array, 0, [array count] - 1);
void quickSort(NSMutableArray *array, NSInteger left, NSInteger right) {
if (left < right) {
NSInteger pivotIndex = partition(array, left, right);
quickSort(array, left, pivotIndex - 1);
quickSort(array, pivotIndex + 1, right);
}
}

快速排序代码实现

1、排序原理:

设要排序的数组是A[0]……A[N-1],首先任意选取一个数据(通常选用数组的第一个数)作为关键数据,

快排图快排图

然后将所有比它小的数都放到它左边,所有比它大的数都放到它右边,这个过程称为一趟快速排序。

2、排序流程

快速排序算法通过多次比较和交换来实现排序,其排序流程如下:

(1)首先设定一个分界值,通过该分界值将数组分成左右两部分。

(2)将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。此时,左边部分中各元素都小于或等于分界值,而右边部分中各元素都大于或等于分界值。

(3)然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。

(4)重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。

四、归并排序

Hello 算法 11.6 归并排序

图解排序算法(四)之归并排序

1、划分阶段:通过递归不断地将数组从中点处分开(以把序列分成元素尽可能相等的两半),将长数组的排序问题转换为短数组的排序问题。当子数组长度为 1 时终止划分,开始合并。

归并排序

归并排序思路简单,速度仅次于快速排序,为稳定排序算法,一般用于对总体无序,但是各子项相对有序的数列。

2、合并阶段:持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。

归并排序的合并过程如下:

  1. 初始化:创建两个指针,分别指向两个已排序数组的起始位置。
  2. 比较元素:比较两个指针所指向的元素。
  3. 选择较小元素将较小的元素放入临时数组,并移动该元素所在数组的指针。
  4. 重复比较:重复步骤2和3,直到其中一个数组的所有元素都被比较过。
  5. 复制剩余元素:如果一个数组的所有元素都已经被比较并复制到临时数组中,将另一个数组中剩余的元素直接复制到临时数组的末尾。
  6. 返回临时数组:临时数组现在是一个合并后的有序数组。

示例:要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。

img

附:深度优先遍历(DFS)和广度优先遍历(BFS)

深度优先遍历(Depth First Search, 简称 DFS)

img

广度优先遍历(Breath First Search, 简称 BFS) 也叫层序遍历,指的是从图的一个未遍历的节点出发,先遍历这个节点的相邻节点,再依次遍历每个相邻节点的相邻节点。

BFS 一般是解决最短路径问题。

img

iOS算法篇-leetcode题目记录

附:排序算法代码

1、冒泡排序代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)methodMaopao {
int array[5] = {4, 5, 1, 3, 2};

for (int i = 0; i < 5-1; i++) { //需要比较几遍
for(int j = 0; j < 5-1-i; j++) { //每一遍都从0开始到倒数第-i个
if (array[j] > array [j + 1]){
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}

for(int i = 0; i < 5; i++) {
printf("%d\n",array[i]);
}
}

2、选择排序代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func sort(items: Array<Int>) -> Array<Int> {
var list = items
for i in 0..<list.count {
//记录当前最小的数,比较i+1后更大的数进行记录
var minIndex = i
for j in i+1..<list.count {
if list[j] < list[minIndex] {
minIndex = j
}
}
// 交换
let temp = list[minIndex]
list[minIndex] = list[i]
list[i] = temp
}
return list
}

3、快速排序代码实现

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
//单趟 快速排序挖坑法
int PartSort2(int* a, int left, int right)
{
if (left >= right) return left;

int begin = left;
int end = right;
int key = a[left]; //保存要排序的值
while (begin < end) //当左右指针相遇时结束
{
// begin是坑,从后往前找比key小的值填到坑里
while (begin < end && a[end] >= key) {
end--;
}
a[begin] = a[end];

// 此时end位置是坑,从前往后比key大的值填到坑中
while (begin < end && a[begin] <= key) {
begin++;
}
a[end] = a[begin];
}

//begin和end相遇的地方是key对应的位置
a[end] = key;
return end; //返回排好位置的元素的下标
}

END

< 返回目录