普通视图

发现新文章,点击刷新页面。
昨天以前掘金 iOS

在H5页面的SSR中,客户端需要做哪些工作?

2025年4月13日 12:38

1、前言

作为前端开发,对于H5页面的SSR,我们一般只关心webview启动之后的工作,如数据请求、水合、渲染等。实际上,H5页面的SSR,需要和客户端高度配合,才能实现所需效果。比如在笔者的上一篇文章【 手把手带你实现 Vite+React 的简易 SSR 改造【含部分原理讲解】】中,简要提到了流式SSR+FCC优化这一工作便主要是借助客户端的缓存实现相应优化:
20250413105055.png

笔者除了前端开发外,对客户端开发也有一定了解(主要是iOS开发、flutter开发等),因此本文从iOS客户端的角度讲解所需要关注的细节,让读者对整个SSR流程更加了解。

2、客户端核心逻辑梳理

20250413115312.png

2-1 入口启动注册

在App启动时进行SSR模块的初始化(主要是注册自定义的 URL 协议类 SSRURLProtocol):

[RequestSSRHandler setup];

2-2 URL拦截和处理

  • SSRURLProtocol
    • 使用 NSURLProtocol 来拦截特定的网络请求。这些拦截基于请求 URL 的某些标志位或头字段。
    • canInitWithRequest: 方法用于判断是否对请求进行拦截,避免重复拦截,并进行必要的日志记录和异常处理。

2-3 请求发送与响应处理

  • RequestSSRHandler:
    • setup 方法设置与应用相关的请求参数,如 appkeyappverutdid 等,并进行统一管理。
    • sendRequest: 方法将请求发送给后端,可选择缓存命中的内容直接响应,优化响应时间。
    • ssrRequest:didReceiveResponse: 处理响应,检查缓存数据是否可用并适用的判断逻辑。
    • 对于特定错误进行降级处理(如网络错误),通过 requestOnline:receiver: 方法发起普通网络请求。

2-4 缓存管理

  • 缓存数据 ( SSRCacheData ) :

    • 负责序列化缓存的 HTML 数据,并保存相关的版本、过期时间等,用于快速响应请求。
    • 提供 getCacheHtmlsetCacheHtml 方法来管理缓存数据的存取,加速处理过程。
  • 缓存存储 ( FCCStorage ) :

    • 管理缓存的持久化。
    • 提供方法来保存、获取和移除缓存数据,以确保缓存的有效利用。
    • 使用特定查询参数和用户标识决定缓存的唯一键值,使缓存管理更具灵活性。

2-5 响应版本和开关校验

  • 在处理请求和响应时,对版本和开关的启用状态进行检查,确保缓存的正确性和适用性。
  • 确保在版本不匹配或开关关闭的情况下替代渲染方法,以保障应用的稳定性。

2-6 请求上下文管理

  • SSRRequestContext:
    • 管理请求的状态和数据,包括是否启用首 chunk 缓存、是否命中缓存、是否复用完缓存等。
    • produceResponse:produceData: 负责处理接收到的响应和数据,根据缓存状态进行处理,包括使用缓存、替代渲染、保存缓存等。
    • 提供 matchFirstChunkCache, saveFirstChunkCache, 和 matchFCC:cacheData:response: 等方法来管理首 chunk 缓存数据的匹配和存取。

2-7 错误处理与降级策略

  • 在请求失败的情况下,如果满足特定条件,会自动降级请求为普通在线请求,以保证系统的稳定性和用户体验。

3、附上WebViewController应有的一些逻辑

  1. WebView初始化与配置

    • 使用WKWebView,进行URL拦截。
    • 支持下拉刷新、自定义导航栏、状态栏样式等。
  2. 请求处理与拦截

    • 登录拦截。
    • URL安全校验,防止恶意链接。
    • 路由拦截,处理本地协议跳转。
  3. 性能监控与埋点

    • 页面加载时间统计。
    • 错误监控与上报。
    • 应用启动阶段H5页面加载的性能追踪。
  4. UI交互

    • 显示加载状态(自定义Loading动画)。
    • 处理横竖屏切换。
    • 导航栏返回按钮和关闭按钮的逻辑。
  5. 其他功能

    • Cookie同步、第三方验证、字体注入等。

了解客户端处理的这些逻辑之后,可以考虑h5页面首屏进一步的性能优化:webview预热和文档预请求,具体的实现逻辑需要笔者和读者一起去学习探索:
7CE0DEC0-5FBB-41A3-AC06-487735635733.png

Trae + SwiftUI 1 小时实现一个单词本 Mac App

作者 冯志浩
2025年4月13日 10:31

前言

在 AI 发展越来越好的现在,它的应用已经不仅仅限制于帮我们生成问题的答案,还可以直接通过对自然语言的理解帮助我们直接生成对象的代码。对于某些简单的场景,如模版代码实现、结构简单的 UI 绘制等,它现在已经做得很好,这对于程序员的生产力提升还是非常有帮助的。

接下来,我通过一个简单的单词本应用,来给大家展示一下 Trae 的真实体验。

应用功能

首先,我们需要将应用的功能通过自然语言去描述出来,比如这个单词本 App,主要包含三个功能,单词本、错词和已掌握三个模块,每个模块都是以列表的形式进行展示。单词本中的单词如果不熟悉可以添加到错词中,如果很熟悉就添加到已掌握中,且支持 SwiftData 。

下面是我梳理的需求描述:

  • 新建一个 Swift 文件,文件名为 Word,并在里面实现一个 Word 类,包含 title 字符串类型、isError 布尔类型、isMaster 布尔类型,需要支持 SwiftData
  • 生成一个长度为 50 的数组 words,元素为 Word 类型,title 为随机的英文单词,10 个元素 isErrortrue,5 个元素 isMastertrue,其余的 isErrorisMasterfalse
  • 在侧边栏实现三个按钮,标题分别为单词本,错题,已掌握,点击按钮切换右侧视图。
  • 单词本、错词、已掌握三个 detail 都为列表形式。
  • 单词本列表内容为 words 中的所有元素,表格样式包含一个文本展示单词,两个按钮,一个按钮是添加到错词,若该模型的 isErrortrue 隐藏该按钮,若为 false 才显示。点击该按钮,将该条数据模型的 isError 赋值为 true。一个按钮是已掌握,若该模型的 isMastertrue 隐藏该按钮,若为 false 才显示。点击该按钮,将该条数据模型的 isMaster 赋值为 true
  • 错词列表内容为 words 中 isErrortrue 的所有元素,表格样式包含一个文本展示单词,一个按钮已掌握,点击该按钮,将该条数据模型的 isMaster 赋值为 trueisError 赋值为 false
  • 已掌握列表内容为 wordsisMastertrue 的所有元素,表格样式包含一个文本展示单词,一个按钮移除,点击该按钮,将该条数据模型的 isMaster 赋值为 false

梳理完,我们就可以通过 Trae 进行代码创建了。

Trae

首先,我们创建一个 SwiftUI 的 macOS app,然后通过 Trae 打开该项目。接着在 AI 对话流中,通过 #Folder 来选定当前文件夹,将第一条需求复制进去点击回车即可生成。

截屏2025-04-13 10.15.14.png

对话流中会生成代码的详细解释,右侧是代码实现,头部有拒绝和接受的选项,点击接受,代码就会自动写入项目中。

其余的需求描述我们需要 #File 选定相应的文件进行需求转代码实现。这里就不一一举例赘述了。

下面让我们来看下 Trae 实现的效果:

录屏2025-04-13 10.18.01.gif

小瑕疵

在代码实现过程中,虽然大部分代码都是正确可编译通过的,但还是碰到了下面的两个小问题:

  • if words.isEmpty { generateWords() } 直接写在了 View 中,代码视图如下:
var body: some View {
NavigationSplitView {
    VStack {}
} detail: {
    if words.isEmpty { generateWords() } // 这里会编译报错
    List(words, id: \.self) { word in
    ...
    }
}

正确的代码:

var body: some View {
NavigationSplitView {
    VStack {}
} detail: {
    if words.isEmpty { generateWords() } // 这里会编译报错
    List(words, id: \.self) { word in
    ...
    }.onAppear {
        if words.isEmpty {
            generateWords()
        }
    }
}
  • if !word.isMaster 写成了 if!word.isMaster,这个错误感觉有点不应该...

总结

从这个小例子的使用感受上来说,对开发者的帮助肯定是正大于负的,比我想象中的要聪明很多。希望大家能够拥抱变化,早早的享受到 AI 的红利。

音视频学习笔记十六——图像处理之OpenCV基础一

2025年4月12日 17:21

题记:前文介绍GPUImage滤镜链的原理,但实际上要写出效果,还需要理解其中图片处理的过程,所以本章开始会介绍一些OpenCV基础相关。图像处理需要用到很多专业的算法,本人业余学习略知皮毛,只是庶竭驽钝叙其所得,在音视频学习Demo有一些的示例。文章或代码若有错误,也希望大佬不吝赐教。

opencv绘图.jpg

一、OpenCV简介

OpenCV(Open Source Computer Vision Library)是一个开源的计算机视觉和机器学习库,广泛应用于人脸识别与生物识别、自动驾驶、工业检测等,核心功能包括:

  • 图像处理:滤波、边缘检测、几何变换、颜色空间转换、直方图均衡化等。
  • 视频分析:运动检测、光流检测等。
  • 特征提取与匹配:SIFT、SURF、ORB、角点检测等。
  • 目标检测与识别:Haar级联分类器(人脸检测)、HOG+SVM(行人检测)、深度学习模型(YOLO、SSD)。
  • 机器学习:支持向量机(SVM)、神经网络等算法。

二、基础操作

2.1. 输入/输出

// 读取图像
cv::Mat img = cv::imread("xxx/xxx.jpg", cv::IMREAD_COLOR);
// 保存图像
cv::imwrite("xxx/xxx.jpg", img);

cv::imshow(winname, img)创建窗口显示,移动端没有实现,iOS端转换为UIImage:

- (UIImage *)matToUIImage:(const cv::Mat&)mat {
    NSData *data = [NSData dataWithBytes:mat.data length:mat.elemSize() * mat.total()];
    CGColorSpaceRef colorSpace = mat.channels() == 1 ? CGColorSpaceCreateDeviceGray() : CGColorSpaceCreateDeviceRGB();
    
    CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)data);
    CGImageRef imageRef = CGImageCreate(mat.cols, mat.rows, 8, 8 * mat.channels(), mat.step[0], colorSpace, kCGImageAlphaNone|kCGBitmapByteOrderDefault, provider, NULL, false, kCGRenderingIntentDefault);
    UIImage *image = [UIImage imageWithCGImage:imageRef];
    CGImageRelease(imageRef);
    CGDataProviderRelease(provider);
    CGColorSpaceRelease(colorSpace);
    
    return image;
}

2.2. Mat对象

Mat基本是OpenCV中基本操作单元,可以从图片中读取(channels为BGR注意与移动端常用的RGB区别),也可以创建空矩阵。

// 空矩阵
cv::Mat emptyMat;

// 指定尺寸和类型(行,列,数据类型)
cv::Mat mat(480, 640, CV_8UC3);          // 3通道 8位无符号(BGR图像)
cv::Mat floatMat(100, 100, CV_32FC1);    // 单通道浮点矩阵

// 初始化值
cv::Mat redMat(100, 100, CV_8UC3, cv::Scalar(0, 0, 255)); // 全红色图像
cv::Mat ones = cv::Mat::ones(3, 3, CV_32F); // 全1矩阵

Mat的数据实际存储在u(UMatData)中,而data的内存管理,使用引用计数,可以使用mat.u->refcount查看引用的计数。

  • 浅拷贝:默认赋值或传参,共享数据内存

    cv::Mat shallow = mat;
    
  • 深拷贝:独立内存

    cv::Mat deep = mat.clone();
    // 或
    mat.copyTo(deep);
    

2.2.1. 访问和修改像素

  • 单通道(灰度):
uchar pixel = mat.at<uchar>(y, x); // 读取 (y,x) 处的值(注意行列顺序!)
mat.at<uchar>(y, x) = 255;         // 修改
  • 多通道(如 BGR 图像):
cv::Vec3b& pixel = mat.at<cv::Vec3b>(y, x); 
pixel[0] = 255; // 蓝色通道
pixel[1] = 0;   // 绿色通道
pixel[2] = 0;   // 红色通道
  • 使用指针高效遍历:
for (int i = 0; i < mat.rows; i++) {
    uchar* row = mat.ptr<uchar>(i);
    for (int j = 0; j < mat.cols; j++) {
        row[j] = ...; // 修改像素
    }
}

2.2.2. 图像处理操作

  • 调整大小:
cv::Mat resized;
cv::resize(inputMat, resized, cv::Size(newWidth, newHeight));
  • 颜色空间转换:
cv::Mat gray;
cv::cvtColor(colorMat, gray, cv::COLOR_BGR2GRAY);
  • 旋转:
cv::Mat rotated;
cv::rotate(inputMat, rotated, cv::ROTATE_90_CLOCKWISE);
旋转.jpg
  • 裁剪 ROI(Region of Interest):
int x = (cols - 200) / 2;
int y = (rows - 200) / 2;
cv::Rect roi_rect(x, y, 200, 200);
roi显示.jpg

矩阵运算

cv::Mat A = ... , B = ... , C;
cv::add(A, B, C);           // 矩阵加法
cv::multiply(A, B, C);      // 逐元素乘法
C = A * B;                  // 矩阵乘法(非逐元素)
cv::transpose(A, C);        // 转置

数据类型转换

cv::Mat floatMat;
mat.convertTo(floatMat, CV_32F, 1.0/255.0); // 转为浮点并归一化
  • 单通道显示

cv::split分离通道操作,注意是按照BGR的顺序,所以R通道为channels[2]

std::vector<cv::Mat> channels;
cv::split(mat, channels);

// 创建零矩阵并合并三通道
cv::Mat zeroMat = cv::Mat::zeros(mat.size(), CV_8UC1);
std::vector<cv::Mat> mergedChannels{zeroMat, zeroMat, channels[2]};
cv::Mat des;
cv::merge(mergedChannels, des);
红色通道.jpg

Blend效果

// 调整img2尺寸与输入图像匹配
cv::resize(img2, img2, mat.size(), 0, 0, cv::INTER_LINEAR);
// 使用addWeighted进行混合
cv::addWeighted(mat, 0.6, img2, 0.4, 0.0, blended);
融合.jpg

二值操作

cv::Mat gray, dst;
// 转换为灰度图像
cv::cvtColor(mat, gray, cv::COLOR_BGR2GRAY);

// 应用Otsu二值化
cv::threshold(gray, dst, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);
return dst;
二值效果.jpg

三、形态学处理

形态学是一类基于图像形状的图像处理技术,以下图为例,看一下形态学的变化。形态学是对实际上会对各个通道进行独立操作,默认是对单通道图像(如灰度图或二值图)操作,所以一般使用二值图看效果。

burr.jpg

3.1. 腐蚀

腐蚀操作原理是取邻域最小值,如下图,处理像素点1时,检查周围像素点(MORPH_RECT),取色值最小的点(右下),所以当前的点变成黑色。换个角度,黑色像素点会把周围点都变成黑色,像黑色来腐蚀了白色。

腐蚀原理.jpg

代码如下:

cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, mat, cv::MORPH_ERODE, kernel);

效果如图,整体变小了,毛刺少了很多。

腐蚀.jpg

3.2. 膨胀

膨胀操作原理是取邻域最大值,就是和腐蚀相反的操作。如下图,处理像素点1时,检查周围像素点(MORPH_RECT),取色值最大的点(右下),所以当前的点变成白色。换个角度,白色像素点会把周围点都染白,像白色像素进行了膨胀。

膨胀原理.jpg

代码如下:

cv::Mat dilated;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::dilate(mat, dilated, kernel);

效果如图,整体变大了,毛刺变得更粗壮了。

膨胀.jpg

3.3. 开运算

上述两种运算都会原来的形状(变大或缩小),而先腐蚀后膨胀就是开运算。开运算一般用于去噪,如下图,先腐蚀会让黑色区域变大,从而中间的白色噪点消失,再膨胀白色区域恢复(原来的噪点消失)。

开运算.jpg

代码如下:

cv::Mat opened;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5,5));
cv::morphologyEx(mat, opened, cv::MORPH_OPEN, kernel);

效果如图,可以通过改变kernel大小调整效果(5x5效果):

开运算效果.jpg

3.4. 闭运算

闭运算是开运算相反的操作先膨胀再腐蚀,运用孔洞填充,如下图字母T,由于打印或拍摄问题,有些像素点缺失。先膨胀就可以把区域连通,再腐蚀恢复成原来大小。

闭运算.jpg

代码如下:

cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5,5));
cv::morphologyEx(mat, mat, cv::MORPH_CLOSE, kernel);

效果如图,可以通过改变kernel大小调整效果(5x5效果),连通了毛刺中间的区域:

闭操作效果.jpg

3.5. 礼帽

礼帽操作是用原图-开运算,开运算作用是去毛刺,那么礼帽的作用就是获取图片中的毛刺,提取亮细节。

代码如下:

cv::Mat result;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, result, cv::MORPH_BLACKHAT, kernel);

效果如图,获取到毛刺:

礼帽.jpg

3.6. 黑帽

黑帽操作是用闭运算-原图,闭运算作用是连通,那么黑帽的作用就是提取暗细节。

黑帽原理.jpg

代码如下:

cv::Mat result;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3,3));
cv::morphologyEx(mat, result, cv::MORPH_BLACKHAT, kernel);

Socket 与 WebSocket 的区别是啥?

作者 90后晨仔
2025年4月12日 16:48

一、基础概念

1. Socket

  • 定义
    Socket 是操作系统提供的 网络通信接口,是应用程序与网络协议(如 TCP/UDP)之间的桥梁。它通过底层协议直接与网络交互,提供 全双工通信能力
    • 协议类型
      • TCP Socket:基于 TCP 协议,保证数据可靠传输(有序、无丢失)。
      • UDP Socket:基于 UDP 协议,提供低延迟传输(但不保证可靠性)。
    • 特点
      • 灵活性:可完全控制数据格式、连接状态和传输逻辑。
      • 低层控制:适合需要精细控制网络行为的场景(如游戏、物联网设备)。

2. WebSocket

  • 定义
    WebSocket 是一种 应用层协议,基于 TCP,通过 HTTP 协议升级为持久化双向连接。它通过一次握手后,建立长期稳定的通信通道。
    • 特点
      • 全双工通信:客户端和服务器可随时主动发送数据。
      • 轻量高效:数据帧头部仅 2-10 字节,传输效率高。
      • 兼容性:原生支持浏览器和移动设备,适合实时 Web 应用。

二、核心区别对比

1. 协议与连接方式

维度 Socket WebSocket
协议层级 传输层(TCP/UDP)或应用层(自定义) 应用层(基于 HTTP/TCP)
连接建立 通过 TCP 三次握手直接建立连接 通过 HTTP 协议升级(如 GET /ws
连接模式 短连接(需手动维护长连接) 长连接(持久化,一次建立长期有效)
数据格式 纯字节流(需自行解析) 帧格式(自动分隔和重组数据)

2. 数据传输与性能

维度 Socket WebSocket
传输效率 低延迟(直接 TCP) 实时性高,但略高于纯 TCP(因握手开销)
头部开销 无额外 HTTP 头 初始握手有 HTTP 头,后续数据头小
可靠性 TCP 保证可靠传输 基于 TCP,但需处理协议层错误

3. 开发复杂度与维护

维度 Socket WebSocket
连接管理 需手动处理心跳、重连、错误恢复 自动管理连接(内置心跳机制)
数据处理 需自行解析分包、处理编码/解码 自动处理数据分帧(如文本/二进制)
安全性 需手动实现加密(如 SSL/TLS) 支持 WSS(WebSocket over TLS)
防火墙穿透 可能被拦截(需开放特定端口) 通过 HTTP/80 或 HTTPS/443 穿透

三、iOS 开发实践

1. Socket 实现

  • 常用框架
    • GCDAsyncSocket(TCP):支持异步操作,适合复杂协议控制。
    • SwiftSocket(TCP/UDP):轻量级,适合简单通信。
  • 开发流程
    1. 建立连接:通过 connect(toHost:onPort:) 建立 TCP 连接。
    2. 数据收发:手动发送字节流(需协议解析)。
    3. 错误处理:监听 didDisconnectWithError 等回调。
  • 示例代码(TCP)
    let socket = GCDAsyncSocket()
    do {
        try socket.connect(toHost: "example.com", onPort: 8080, withTimeout: 10)
        socket.readData(withTimeout: -1, tag: 0)
    } catch {
        print("连接失败:\(error)")
    }
    

2. WebSocket 实现

  • 常用框架
    • Starscream:轻量级,支持文本/二进制消息。
    • Socket.IO:兼容多种传输协议(WebSocket 优先)。
  • 开发流程
    1. 建立连接:通过 connect() 发送 HTTP 升级请求。
    2. 消息监听:通过 onTextonData 等回调接收数据。
    3. 自动重连:支持配置重连策略(如指数退避)。
  • 示例代码(Starscream)
    import Starscream
    
    let socket = WebSocket(url: URL(string: "wss://example.com/ws")!)
    socket.onConnect = { print("连接成功") }
    socket.onText = { text in print("收到文本消息:\(text)") }
    socket.connect()
    

四、场景选择与性能优化

1. 适用场景

场景类型 Socket WebSocket
实时性要求极高 游戏、物联网设备控制(毫秒级响应) 聊天、股票行情(秒级响应)
低延迟传输 传感器数据流、在线游戏同步 实时消息推送、音视频同步
复杂协议控制 自定义二进制协议(如金融交易) 标准化协议(如聊天协议)
浏览器/移动端混合开发 需额外适配(如 WebSockets 桥接) 原生支持,适合跨平台实时通信

2. 性能优化

Socket 优化建议

  • 复用连接:避免频繁建立/关闭连接,复用长连接。
  • 数据压缩:使用二进制协议(如 Protobuf)减少传输体积。
  • 心跳机制:定期发送心跳包检测连接状态(如每 30 秒一次)。

WebSocket 优化建议

  • 消息队列:批量发送消息以减少网络开销。
  • 协议压缩:启用 Permessage-Deflate 压缩(需服务器支持)。
  • 服务器选择:使用高性能 WebSocket 服务器(如 Nginx、Socket.IO 服务端)。

五、总结:如何选择?

  • 选 Socket 的场景

    • 需要 底层协议控制(如自定义二进制协议)。
    • 高实时性要求(如游戏、传感器数据)。
    • 局域网或封闭环境(防火墙限制较少)。
  • 选 WebSocket 的场景

    • 快速开发:标准化协议,开发效率高。
    • 实时 Web 应用:聊天、在线协作、股票推送。
    • 混合开发:需要与浏览器或跨平台应用通信。

六、关键术语表

术语 解释
TCP 可靠传输协议,保证数据顺序和完整性。
UDP 无连接协议,低延迟但不保证可靠性。
HTTP 升级 WebSocket 通过 HTTP 请求升级连接。
Permessage-Deflate WebSocket 的数据压缩扩展协议。

iOS开发:关于路由

作者 season_zhu
2025年4月11日 16:22

在iOS开发中引入路由框架一直是一个有争议的话题。

因为即使不使用路由框架,似乎也不会有太大的影响。那么我们先来回顾一下几个典型的跳转场景:

从外部跳转到App

  1. Safari浏览器网页点击事件跳转到App的页面

  2. App在挂起或者杀死状态,收到推送跳转到App的页面

  3. 收到短信,短信里面有短链接跳转到App的页面

  4. 从邮件中的链接跳转到App的页面

  5. 从社交媒体应用(如微信、微博)中的链接跳转到App的页面

  6. 从App的小组件跳转到App的页面

  7. Siri、ShotCut进行跳转

App内部跳转

  1. 单一主工程,无业务模块依赖,页面间进行跳转

  2. 多模块工程,多个模块之间可以随意跳转

需要注意的是,对于多模块工程,模块可能是第三方开发的,并不遵守内部开发标准,这种情况不在考虑范围内。

路由中心

  1. 跳转到微信小程序:其实跳转到其他App的小程序也可以认为是这种业务场景。目前已经无法从微信小程序跳转到App了。点击查看
  2. 跳转到其他App:这种情况跳转API相对固定,传值也有规则,无需路由框架,不在我们的讨论之中。
  3. 跳转到Safari浏览器:这种情况跳转API也相对固定,传值也有规则,无需路由框架,不在我们的讨论之中。

image.png

上图展示了非常复杂的跳转场景。在日常开发过程中,比如推送跳转到不同页面,实际上是通过推送信息中的字符串创建一个枚举映射判断,不同的字段跳转到不同的页面。浏览器和短信消息以及App开屏广告跳转也是使用枚举映射的逻辑。

维护一个枚举映射表可以完成这个功能,但随着业务量的增大,这种方式虽然可行,但不够友好,下面是伪代码示例:


func pushToAppPage(model: LaunchAdModel,
                   tabbarController: UITabBarController,
                   navigationController: UINavigationController) {
        switch model.appPage {
        case "app_home_page":
            break
        case "app_message_center":/// 消息中心
            navigationController.pushViewController(MessageCenterController(), animated: true)
        case "app_message_center_detail":/// 公告详情
            let vc = AppMessageDetailController()
            vc.messageId = model.appItemId
            navigationController.pushViewController(vc, animated: true)
        case "community_topics":// 资讯
            break
        case "activity":
            break
        /// 业务增加会case也逐渐增加,如果入参规则不同,还需要不同的构建器,初始化方法和赋值

        }
}

如果使用路由框架将页面和路由表提前绑定,此时外部跳转进来,只需一行代码即可搞定。可以认为是将集中的枚举映射分散到了路由框架中。

其实我完全可以把上面的伪代码封装成一个路由中心,然后制定一系列入参传参规则来保证一致性,但是我也可以直接使用现有的框架来避免我重复造轮子,我只用了解框架的使用与传参规则就可以。 另外,良好的跳转逻辑,不仅需要移动端制定规则,还需要后端配合,完成数据下发的格式的对应。 可以想象一下再App中增加了一个路由中心,所有的跳转情况逻辑与跳转Action都由路由管理,然后再从路由中心发出去: image.png

TheRouter

我最近研究了一下相关框架,目前觉得TheRouter的功能和业务场景符合要求,因为它同时兼容OC,所以在某些语法上看起来很怪异。

其实所有的路由这种从前端借鉴过来的舶来物,总需要这样个过程:

  • 注册路由

  • 保证注册之后再使用路由

  • 异常路由侧进去了定义好的错误页面

比如Flutter中使用GetX的路由,我们会这样:


abstract class Routes {

  Routes._();

  static const coinRink = '/coinRink';

  static const unknown = "/unknown";

  ///页面合集

  static final routePage = [

    GetPage(
      name: coinRink,
      page: () => const CoinRankPage(),
      binding: CoinRankBinding(),
      middlewares: [LoginMiddleware()],
    ),

   GetPage(
      name: unknown,
      page: () => const UnknownPage(),
    ),
  ];

  static final unknownPage = GetPage(
    name: Routes.unknown,
    page: () => const UnknownPage(),
  );
}

TheRouter对比这种思路,手动注册之外,有一个我觉得很有特色功能就是通过runtime遍历进行路由的自动注册,减少了手动注册的不舒适度。


  let beginRegisterTime = CFAbsoluteTimeGetCurrent()

  var resultXLClass = [AnyClass]()

  let bundles = CFBundleGetAllBundles() as? [CFBundle]

  for bundle in bundles ?? [] {
      let identifier = CFBundleGetIdentifier(bundle);
      if let id = identifier as? String {
          if excludeCocoapods {
              if  id.hasPrefix(kSAppleSuffix) || id.hasPrefix(kSCocoaPodsSuffix) {
                  continue
              }
          } else {
              if  id.hasPrefix(kSAppleSuffix) {
                  continue
              }
          }
      }

      guard let execURL = CFBundleCopyExecutableURL(bundle) as NSURL? else { continue }
      let imageURL = execURL.fileSystemRepresentation
      let classCount = UnsafeMutablePointer<UInt32>.allocate(capacity: MemoryLayout<UInt32>.stride)
      guard let classNames = objc_copyClassNamesForImage(imageURL, classCount) else {
          continue
      }

      for idx in 0..<classCount.pointee {
          let currentClassName = String(cString: classNames[Int(idx)])
          guard let currentClass = NSClassFromString(currentClassName) else {
              continue
          }

          if class_getInstanceMethod(currentClass, NSSelectorFromString("methodSignatureForSelector:")) != nil,
             class_getInstanceMethod(currentClass, NSSelectorFromString("doesNotRecognizeSelector:")) != nil {

              if let cls =  currentClass as? UIViewController.Type {
                  resultXLClass.append(cls)
              }
          }
#if DEBUG
          if let clss = currentClass as? CustomRouterInfo.Type {
              apiArray.append(clss.patternString)
              classMapArray.append(clss.routerClass)
          }
#endif
      }
  }


  for i in 0 ..< resultXLClass.count {
      let currentClass: AnyClass = resultXLClass[i]
      if let cls = currentClass as? TheRouterable.Type {
          let fullName: String = NSStringFromClass(currentClass.self)
          if fullName.contains(kSADelegateClassSensorsSuffix)  {
              break
          }

          for s in 0 ..< cls.patternString.count {

              if fullName.contains(NSKVONotifyingPrefix) {
                  let range = fullName.index(fullName.startIndex, offsetBy: NSKVONotifyingPrefix.count)..<fullName.endIndex
                  let subString = fullName[range]
                  registerRouterList.append([TheRouterPath: cls.patternString[s], TheRouterClassName: "\(subString)", TheRouterPriority: "\(cls.priority)"])
              } else {
                  registerRouterList.append([TheRouterPath: cls.patternString[s], TheRouterClassName: fullName, TheRouterPriority: "\(cls.priority)"])
              }
          }

      } else if currentClass.self.conforms(to: TheRouterableProxy.self) {
          let fullName: String = NSStringFromClass(currentClass.self)
          if fullName.contains(kSADelegateClassSensorsSuffix)  {
              break
          }

          for s in 0 ..< currentClass.patternString().count {
              if fullName.contains(NSKVONotifyingPrefix) {
                  let range = fullName.index(fullName.startIndex, offsetBy: NSKVONotifyingPrefix.count)..<fullName.endIndex
                  let subString = fullName[range]
                  registerRouterList.append([TheRouterPath: currentClass.patternString()[s], TheRouterClassName: "\(subString)", TheRouterPriority: "\(String(describing: currentClass.priority()))"])
              } else {
                  registerRouterList.append([TheRouterPath: currentClass.patternString()[s], TheRouterClassName: fullName, TheRouterPriority: "\(String(describing: currentClass.priority()))"])
              }
          }
      }
  }
  let endRegisterTime = CFAbsoluteTimeGetCurrent()

另外需要注意,在最新的Xcode16下面,Debug模式下面自动注册runtime不起作用,需要修改一下工程配置:

Xcode16 下 Debug 模式 ENABLE_DEBUG_DYLIB 选项默认开启,开启之后 objc_copyClassNamesForImage 主工程 image 调用失败,Debug 模式下会使用 **.debug.dylib文件,所以会有点问题。可以先将ENABLE_DEBUG_DYLIB 关闭

image.png

github.com/HuolalaTech…

同时TheRouter会有一个强制校验过程,也就是必须在工程中手动维护一张路由表,来保证自动注册的路由表和手动注册的路由表一致,这种措施是为了保证在Debug环境下的一致性,当然如果工程不那么复杂,这个功能不用也罢:


/// - Parameters:
///   - excludeCocoapods: 排除一些非业务注册类,这里一般会将 "com.apple", "org.cocoapods" 进行过滤,但是如果组件化形式的,创建的BundleIdentifier也是
///   org.cocoapods,这里需要手动改下,否则组件内的类将不会被获取。
///   - urlPath: 将要打开的路由path
///   - userInfo: 路由传递的参数
///   - forceCheckEnable: 是否支持强制校验,强制校验要求Api声明与对应的类必须实现TheRouterAble协议
///   - forceCheckEnable 强制打开TheRouterApi定义的便捷类与实现TheRouterAble协议类是否相同,打开的话,debug环境会自动检测,避免线上出问题,建议打开
return TheRouterManager.addGloableRouter(true, url, userInfo, forceCheckEnable: false)

TheRouter

如果项目是多模块组成,传统的push与pop可能需要模块对外暴露Controller,以保证可以构建控制器与页面跳转。使用路由框架可以抹掉这些细节与传参、构造器方法,对外暴露跳转路径即可。

同时我也在思考,如果一个Flutter项目也是多模块的情况下,主工程无法知道子模块的暴露的Page,是如何维护路由表的呢?

截止我发文的时候,掘金的货拉拉又发了一篇文章,《iOS货运用户App组件路由器设计与实践》,不过目前被删除了,也不知道是个啥情况,反正就是说他们还有一套与TheRouter的不同的路由框架,嗯,好吧~

结论

  1. 路由框架并不是iOS开发的必备工具。如果外部跳转到App场景少,App内部跳转简单,单一工程,或者多模块但模块间跳转场景少、不复杂,可以不用。

  2. 路由带来方便的同时,可能会导致页面切换转场动画的固定化,因为路由的目的是打开页面,而页面相关的动画等,如果放在路由框架中,显然又不太合适,所以当存在路由框架时,在需要使用转场动画时,可能无法尽善尽美。

  3. 如果外部跳转和App内部跳转复杂,可以考虑使用路由框架,以减轻维护逻辑的编写。同时如果考虑双端一致性,甚至可以一次配置,双端可行。

❌
❌