普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月2日掘金 iOS

尝试给Lookin 支持 MCP

作者 FeliksLv
2026年3月1日 22:33

不知道大家在 Vibe Coding 的时候,是否经常遇到这样的情况,让 AI 修改一个复杂页面,改完之后发现布局乱了,只能通过文字描述让 AI 去改,还经常改不对。

在日常开发中,我们经常会使用 Lookin 来查看布局,我想能否给 Lookin 支持 MCP 查看布局+刷新。这样是不是就不用我们自己给 AI 描述问题了。

于是我开始了这个集成工作,用本文记录下整个过程。先放下最终效果:

path-image-1e9c6001a4ab420da34cb981b0080ae1.png

image.png

image.png

Lookin MCP 支持的所有方法

# 方法名 描述 参数
1 get_status 获取 Lookin 服务器状态和连接状态,返回是否有 iOS 应用连接以及是否有层级数据
2 list_apps 列出所有连接的 iOS 应用及其基本信息
3 get_hierarchy 获取已连接 iOS 应用的完整视图层级,返回所有视图及其属性、frame 和关系 flat: bool (可选) - 是否返回扁平数组
maxDepth: int (可选) - 最大遍历深度
4 get_view 通过 oid 获取指定视图的详细信息,包括 frame、bounds、类继承链等 oid: int (必需) - 视图对象 ID
5 get_screenshot 获取指定视图的截图,返回 base64 编码的 PNG 图片 oid: int (必需) - 视图对象 ID
6 search_views 按类名、文字内容或 oid 搜索视图 query: string (必需) - 搜索关键词
type: enum (可选) - 搜索类型: "class"/"text"/"oid"
7 list_viewcontrollers 列出应用中所有的 ViewController,包括类名、内存地址和关联的视图 oid
8 get_app_info 获取已连接 iOS 应用的详细信息,包括应用名、Bundle ID、设备名、OS 版本、屏幕尺寸
9 reload_hierarchy 重新加载视图层级数据,用于 UI 变化后刷新数据
10 get_view_attributes 获取视图的完整属性详情,包括所有属性组(Layout、AutoLayout、UILabel、UIScrollView 等)、事件处理器(手势、target-action)和 AutoLayout 约束 oid: int (必需) - 视图对象 ID

第一版-双进程

MCP 协议要求 Server 通过 stdio(标准输入/输出)与 Client 通信,这对于 GUI 应用来说是个问题:

  • macOS GUI 应用没有 stdin/stdout
  • GUI 应用不适合作为子进程被其他应用启动
  • Lookin 需要保持独立运行以维护与 iOS 设备的连接

所以第一版采用双进程架构:一个独立的命令行工具 lookin-mcp 处理 MCP 协议,通过 HTTP 与 Lookin 主应用通信。

sequenceDiagram
    participant AI as AI 工具
    participant MCP as lookin-mcp
    participant HTTP as LKMCPServer
    participant iOS as iOS App
    
    Note over AI,MCP: stdio (JSON-RPC)
    Note over MCP,HTTP: HTTP (REST)
    Note over HTTP,iOS: Peertalk/Bonjour
    
    AI->>MCP: tools/call (get_hierarchy)
    MCP->>HTTP: GET /hierarchy
    HTTP->>iOS: 获取视图数据
    iOS-->>HTTP: LookinHierarchyInfo
    HTTP-->>MCP: JSON Response
    MCP-->>AI: Tool Result
组件 角色 通信方式
lookin-mcp MCP Server stdio (JSON-RPC)
LKMCPServer HTTP Server HTTP REST API
Lookin.app 主应用 内嵌 HTTP Server

实现细节

1. 手写 MCP 协议

MCP 协议基于 JSON-RPC 2.0,需要实现请求/响应的解析和序列化:

struct JSONRPCRequest: Codable {
    let jsonrpc: String
    let id: RequestId?
    let method: String
    let params: AnyCodable?
}

struct JSONRPCResponse: Codable {
    let jsonrpc: String
    let id: RequestId?
    let result: AnyCodable?
    let error: JSONRPCError?
}

2. HTTP Server (LKMCPServer)

在 Lookin 主应用中内嵌一个轻量级 HTTP Server,监听 127.0.0.1:47199:

@implementation LKMCPServer

- (void)start {
    self.server = [[GCDWebServer alloc] init];
    
    // GET /status - 检查连接状态
    [self.server addHandlerForMethod:@"GET" path:@"/status" 
        requestClass:[GCDWebServerRequest class]
        processBlock:^GCDWebServerResponse *(GCDWebServerRequest *request) {
            return [self handleStatusRequest];
        }];
    
    // GET /hierarchy - 获取视图层级
    [self.server addHandlerForMethod:@"GET" path:@"/hierarchy" ...];
    
    // POST /reload - 刷新数据
    [self.server addHandlerForMethod:@"POST" path:@"/reload" ...];
    
    [self.server startWithPort:47199 bonjourName:nil];
}

@end

3. MCP Tools 定义

第一版实现了 8 个 Tools:

Tool 描述 HTTP 映射
status 检查连接状态 GET /status
get_hierarchy 获取视图层级 GET /hierarchy
get_view 获取视图详情 GET /view/:oid
search 搜索视图 GET /search?q=&type=
get_screenshot 获取视图截图 GET /screenshot/:oid
get_app_info 获取应用信息 GET /app-info
list_view_controllers 列出 VC GET /viewcontrollers
reload 刷新层级数据 POST /reload

4. 构建和部署

需要在 Xcode Build Phase 中添加脚本,将 lookin-mcp 复制到 app bundle:

# Scripts/build_mcp.sh
cd "${SRCROOT}/LookinMCP"
swift build -c release

mkdir -p "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Helpers"
cp ".build/release/lookin-mcp" "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Helpers/"

通信流程

启动流程:

sequenceDiagram
    participant User as 用户
    participant Lookin as Lookin.app
    participant Pref as LKPreferenceManager
    participant HTTP as LKMCPServer
    
    User->>Lookin: 启动应用
    Lookin->>Pref: 读取 enableMCPServer
    alt MCP Server 已启用
        Pref-->>Lookin: YES
        Lookin->>HTTP: start()
        HTTP-->>Lookin: 监听 127.0.0.1:47199
    else MCP Server 已禁用
        Pref-->>Lookin: NO
        Note over HTTP: 不启动
    end

查询流程:

sequenceDiagram
    participant AI as AI 工具
    participant MCP as lookin-mcp
    participant HTTP as LKMCPServer
    participant DS as DataSource
    
    AI->>MCP: {"method": "tools/call", "params": {"name": "get_hierarchy"}}
    MCP->>HTTP: GET http://127.0.0.1:47199/hierarchy
    HTTP->>DS: flatItems
    DS-->>HTTP: [LookinDisplayItem]
    HTTP-->>MCP: {"views": [...], "total": 150}
    MCP-->>AI: {"content": [{"type": "text", "text": "..."}]}

使用方式

以OpenCode为例,修改 ~/.config/opencode/opencode.json

{
  "mcp": {
    "lookin": {
        "type": "local",
        "command": "/Applications/Lookin.app/Contents/Helpers/lookin-mcp"
    }
  }
}

运行试了一下,可以拿到对应节点的视图信息。

1040g3g831t60a6umla06gl60tgb1aifea95vsf8.jpg

第一版的问题

虽然能用,但有几个不太满意的地方:

  1. 双进程架构复杂 - 需要维护两套代码,调试也麻烦
  2. 手写协议不可靠 - MCP 协议还在演进,手写实现容易出 bug
  3. 部署麻烦 - 需要在 Build Phase 中复制二进制文件
  4. 配置繁琐 - 用户需要手动修改 JSON 配置文件

第二版

MCP 是有 Swift 版本官方 SDK 的:

github.com/modelcontex…

既然有官方 SDK,为什么还要自己手写协议呢?而且第一版的双进程架构也有点复杂。于是我决定重构,目标是:

  1. 使用官方 Swift SDK 替换手写的 MCP 协议实现
  2. 将 MCP Server 内嵌到 Lookin 主应用,去掉独立进程
  3. 使用 HTTP Transport,简化用户配置

新架构

graph TB
    subgraph "AI 工具"
        AI[OpenCode / Claude / Cursor ...]
    end
    
    subgraph "Lookin.app"
        HTTP[NIO HTTP Server<br/>:47199/mcp]
        MCP[MCP Server<br/>官方 Swift SDK]
        DS[LKStaticHierarchyDataSource]
        Apps[LKAppsManager]
    end
    
    subgraph "iOS App"
        LookinServer[LookinServer SDK]
    end
    
    AI <-->|HTTP<br/>MCP Protocol| HTTP
    HTTP --> MCP
    MCP --> DS
    MCP --> Apps
    Apps <-->|USB/WiFi| LookinServer

对比一下两个版本:

对比项 第一版 第二版
协议实现 手写 JSON-RPC 官方 Swift SDK
进程模型 双进程 (stdio + HTTP) 单进程 (内嵌 HTTP)
通信方式 stdio → HTTP → 数据源 HTTP → 数据源
配置方式 修改配置文件 一行命令
依赖管理 复制二进制到 app bundle SPM 本地 Package

SDK 选型

官方 SDK 版本 0.11.0 支持多种 Transport:

  • StdioServerTransport: 传统的 stdio 方式
  • StreamableHTTPServerTransport: 有状态的 HTTP 流式传输
  • StatelessHTTPServerTransport: 无状态 HTTP,适合简单场景

我选择了 StatelessHTTPServerTransport,因为 Lookin 的场景不需要维护会话状态,每次请求都是独立的查询。

实现细节

1. LookinMCP Package 结构

重构后的 Package 变得更简洁:

LookinMCP/
├── Package.swift                    # SPM 配置,依赖官方 SDK
└── Sources/LookinMCP/
    ├── LookinMCPDataSource.swift    # 数据源协议 + 模型定义
    ├── LookinMCPServer.swift        # HTTP Server + MCP Server
    └── LookinMCPToolHandler.swift   # 9 个 Tools 的注册和处理

Package.swift 配置:

// swift-tools-version: 6.1
import PackageDescription

let package = Package(
    name: "LookinMCP",
    platforms: [.macOS(.v13)],
    products: [
        .library(name: "LookinMCP", targets: ["LookinMCP"]),
    ],
    dependencies: [
        .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.11.0"),
    ],
    targets: [
        .target(
            name: "LookinMCP",
            dependencies: [
                .product(name: "MCP", package: "swift-sdk"),
            ]
        ),
    ]
)

2. HTTP Server 实现

SDK 提供了 StatelessHTTPServerTransport,但需要自己搭建 HTTP Server。参考 SDK 示例,使用 swift-nio:

public func start() async throws {
    let bootstrap = ServerBootstrap(group: eventLoopGroup)
        .serverChannelOption(.backlog, value: 256)
        .childChannelInitializer { channel in
            channel.pipeline.configureHTTPServerPipeline().flatMap {
                channel.pipeline.addHandler(HTTPHandler(transport: self.transport))
            }
        }
    
    let channel = try await bootstrap.bind(host: host, port: port).get()
    // Server started on http://127.0.0.1:47199/mcp
}

3. Tool 注册

使用 SDK 的 withMethodHandler API 注册 Tools:

await server.withMethodHandler(ListTools.self) { _ in
    ListToolsResult(tools: [
        Tool(name: "status", description: "Check Lookin connection status"),
        Tool(name: "get_hierarchy", description: "Get view hierarchy", inputSchema: ...),
        // ... 更多 tools
    ])
}

await server.withMethodHandler(CallTool.self) { params in
    switch params.name {
    case "status":
        let status = await dataSource.getStatus()
        return CallToolResult(content: [.text(status.toJSON())])
    case "get_hierarchy":
        // ...
    }
}

4. 主应用集成

AppDelegate.m 中启动 MCP Server:

if ([LKPreferenceManager mainManager].enableMCPServer) {
    [[MCPServerManager shared] start];
}

MCPServerManager 是一个 Swift 类,提供 @objc 接口供 Obj-C 调用:

@objc(MCPServerManager)
@MainActor
final class MCPServerManager: NSObject {
    @objc static let shared = MCPServerManager()
    
    @objc func start() {
        Task {
            let server = try await LookinMCPServer(dataSource: dataProvider, port: 47199)
            try await server.start()
        }
    }
}

使用方式

对于 OpenCode:

// .config/opencode/opencode.json
{
    // ...
  "mcp": {
    "lookin": {
      "type": "remote",
      "url": "http://127.0.0.1:47199/mcp",
      "enabled": true
    }
  }
}

对于 Claude Code:

claude mcp add --transport http lookin http://127.0.0.1:47199/mcp

然后就可以直接使用了:

image.png

现在可以愉快地让 AI 帮我看布局、找问题了!

代码放在 fork 的仓库里:github.com/FeliksLv01/…

01-研究系统框架@Web@iOS | JavaScriptCore 框架:从使用到原理解析

2026年3月1日 20:10

JavaScriptCore 框架:从使用到原理解析

JavaScript 越来越多地出现在我们客户端开发的视野中,从 React Native 到 JSPatch,JavaScript 与客户端相结合的技术开始变得魅力无穷。本文主要讲解 iOS 中的 JavaScriptCore 框架,正是它为 iOS 提供了执行 JavaScript 代码的能力。未来的技术日新月异,JavaScript 与 iOS 正在碰撞出新的激情。

JavaScriptCoreJavaScript虚拟机,为 JavaScript 的执行提供底层资源。


📋 目录


一、JavaScript

在讨论JavaScriptCore之前,我们首先必须对JavaScript有所了解。

1. JavaScript干啥的?

  • 说的高大上一点:一门基于原型、函数先行的高级编程语言,通过解释执行,是动态类型的直译语言。是一门多范式的语言,它支持面向对象编程,命令式编程,以及函数式编程。
  • 说的通俗一点:主要用于网页,为其提供动态交互的能力。可嵌入动态文本于HTML页面,对浏览器事件作出响应,读写HTML元素,控制cookies等。
  • 再通俗一点:抢月饼,button.click()。(PS:请谨慎使用while循环)

img

2. JavaScript起源与历史

  • 1990年底,欧洲核能研究组织(CERN)科学家Tim Berners-Lee,在互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。
  • 1994年12月,Netscape 发布了一款面向普通用户的新一代的浏览器Navigator 1.0版,市场份额一举超过90%。
  • 1995年,Netscape公司雇佣了程序员Brendan Eich开发这种嵌入网页的脚本语言。最初名字叫做Mocha,1995年9月改为LiveScript。
  • 1995年12月,Netscape公司与Sun公司达成协议,后者允许将这种语言叫做JavaScript。

3. JavaScript与ECMAScript

  • “JavaScript”是Sun公司的注册商标,用来特制网景(现在的Mozilla)对于这门语言的实现。网景将这门语言作为标准提交给了ECMA——欧洲计算机制造协会。由于商标上的冲突,这门语言的标准版本改了一个丑陋的名字“ECMAScript”。同样由于商标的冲突,微软对这门语言的实现版本取了一个广为人知的名字“Jscript”。
  • ECMAScript作为JavaScript的标准,一般认为后者是前者的实现。

4. Java和JavaScript

img

《雷锋和雷峰塔》

Java 和 JavaScript 是两门不同的编程语言 一般认为,当时 Netscape 之所以将 LiveScript 命名为 JavaScript,是因为 Java 是当时最流行的编程语言,带有 “Java” 的名字有助于这门新生语言的传播。

二、 JavaScriptCore

1. 浏览器演进

  • 演进完整图

upload.wikimedia.org/wikipedia/c…

  • WebKit分支

现在使用WebKit的主要两个浏览器Sfari和Chromium(Chorme的开源项目)。WebKit起源于KDE的开源项目Konqueror的分支,由苹果公司用于Sfari浏览器。其一条分支发展成为Chorme的内核,2013年Google在此基础上开发了新的Blink内核。

img

2. WebKit排版引擎

webkit是sfari、chrome等浏览器的排版引擎,各部分架构图如下

img

  • webkit Embedding API是browser UI与webpage进行交互的api接口;
  • platformAPI提供与底层驱动的交互, 如网络, 字体渲染, 影音文件解码, 渲染引擎等;
  • WebCore它实现了对文档的模型化,包括了CSS, DOM, Render等的实现;
  • JSCore是专门处理JavaScript脚本的引擎;

3. JavaScript引擎

  • JavaScript引擎是专门处理JavaScript脚本的虚拟机,一般会附带在网页浏览器之中。第一个JavaScript引擎由布兰登·艾克在网景公司开发,用于Netscape Navigator网页浏览器中。JavaScriptCore就是一个JavaScript引擎。
  • 下图是当前主要的还在开发中的JavaScript引擎

img

4. JavaScriptCore组成

JavaScriptCore主要由以下模块组成:

  • Lexer 词法分析器,将脚本源码分解成一系列的Token
  • Parser 语法分析器,处理Token并生成相应的语法树
  • LLInt 低级解释器,执行Parser生成的二进制代码
  • Baseline JIT 基线JIT(just in time 实施编译)
  • DFG 低延迟优化的JIT
  • FTL 高通量优化的JIT

关于更多JavaScriptCore的实现细节,参考 trac.webkit.org/wiki/JavaSc…

5. JavaScriptCore 框架与历史

JavaScriptCore 是一个 C++ 实现的开源项目(WebKit 的一部分)。历史上,JSC 长期作为 Safari / WebKit 的内置 JS 引擎;自 iOS 7.0 / OS X 10.9 起,Apple 将 JavaScriptCore 以系统框架 JavaScriptCore.framework 的形式开放给开发者,使其可在 Objective-C 或基于 C 的程序中执行 JavaScript 代码,并向 JS 环境中插入自定义对象,而无需依赖 UIWebView。这为 Hybrid 应用、热更新、脚本引擎等场景提供了统一的底层能力。

JavaScriptCore.h 中,我们可以看到:

#ifndef JavaScriptCore_h
#define JavaScriptCore_h

#include <JavaScriptCore/JavaScript.h>
#include <JavaScriptCore/JSStringRefCF.h>

#if defined(__OBJC__) && JSC_OBJC_API_ENABLED

#import "JSContext.h"
#import "JSValue.h"
#import "JSManagedValue.h"
#import "JSVirtualMachine.h"
#import "JSExport.h"

#endif

#endif /* JavaScriptCore_h */

这里已经很清晰地列出了JavaScriptCore的主要几个类:

  • JSContext
  • JSValue
  • JSManagedValue
  • JSVirtualMachine
  • JSExport

接下来我们会依次讲解这几个类的用法。

6. Hello World!

这段代码展示了如何在 Objective-C 中执行一段 JavaScript 代码,并且获取返回值并转换成 OC 数据打印:

// 创建虚拟机
JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];

//创建上下文
JSContext *context = [[JSContext alloc] initWithVirtualMachine:vm];

//执行JavaScript代码并获取返回值
JSValue *value = [context evaluateScript:@"1+2*3"];

// 转换成 OC 数据并打印
NSLog(@"value = %d", [value toInt32]);
// Output: value = 7

Swift 等价写法:

import JavaScriptCore

let vm = JSVirtualMachine()!
let context = JSContext(virtualMachine: vm)!
let value = context.evaluateScript("1 + 2 * 3")!
print("value =", value.toInt32())  // value = 7

三、 JSVirtualMachine

一个JSVirtualMachine的实例就是一个完整独立的JavaScript的执行环境,为JavaScript的执行提供底层资源。

这个类主要用来做两件事情:

  1. 实现并发的 JavaScript 执行
  2. JavaScript 和 Objective-C 桥接对象的内存管理

看下头文件 JSVirtualMachine.h 里有什么:

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSVirtualMachine : NSObject

/* 创建一个新的完全独立的虚拟机 */
(instancetype)init;

/* 对桥接对象进行内存管理 */
- (void)addManagedReference:(id)object withOwner:(id)owner;

/* 取消对桥接对象的内存管理 */
- (void)removeManagedReference:(id)object withOwner:(id)owner;

@end

每一个JavaScript上下文(JSContext对象)都归属于一个虚拟机(JSVirtualMachine)。每个虚拟机可以包含多个不同的上下文,并允许在这些不同的上下文之间传值(JSValue对象)。

然而,每个虚拟机都是完整且独立的,有其独立的堆空间和垃圾回收器(garbage collector ),GC无法处理别的虚拟机堆中的对象,因此你不能把一个虚拟机中创建的值传给另一个虚拟机。

img

线程和JavaScript的并发执行

JavaScriptCore API都是线程安全的。你可以在任意线程创建JSValue或者执行JS代码,然而,所有其他想要使用该虚拟机的线程都要等待。

  • 如果想并发执行JS,需要使用多个不同的虚拟机来实现。
  • 可以在子线程中执行JS代码。

通过下面这个 demo 来理解这个并发机制:

JSContext *context = [[CustomJSContext alloc] init];
JSContext *context1 = [[CustomJSContext alloc] init];
JSContext *context2 = [[CustomJSContext alloc] initWithVirtualMachine:[context virtualMachine]];
NSLog(@"start");
dispatch_async(queue, ^{
    while (true) {
        sleep(1);
        [context evaluateScript:@"log('tick')"];
    }
});
dispatch_async(queue1, ^{
    while (true) {
        sleep(1);
        [context1 evaluateScript:@"log('tick_1')"];
    }
});
dispatch_async(queue2, ^{
    while (true) {
        sleep(1);
        [context2 evaluateScript:@"log('tick_2')"];
    }
});
[context evaluateScript:@"sleep(5)"];
NSLog(@"end");

context和context2属于同一个虚拟机。

context1属于另一个虚拟机。

三个线程分别异步执行每秒1次的js log,首先会休眠1秒。

在context上执行一个休眠5秒的JS函数。

首先执行的应该是休眠5秒的JS函数,在此期间,context所处的虚拟机上的其他调用都会处于等待状态,因此tick和tick_2在前5秒都不会有执行。

而context1所处的虚拟机仍然可以正常执行tick_1

休眠5秒结束后,tick和tick_2才会开始执行(不保证先后顺序)。

实际运行输出的 log 是:

start
tick_1
tick_1
tick_1
tick_1
end
tick
tick_2

四、 JSContext

一个JSContext对象代表一个JavaScript执行环境。在native代码中,使用JSContext去执行JS代码,访问JS中定义或者计算的值,并使JavaScript可以访问native的对象、方法、函数。

img

1. JSContext执行JS代码

  • 调用evaluateScript函数可以执行一段top-level 的JS代码,并可向global对象添加函数和对象定义
  • 其返回值是JavaScript代码中最后一个生成的值

API Reference

NS_CLASS_AVAILABLE(10_9, 7_0)
@interface JSContext : NSObject

/* 创建一个JSContext,同时会创建一个新的JSVirtualMachine */
(instancetype)init;

/* 在指定虚拟机上创建一个JSContext */
(instancetype)initWithVirtualMachine:
        (JSVirtualMachine*)virtualMachine;

/* 执行一段JS代码,返回最后生成的一个值 */
(JSValue *)evaluateScript:(NSString *)script;

/* 执行一段JS代码,并将sourceURL认作其源码URL(仅作标记用) */
- (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL*)sourceURL     NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的context */
+ (JSContext *)currentContext;

/* 获取当前执行的JavaScript function*/
+ (JSValue *)currentCallee NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的this */
+ (JSValue *)currentThis;

/* Returns the arguments to the current native callback from JavaScript code.*/
+ (NSArray *)currentArguments;

/* 获取当前context的全局对象。WebKit中的context返回的便是WindowProxy对象*/
@property (readonly, strong) JSValue *globalObject;

@property (strong) JSValue *exception;
@property (copy) void(^exceptionHandler)(JSContext *context, JSValue
    *exception);

@property (readonly, strong) JSVirtualMachine *virtualMachine;

@property (copy) NSString *name NS_AVAILABLE(10_10, 8_0);


@end

2. JSContext访问JS对象

一个JSContext对象对应了一个全局对象(global object)。例如web浏览器中中的JSContext,其全局对象就是window对象。在其他环境中,全局对象也承担了类似的角色,用来区分不同的JavaScript context的作用域。全局变量是全局对象的属性,可以通过JSValue对象或者context下标的方式来访问。

示例代码:

JSValue *value = [context evaluateScript:@"var a = 1+2*3;"];

NSLog(@"a = %@", [context objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", [context.globalObject objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", context[@"a"]);
// Output: a = 7, a = 7, a = 7

这里列出了三种访问JavaScript对象的方法

  • 通过context的实例方法objectForKeyedSubscript
  • 通过context.globalObject的objectForKeyedSubscript实例方法
  • 通过下标方式

设置属性也是对应的。

API Reference

/* 为 JSContext 提供下标访问元素的方式 */
@interface JSContext (SubscriptSupport)

/* 首先将key转为JSValue对象,然后使用这个值在JavaScript context的全局对象中查找这个名字的属性并返回 */
(JSValue *)objectForKeyedSubscript:(id)key;

/* 首先将key转为JSValue对象,然后用这个值在JavaScript context的全局对象中设置这个属性。
可使用这个方法将native中的对象或者方法桥接给JavaScript调用 */
(void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying>*)key;

@end



/* 例如:以下代码在JavaScript中创建了一个实现是Objective-C block的function */
context[@"makeNSColor"] = ^(NSDictionary *rgb){
    float r = [rgb[@"red"] floatValue];
    float g = [rgb[@"green"] floatValue];
    float b = [rgb[@"blue"] floatValue];
    return [NSColor colorWithRed:(r / 255.f) green:(g / 255.f) blue:(b / 255.f)         alpha:1.0];
};
JSValue *value = [context evaluateScript:@"makeNSColor({red:12, green:23, blue:67})"];

五、 JSValue

一个JSValue实例就是一个JavaScript值的引用。使用JSValue类在JavaScript和native代码之间转换一些基本类型的数据(比如数值和字符串)。你也可以使用这个类去创建包装了自定义类的native对象的JavaScript对象,或者创建由native方法或者block实现的JavaScript函数。

每个JSValue实例都来源于一个代表JavaScript执行环境的JSContext对象,这个执行环境就包含了这个JSValue对应的值。每个JSValue对象都持有其JSContext对象的强引用,只要有任何一个与特定JSContext关联的JSValue被持有(retain),这个JSContext就会一直存活。通过调用JSValue的实例方法返回的其他的JSValue对象都属于与最始的JSValue相同的JSContext。

img

每个JSValue都通过其JSContext间接关联了一个特定的代表执行资源基础的JSVirtualMachine对象。你只能将一个JSValue对象传给由相同虚拟机管理(host)的JSValue或者JSContext的实例方法。如果尝试把一个虚拟机的JSValue传给另一个虚拟机,将会触发一个Objective-C异常。

img

1. JSValue类型转换

JSValue提供了一系列的方法将native与JavaScript的数据类型进行相互转换:

img

2. NSDictionary与JS对象

NSDictionary 对象以及其包含的 keys 与 JavaScript 中的对应名称的属性相互转换。key 所对应的值也会递归地进行拷贝和转换。

[context evaluateScript:@"var color = {red:230, green:90, blue:100}"];

//js->native 给你看我的颜色
JSValue *colorValue = context[@"color"];
NSLog(@"r=%@, g=%@, b=%@", colorValue[@"red"], colorValue[@"green"], colorValue[@"blue"]);
NSDictionary *colorDic = [colorValue toDictionary];
NSLog(@"r=%@, g=%@, b=%@", colorDic[@"red"], colorDic[@"green"], colorDic[@"blue"]);

//native->js 给你点颜色看看
context[@"color"] = @{@"red":@(0), @"green":@(0), @"blue":@(0)};
[context evaluateScript:@"log('r:'+color.red+'g:'+color.green+' b:'+color.blue)"];
// Output:
// r=230, g=90, b=100
// r=230, g=90, b=100
// r:0 g:0 b:0

可见,JS中的对象可以直接转换成Objective-C中的NSDictionary,NSDictionary传入JavaScript也可以直接当作对象被使用。

3. NSArray与JS数组

NSArray 对象与 JavaScript 中的 array 相互转换。其子元素也会递归地进行拷贝和转换。

[context evaluateScript:@"var friends = ['Alice','Jenny','XiaoMing']"];

//js->native 你说哪个是真爱?
JSValue *friendsValue = context[@"friends"];
NSLog(@"%@, %@, %@", friendsValue[0], friendsValue[1], friendsValue[2]);
NSArray *friendsArray = [friendsValue toArray];
NSLog(@"%@, %@, %@", friendsArray[0], friendsArray[1], friendsArray[2]);

//native->js 我觉得 XiaoMing 不错,给你再推荐个 Jimmy
context[@"girlFriends"] = @[friendsArray[2], @"Jimmy"];
[context evaluateScript:@"log('girlFriends :'+girlFriends[0]+' '+girlFriends[1])"];
// Output: Alice, Jenny, XiaoMing / girlFriends : XiaoMing Jimmy

4. Block/函数和JS function

Objective-C中的block转换成JavaScript中的function对象。参数以及返回类型使用相同的规则转换。

将一个代表native的block或者方法的JavaScript function进行转换将会得到那个block或方法。

其他的JavaScript函数将会被转换为一个空的dictionary。因为JavaScript函数也是一个对象。

5. OC对象和JS对象

对于所有其他 native 的对象类型,JavaScriptCore 都会创建一个拥有 constructor 原型链的 wrapper 对象,用来反映 native 类型的继承关系。默认情况下,native 对象的属性和方法并不会导出给其对应的 JavaScript wrapper 对象。通过 JSExport 协议可选择性地导出属性和方法。下面第六节对 JSExport 与原生对象导出做详细讲解。


六、JSExport 与原生对象导出

JSExport 是 JavaScriptCore 框架中的协议,用于将 Objective-C/Swift 的类(属性与方法)选择性导出给 JavaScript,使 JS 代码可以像调用普通对象一样调用原生对象 [1][2]。

6.1 作用与机制

  • 遵循 JSExport 的协议中声明的属性和方法,会在将 native 对象注入到 JSContext(如 context[@"bridge"] = nativeObject)时,自动暴露为 JS 侧的属性和函数。
  • 若类未实现 JSExport 或未在协议中声明,则对应属性/方法不会出现在 JS 中;这样可控制「桥接面」,避免暴露内部实现 [1][2]。

6.2 使用示例(概念)

@protocol MyPointExport <JSExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
- (NSString *)description;
@end

@interface MyPoint : NSObject <MyPointExport>
@property (nonatomic, assign) double x;
@property (nonatomic, assign) double y;
@end

MyPoint 实例赋给 context[@"point"] 后,在 JS 中可访问 point.xpoint.y 并调用 point.description()
注意:若在 Block 或导出方法中再次使用 JSValueJSContext,需注意线程与内存管理(见第七节 JSManagedValue)[1][2]。

Swift 中的等价写法(通过 JSContext 注入遵循 JSExport 的类):

import JavaScriptCore

@objc protocol PointExport: JSExport {
    var x: Double { get set }
    var y: Double { get set }
    func description() -> String
}

class Point: NSObject, PointExport {
    @objc var x: Double
    @objc var y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
    func description() -> String { "Point(\(x), \(y))" }
}

// 注入到 context
let context = JSContext()!
context.setObject(Point(x: 1, y: 2), forKeyedSubscript: "point" as NSString)
context.evaluateScript("point.x; point.description()")

6.3 与 Block 注入的对比

方式 适用场景
context[@"fn"] = ^(id arg){ ... } 单次或简单逻辑,直接暴露为 JS 函数
JSExport 协议 + 原生对象 需要暴露多个方法/属性、保持对象身份与状态的「桥接对象」

七、JSManagedValue 与内存管理

7.1 为何需要 JSManagedValue

  • JSValueJSContext强引用JSContext 又挂在 JSVirtualMachine 上。
  • 若在 堆上的 OC 对象(如某 ViewController 的 property)中直接强引用 JSValue,而该 JSValue 通过某种方式(例如被注入到 context 的全局对象)又引用回该 OC 对象,会形成 OC ↔ JS 的循环引用,导致 Context 与 OC 对象均无法释放 [1][2]。

7.2 JSManagedValue 的职责

JSManagedValueJSValue 的包装类,用于在「被 OC 堆对象持有」的场景下,以条件保留的方式引用 JS 值,并可与 JSVirtualMachineaddManagedReference:withOwner: / removeManagedReference:withOwner: 配合,让虚拟机在合适的时机断开或保留对 native 对象的引用,从而打破循环、避免 JSContext 无法释放 [1][2]。

7.3 使用要点(概念)

  • 当需要把 JSValue(或从 JS 传回的函数/对象)存为 OC 对象的成员变量时,应使用 JSManagedValue 包装,并以 owner 注册到 JSVirtualMachine;在 owner 析构或不再需要时调用 removeManagedReference:withOwner: [1][2]。
  • 仅临时在栈上使用 JSValue(如 evaluateScript 的返回值在方法内使用后不再持有)时,一般无需 JSManagedValue。

八、关键概念图示与流程

8.1 VM、Context、Value 关系

flowchart TB
  subgraph VM1[JSVirtualMachine 1]
    C1[JSContext 1]
    C2[JSContext 2]
  end
  subgraph VM2[JSVirtualMachine 2]
    C3[JSContext 3]
  end
  C1 --> V1[JSValue]
  C2 --> V2[JSValue]
  C1 -.->|可传值| C2
  C1 -.->|不可跨 VM| C3

同一 JSVirtualMachine 下多个 JSContext 可共享、传递 JSValue;不同 VM 之间不能传递 JSValue [3]。

8.2 JavaScriptCore 引擎执行层级(概念)

源码经 Lexer → Parser 得到语法树并生成字节码后,由下至上的执行/编译层级可概括为:

flowchart LR
  A[源码] --> B[Lexer]
  B --> C[Parser / AST]
  C --> D[字节码]
  D --> E[LLInt 解释器]
  E --> F[Baseline JIT]
  F --> G[DFG JIT]
  G --> H[FTL JIT]
  • LLInt:低级解释器,低延迟启动。
  • Baseline JIT:首次 JIT,兼顾分析与回退。
  • DFG:基于数据流的优化 JIT。
  • FTL:更高优化层(历史上曾用 LLVM/B3 后端)[4][5]。

更多实现细节见 WebKit JavaScriptCore Wiki


九、应用场景与最佳实践

9.1 典型应用场景

场景 说明
Hybrid 应用 在 App 内执行 JS 脚本、调用原生能力(如弹窗、定位、支付),JavaScriptCore 提供 OC/Swift 与 JS 的双向桥接 [1][2]
React Native / 类 RN 方案 早期 RN 等方案在 iOS 上依赖 JSC 执行 JS bundle;JSC 提供 VM、Context、Value 等能力 [3]
JSPatch 等热修复 通过下发 JS 脚本并在 JSC 中执行,动态调用原生类与方法,实现热更新(需注意安全与审核政策)[3]
WKWebView 与 Web 页面 WKWebView 内部使用系统 WebKit,其 JS 引擎与 Safari 一致;独立使用 JSC 时无需 WebView 即可执行 JS [1][2]
规则引擎 / 脚本配置 将业务规则或配置写成 JS,由原生在 JSC 中执行并取结果,便于迭代与 A/B 测试

9.2 最佳实践要点

  • 线程:同一 VM 下多线程会串行等待;需并发执行 JS 时使用多个 JSVirtualMachine [3]。
  • 异常:设置 context.exceptionHandler,在 JS 抛错时记录或上报,避免静默失败 [3]。
  • 内存:在 OC 堆对象中持有 JS 值时使用 JSManagedValue + add/removeManagedReference,避免循环引用 [1][2]。
  • 安全:执行来自网络或不可信来源的 JS 时,需做沙箱与权限控制;避免将敏感 API 无限制暴露给 JS [3]。

十、伪代码与算法说明

10.1 执行脚本并取返回值(概念)

function evaluateScript(script: String) -> JSValue:
  parse script -> AST
  generate bytecode from AST
  execute bytecode (via LLInt or JIT tier)
  return last expression value as JSValue

10.2 将 Native 对象注入 Context(概念)

function setObject(object: Any, forKey key: String):
  if object is Block or conforms to JSExport:
    create JS wrapper (function or object with exported properties/methods)
  else:
    create generic wrapper preserving native type hierarchy
  set wrapper on context.globalObject[key]

10.3 JS 调用 Native Block 时(概念)

JavaScript 侧,调用通过 context[@"key"] 注入的 Block,与调用普通函数一致:

// 假设 Native 已注入:context["makeColor"] = ^(NSDictionary *rgb) { ... }
var color = makeColor({ red: 12, green: 23, blue: 67 });

底层流程(伪代码):

当 JS 调用 context 中注册的 Block 时:
  1. JSC 将 JS 参数按类型转换为 OC 对象(NSNumber/NSString/NSDictionary/NSArray 等)
  2. 调用 Block,传入转换后的参数
  3. 将 Block 返回值按类型转换为 JSValue 并返回给 JS

参考文献

[1] Apple. JavaScriptCore Framework. iOS / macOS Developer Documentation.
[2] 掘金 / 博客. iOS 与 JS 交互开发知识总结JavaScriptCore 初探 等.
[3] 本文原稿与常见 JSC 教程(JSVirtualMachine、JSContext、JSValue、并发与内存).
[4] WebKit. Introducing the WebKit FTL JIT. webkit.org/blog/3362/i…
[5] WebKit. JavaScriptCore - Deep Dive. docs.webkit.org/Deep%20Dive…
[6] trac.webkit.org. JavaScriptCore. trac.webkit.org/wiki/JavaSc…
[7] 美团技术团队. 深入理解 JSCore. blog.csdn.net/MeituanTech…

昨天 — 2026年3月1日掘金 iOS

SwiftUI路由管理架构揭秘:从混乱到优雅的蜕变

作者 StarkCoder
2026年2月28日 19:26

引言

想象一下:当你打开一个 App,点击不同标签页,切换页面时,所有导航状态都能完美保持;当你从详情页返回时,TabBar 能智能地重新出现;当你需要传递数据时,类型安全的导航能让你告别字符串硬编码的烦恼。这一切,都离不开一个优秀的路由管理架构。

在现代 iOS 应用开发中,路由管理常常被视为"基础设施"而被忽视,但其重要性却不亚于任何核心功能。一个设计良好的路由系统,不仅能让代码结构更清晰,还能显著提升用户体验。今天,我将带大家深入剖析我项目中的路由管理架构,分享从设计到实现的全过程,希望能为你的项目带来启发。

路由架构概览

我项目的路由管理基于 SwiftUI 的 NavigationStackNavigationPath,采用了集中式的路由管理方案。核心组件包括:

  • Router 类:全局导航路由器,管理所有 Tab 的导航路径
  • MainTab 枚举:定义应用的标签页结构
  • MainContainerView:主容器视图,负责整合标签页和导航逻辑
  • App 启动注入:在应用启动时将 Router 注入到环境中

路由的启动注入

EviApp.swift 中,我们通过 @StateObject 创建 Router 实例,并通过 environmentObject 将其注入到应用环境中:

import SwiftUI

@main
struct EviApp: App {
    // 把 AppDelegate 接进来,系统会照常调用 didFinishLaunchingWithOptions 等
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    // 全局弹框管理器
    @StateObject private var overlay = GlobalOverlayManager.shared
    // 全局导航路由器
    @StateObject private var router = Router()
    
    var body: some Scene {
        WindowGroup {
            MainContainerView()
                .environmentObject(overlay)
                .environmentObject(router)
        }
    }
}

这样,在应用的任何视图中,都可以通过 @EnvironmentObject 来访问 Router 实例,实现全局路由管理。

核心组件分析

1. Router 类:路由管理的核心

import SwiftUI

/// 全局导航路由器,管理所有Tab的导航路径
class Router: ObservableObject {
    
    // 当前选中的Tab
    @Published var selectedTab: MainTab = .home
    
    // 为每个tab单独存储NavigationPath
    @Published var homePath = NavigationPath()
    @Published var hotPath = NavigationPath()
    @Published var creationPath = NavigationPath()
    @Published var stylePath = NavigationPath()
    @Published var profilePath = NavigationPath()
    
    // MARK: - 获取导航路径
    
    /// 获取指定tab的导航路径
    func getNavigationPath(for tab: MainTab) -> NavigationPath {
        switch tab {
        case .home: return homePath
        case .hot: return hotPath
        case .creation: return creationPath
        case .style: return stylePath
        case .profile: return profilePath
        }
    }
    
    /// 获取指定tab的导航路径绑定
    func getNavigationPathBinding(for tab: MainTab) -> Binding<NavigationPath> {
        switch tab {
        case .home: return binding(for: \.homePath)
        case .hot: return binding(for: \.hotPath)
        case .creation: return binding(for: \.creationPath)
        case .style: return binding(for: \.stylePath)
        case .profile: return binding(for: \.profilePath)
        }
    }
    
    // MARK: - 清空导航路径
    
    /// 清空指定tab的导航路径
    func clearPath(for tab: MainTab) {
        switch tab {
        case .home: clear(\.homePath)
        case .hot: clear(\.hotPath)
        case .creation: clear(\.creationPath)
        case .style: clear(\.stylePath)
        case .profile: clear(\.profilePath)
        }
    }
    
    /// 清空所有导航路径
    func clearAllPaths() {
        clear(\.homePath)
        clear(\.hotPath)
        clear(\.creationPath)
        clear(\.stylePath)
        clear(\.profilePath)
    }
    
    // MARK: - 当前Tab操作
    
    /// 获取当前选中Tab的导航路径
    func getCurrentNavigationPath() -> NavigationPath {
        return getNavigationPath(for: selectedTab)
    }
    
    /// 获取当前选中Tab的导航路径绑定
    func getCurrentNavigationPathBinding() -> Binding<NavigationPath> {
        return getNavigationPathBinding(for: selectedTab)
    }
    
    /// 清空当前选中Tab的导航路径
    func clearCurrentPath() {
        clearPath(for: selectedTab)
    }
    
    // MARK: - 私有辅助方法
    
    /// 创建导航路径的绑定
    private func binding(for keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) -> Binding<NavigationPath> {
        Binding {
            self[keyPath: keyPath]
        } set: {
            self[keyPath: keyPath] = $0
        }
    }
    
    /// 清空指定的导航路径
    private func clear(_ keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) {
        self[keyPath: keyPath].removeLast(self[keyPath: keyPath].count)
    }
}

设计亮点

  • 集中管理:所有路由逻辑集中在一个类中,便于统一管理
  • Tab 隔离:为每个标签页维护独立的导航路径,确保切换标签时不会影响其他标签的导航状态
  • 响应式设计:使用 @Published 修饰符,实现路由状态的自动更新
  • 便捷方法:提供了丰富的方法来操作导航路径,如获取路径、清空路径等

2. MainTab 枚举:标签页定义

import SwiftUI

/// 主标签栏枚举
enum MainTab {
    case home
    case hot
    case creation
    case style
    case profile
}

extension MainTab {
    
    /// 根据选中状态返回对应的图标名称
    func iconName(isSelected: Bool) -> String {
        switch self {
        case .home:
            return isSelected ? "tabbar_home_sel" : "tabbar_home_nor"
        case .hot:
            return isSelected ? "tabbar_hot_sel" : "tabbar_hot_nor"
        case .creation:
            return "tabbar_add"
        case .style:
            return isSelected ? "tabbar_style_sel" : "tabbar_style_nor"
        case .profile:
            return isSelected ? "tabbar_me_sel" : "tabbar_me_nor"
        }
    }
}

设计亮点

  • 类型安全:使用枚举定义标签页,避免了字符串硬编码
  • 扩展功能:通过扩展为枚举添加了获取图标名称的功能,使代码更整洁

3. MainContainerView:路由的实际应用

import SwiftUI

/// 主容器视图,包含悬浮TabBar
struct MainContainerView: View {
    
    // 获取指定tab的导航路径
    private func getNavigationPath(for tab: MainTab) -> NavigationPath {
        return router.getNavigationPath(for: tab)
    }
    
    /// 创建带有NavigationStack的标签页视图
    private func tabView(_ tab: MainTab) -> some View {
        NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
            switch tab {
            case .home:
                HomeView()
            case .hot:
                HotHomeView()
            case .creation:
                CreationHomeView()
            case .style:
                StyleHomeView()
            case .profile:
                ProfileHomeView()
            }
        }
        .tag(tab)
    }
    
    @StateObject private var appConfigManager = AppConfigManager.shared
    
    @EnvironmentObject private var overlay: GlobalOverlayManager
    @EnvironmentObject private var router: Router
    
    var body: some View {
        if appConfigManager.appConfig != nil {
            ZStack {
                
                // 真正负责页面生命周期的容器
                TabView(selection: $router.selectedTab) {
                    tabView(.home)
                    tabView(.hot)
                    tabView(.creation)
                    tabView(.style)
                    tabView(.profile)
                }
                
                // 你的悬浮TabBar,根据当前选中标签的导航路径长度控制显示
                if isTabBarVisible {
                    VStack {
                        Spacer()
                        FloatingTabBar(selectedTab: $router.selectedTab)
                            .padding(.horizontal, 16)
                            .padding(.bottom, 20)
                    }
                }
                
                // 全局弹框显示
                if let current = overlay.current {
                    
                    // 遮罩
                    Color.black.opacity(0.4)
                        .ignoresSafeArea()
                        .onTapGesture {
                            overlay.dismiss()
                        }
                    
                    switch current {
                    case .login:
                        LoginOverlayView(onClose: {
                            overlay.dismiss()
                        })
                        .transition(.flipFromBottom)
                    }
                }
                
            }
            .animation(.easeInOut(duration: 0.25), value: overlay.current)
        } else {
            // 显示空View
            EmptyView()
                .background(ThemeManager.Background.global)
        }
    }
    
    var isTabBarVisible: Bool {
        return getNavigationPath(for: router.selectedTab).count == 0
    }
}

设计亮点

  • NavigationStack 集成:为每个标签页创建独立的 NavigationStack
  • TabBar 智能显示:根据当前导航路径长度控制 TabBar 的显示/隐藏
  • 环境对象注入:使用 @EnvironmentObject 注入 Router,实现全局访问
  • 动画效果:添加了平滑的过渡动画,提升用户体验

路由管理的实现细节

1. 路径管理机制

路由系统的核心是 NavigationPath 的管理。NavigationPath 是 SwiftUI 4.0+ 引入的类型,它是一个类型擦除的容器,可以存储任意类型的导航目的地。

在我们的实现中:

  • 每个标签页都有自己的 NavigationPath 实例
  • 通过 getNavigationPathBinding 方法获取路径的绑定,用于 NavigationStack
  • 提供了 clearPathclearAllPaths 方法来清空导航路径

2. 标签页切换逻辑

当用户切换标签页时:

  1. router.selectedTab 的值会更新
  2. TabView 会根据新的 selectedTab 显示对应的标签页
  3. 由于每个标签页有独立的 NavigationPath,切换标签不会影响其他标签的导航状态

3. 导航路径的实际使用

在具体的视图中,可以通过以下方式使用路由:

// 在视图中注入 Router
@EnvironmentObject private var router: Router

// 使用全局路由管理进行导航
let currentPath = router.getCurrentNavigationPathBinding()
// 向当前路径添加新页面
currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material))

// 清空当前标签页的导航路径
router.clearCurrentPath()

4. 导航目的地定义

项目使用 AppNavigationDestination 枚举来定义导航目的地:

import Foundation
import SwiftUI

/// 导航目标枚举
enum AppNavigationDestination: Hashable {
    case accountLogin
    case materialDetail(MaterialListDTOElement)
}

这种方式的优势:

  • 类型安全:使用枚举定义导航目的地,避免了字符串硬编码
  • 参数传递:可以在导航时传递相关数据,如 materialDetail 中的 MaterialListDTOElement
  • 可扩展性:可以轻松添加新的导航目的地

5. NavigationStack 中处理导航目的地

在使用 NavigationStack 时,需要处理导航目的地的显示逻辑。通常在根视图中添加 navigationDestination 修饰符:

NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .accountLogin:
                AccountLoginView()
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            }
        }
}

这样,当我们通过 currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material)) 导航时,NavigationStack 会自动显示对应的目标视图。

6. 完整导航流程示例

下面是一个完整的导航流程示例,展示从触发导航到显示目标页面的全过程:

// 1. 在视图中注入 Router
@EnvironmentObject private var router: Router

// 2. 定义导航触发事件
Button("查看素材详情") {
    // 3. 获取当前路径绑定
    let currentPath = router.getCurrentNavigationPathBinding()
    // 4. 向路径添加导航目的地
    currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(selectedMaterial))
}

// 5. 在根视图中处理导航目的地
NavigationStack(path: router.getNavigationPathBinding(for: .home)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            default:
                EmptyView()
            }
        }
}

// 6. 从详情页返回
Button("返回") {
    // 清空当前路径,返回根视图
    router.clearCurrentPath()
}

7. 导航路径与 TabBar 显示的关联

MainContainerView 中,通过 isTabBarVisible 计算属性控制 TabBar 的显示:

var isTabBarVisible: Bool {
    return getNavigationPath(for: router.selectedTab).count == 0
}

当导航路径为空时(即处于标签页的根视图),显示 TabBar;当导航路径不为空时(即进入了子页面),隐藏 TabBar,为用户提供更大的内容显示区域。

优势与最佳实践

优势

  1. 清晰的职责分离:路由逻辑与 UI 逻辑分离,使代码更易于维护
  2. 类型安全:使用枚举和类型化的导航路径,减少运行时错误
  3. 状态管理:集中管理路由状态,避免状态分散
  4. 灵活性:可以轻松添加新的标签页和导航目的地
  5. 用户体验:标签页切换时保持各自的导航状态,提升用户体验

最佳实践

  1. 统一的路由入口:所有导航操作都通过 Router 进行,避免直接操作 NavigationPath
  2. 合理的路径清理:在适当的时机清理导航路径,避免内存占用过高
  3. 导航目的地的类型定义:为导航目的地创建明确的类型,提高代码可读性
  4. 错误处理:添加适当的错误处理,确保导航操作的稳定性
  5. 测试:为路由逻辑编写单元测试,确保其正确性

代码优化建议

  1. 导航目的地类型化

    // 建议为每个标签页创建导航目的地枚举
    enum HomeDestination {
        case detail(id: String)
        case search
    }
    
    // 然后在导航时使用
    router.homePath.append(HomeDestination.detail(id: "123"))
    
  2. 添加导航日志

    // 添加导航日志,便于调试和分析用户行为
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        let path = getNavigationPathBinding(for: tab)
        path.wrappedValue.append(value)
        print("Navigate to \(value) in tab \(tab)")
    }
    
  3. 导航路径持久化

    // 可以考虑在应用进入后台时保存导航状态,在应用启动时恢复
    func saveNavigationState() {
        // 保存导航状态到 UserDefaults 或其他存储
    }
    
    func restoreNavigationState() {
        // 从存储中恢复导航状态
    }
    
  4. 添加路由拦截器

    // 可以添加路由拦截器,用于处理登录验证等场景
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        if needsAuthentication(for: value) {
            // 显示登录界面
            overlay.present(.login)
        } else {
            let path = getNavigationPathBinding(for: tab)
            path.wrappedValue.append(value)
        }
    }
    

总结

通过以上分析,我们可以看到,一个良好的路由管理架构对于 iOS 应用的重要性。我项目中的路由架构采用了集中式管理、Tab 隔离、响应式设计等原则,通过 Router 类、MainTab 枚举和 MainContainerView 的配合,实现了清晰、灵活、用户友好的导航体验。

这种路由架构不仅适用于当前项目,也可以作为其他 SwiftUI 项目的参考。通过不断优化和扩展,可以构建更加完善的路由系统,为用户提供更加流畅的应用体验。

希望这篇文章能够帮助大家更好地理解和实现 iOS 项目中的路由管理架构。如果你有任何问题或建议,欢迎在评论区留言讨论!

昨天以前掘金 iOS

Flutter调试组件:打印任意组件尺寸位置信息 NRenderBox

作者 SoaringHeart
2026年2月28日 22:20

一、需求来源

当页面元素特别多,比较杂,又必须获取某个组件尺寸位置时,一个个加 GlobalKey 有太麻烦,这是使用一个封装好的组件就特别有用了。然后就有了 NRenderBox 组件,可以打印出子组件的位置及尺寸。

二、使用

NRenderBox(
  child: Container(
    padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
    decoration: BoxDecoration(
      color: Colors.transparent,
      border: Border.all(color: Colors.blue),
      borderRadius: BorderRadius.all(Radius.circular(0)),
    ),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        NNetworkImage(
          width: 50,
          height: 60,
          url: AppRes.image.urls.random ?? '',
        ),
        Text("选项"),
      ],
    ),
  ),
)
flutter: NRenderBox rect: Rect.fromLTRB(88.5, 322.0, 157.5, 413.0)

三、NRenderBox源码

import 'package:flutter/material.dart';

/// 点击打印尺寸
class NRenderBox extends StatefulWidget {
  const NRenderBox({
    super.key,
    required this.child,
  });

  final Widget child;

  @override
  State<NRenderBox> createState() => _NRenderBoxState();
}

class _NRenderBoxState extends State<NRenderBox> {
  final renderKey = GlobalKey();

  RenderBox? get renderBox {
    final ctx = renderKey.currentContext;
    if (ctx == null) {
      return null;
    }
    final box = ctx.findRenderObject() as RenderBox?;
    return box;
  }

  Offset? get renderPosition {
    return renderBox?.localToGlobal(Offset.zero);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      key: renderKey,
      onTap: () {
        if (renderBox == null) {
          return;
        }
        final position = renderPosition;
        final size = renderBox!.size;
        final rect = Rect.fromLTWH(position!.dx, position.dy, size.width, size.height);
        debugPrint("$widget rect: $rect");
      },
      child: widget.child,
    );
  }
}

github

苹果谷歌商店:如何监控并维护用户评分评论

作者 CocoaKier
2026年2月28日 18:59

前阵子,我无意中发现我们的应用在 App Store 上悄然出现了几条差评,但团队里似乎没人注意到。这让我意识到一个严重的问题:如果我们不能及时听到用户的声音,怎么能及时发现应用的不足,留住用户呢? 更令人担忧的是,潜在用户在下载前往往会浏览评论区,一条未被回应的负面评价,可能就足以让他们转身离开,影响新增转化。

如果能在用户留下评论(尤其是差评)的第一时间收到通知,我们就能快速响应、修复问题、安抚情绪,甚至将一次不满转化为一次忠诚度的提升。更重要的是,积极、真诚地回复用户评论,不仅能展现团队的专业与负责,还能向所有观望者传递一个信号:我们在乎每一位用户。

本篇文章将从实操角度出发,为不熟悉苹果和谷歌开发者后台的开发或运营同学,讲解如何监控苹果谷歌商店的评分评论,以及如何回复用户评论,为大家提供一些帮助。

一、苹果

苹果开发者后台 appstoreconnect.apple.com/,需要 客户支持 权限。

1、如何监控评分和评论

苹果后台目前不支持收到新的评分评论后邮件通知开发者。只支持“开发者回复”(当顾客编辑你已回复的评论时,你将收到电子邮件),如需开启“开发者回复”邮件通知,按下面步骤操作:

登录 App Store Connect。
点击右上角的用户头像,进入 “用户和访问”。
选择你的账户,在左侧菜单点击 “通知”。

Tips:“收到评分评论后邮件通知开发者”,这个功能在旧版 iTunes Connect 中曾经存在,但在新版 App Store Connect 中已被移除。猜测苹果可能不想开发者过度关注单条评分评论。

如果目前想要监控苹果商店的评分评论,有几个方案可参考:
1、使用官方的 App Store Connect App,每天刷一刷,自己主动去看。App内可以设置“接收用户评分”通知,但不确定现在还是否有效。
2、苹果官方提供了App Store Connect API,可以自己开发程序拉取用户评分,再进一步做监控。
3、滴答清单定个周期性提醒,每天上班打开商店详情瞅一眼,现在苹果上线了Web版AppStore了,瞅一眼也很方便。
4、借助第三方平台。

2、查看和回复用户评论

(1)通过网页端查看

登录苹果开发者后台,appstoreconnect.apple.com/

评分评论入口:分发 - 评分和评论 图片.png

点击“回复”可以回复用户评论
图片.png

(2)通过官方App "App Store Connect" 查看

iOS端下载地址:apps.apple.com/cn/app/app-…
(如果你搜不到可能是你手机系统版本太低了。没有安卓端。)

图片.png App Store Connect App核心功能:
-- 销售与趋势监控(查看 App 的下载量、销售额)
-- 版本状态管理(跟踪审核状态,回复审核)
-- 用户评论处理(查看和回复评论)

App Store Connect内查看评分及评论入口:
图片.png

3、重置总评分

发布新版本到 App Store 时(必须更包),你可以重置 App 总评分。重置后,你的 App Store 产品页面将显示说明,提示顾客 App 的总评分最近已重置。此说明将一直显示,直到有足够多的顾客对新版本进行了评分且页面出现新的总评分。

请注意,重置总评分并不会重置顾客评论,App Store 仍将继续显示历史的顾客评论

图片.png

二、Google

Google开发者后台 play.google.com/console/dev…,需要 用户反馈 权限。
“用户反馈”权限

1、如何监控评分和评论

Google官方支持收到新的评论后邮件提醒开发者,并支持按应用、评分星级设置不同的提醒开关。注意:邮件提醒默认是关闭的,需要手动开启。请按下列步骤操作。

Google开发者后台 - 设置 - 个人邮件通知(这个只会改你个人的通知设置,不会改整个团队的) 图片.png

按需将邮件提醒开关打开,修改后记得保存。
图片.png

如果你的账号拥有开发者账号下多个App的权限,默认是所有应用都给你发邮件,点击下图位置,可以选择哪些应用接收邮件。 图片.png

收到新的评论后,Google会给你推邮件,模板样式如下,包含了应用名称、评分星级、评论内容,不用打开Google后台就能看到评论内容,很方便。
注意:如果你接收了多个应用的邮件,请留意邮件标题里App的名字。

图片.png

2、查看和回复用户评论

(1)网页端

Google后台 - 应用 - 监控与改进 - 评分与评价。

Google后台的评论,Google会默认帮你翻译成你的语言,很贴心。如果你想看原始评论,点击“显示原评论”查看。你也可以在这里回复用户的评论。
图片.png

(2)官方 Google Play Console App

Google也像苹果一样,提供了官方的供开发者维护自己App的应用,Google Play Console App。你可以通过它在移动端方便的看评分和回复评论。

iOS端:apps.apple.com/cn/app/goog…
安卓端:play.google.com/store/apps/…

Google Play Console App

Google Play Console App 核心功能:

  • 查看数据指标:监控安装量、卸载量、更新量以及应用的崩溃率(ANR/Crash)。
  • 回复用户评论:及时查看并回复用户的评价,这对于维护 App 评分至关重要。
  • 订单管理:查看应用内购买和订阅的订单详情,甚至可以进行简单的退款操作。
  • 发布状态监控:跟踪应用版本的审核进度和发布状态。

3、Google不支持重置评分评论

Google不像苹果那样可以主动重置评分。虽然你不能手动重置,但 Google Play 的评分系统是动态权重的,更加偏重于近期(Recent)的用户评分权重会更高

这意味着:
(1)如果你的应用过去因为有 Bug 而评分很低,只要你在新版本中修复了问题,随着新用户和老用户在近期的好评增多,你的平均分会逐渐回升。
(2)时间是最好的解药:只要新版本的体验确实提升了,评分曲线会自动向好的方向修正。

三、结束语

其实维护应用商店的评论,并不需要多么复杂的流程或高深的技巧,但你做了和没做,用户感受是不一样的,每个人都希望被尊重,用真诚打动你的用户吧!

希望这篇文章能给你一点帮助。如果你有更好的监控方法,欢迎留言交流。

参考文档
【苹果官方文档】查看评分和评论

iOS设备崩溃日志获取与查看

作者 iOS日常
2026年2月28日 17:14

1)如何从 iPhone 获取崩溃日志

路径:设置 → 隐私与安全性 → 分析与改进 → 分析数据
这里的崩溃日志通常是 .ips 文件。

.ips 原始内容示例(节选):

{"app_name":"hello","timestamp":"2026-02-28 15:05:24.00 +0800","app_version":"1.0","bundleID":"com.example.hello","bug_type":"309","os_version":"iPhone OS 26.3 (23D127)","incident_id":"2B7A2F77-7F64-42DA-A184-AA496AD61AAC"}
{
  "modelCode" : "iPhone18,3",
  "captureTime" : "2026-02-28 15:05:24.5689 +0800",
  "procName" : "hello",
  "bundleInfo" : {"CFBundleShortVersionString":"1.0","CFBundleVersion":"1","CFBundleIdentifier":"com.example.hello"}
}

2)如何将 .ips 转成可查看的崩溃日志

.ips 文件复制到 Mac(如桌面),直接双击
系统会用 控制台(Console) 打开,并自动转成可读格式(Translated Report)。

转换后示例(节选):

-------------------------------------
Translated Report (Full Report Below)
-------------------------------------
Incident Identifier: 2B7A2F77-7F64-42DA-A184-AA496AD61AAC
Process: hello [1056]
Identifier: com.example.hello
Version: 1.0 (1)
OS Version: iPhone OS 26.3 (23D127)

Exception Type: EXC_BREAKPOINT (SIGTRAP)
Triggered by Thread: 0

Thread 0 Crashed:
0   libswiftCore.dylib   _assertionFailure(...)
1   hello.debug.dylib    ViewController.click(_:)

说明:这是一个 Demo 在真机调试运行时产生的崩溃日志,符号信息完整,不需要额外 dSYM 符号化也能直接看到具体崩溃代码位置(如 ViewController.click(_:))。

Xcode 垃圾清理

作者 iOS日常
2026年2月27日 17:42

一、可清理目录总览

场景 目录 是否可删 影响 建议
模拟器数据 ~/Library/Developer/CoreSimulator 可删 模拟器数据会被清空 不用模拟器时可重点清理(如 devices
真机调试符号 ~/Library/Developer/Xcode/iOS DeviceSupport 可删(建议选择性) 删掉后下次连接对应 iOS 版本会自动重建 删除不用的设备版本,常用版本保留
打包归档 ~/Library/Developer/Xcode/Archives 可删 会失去历史归档(.xcarchive) 先保留线上版本再清理
构建缓存 ~/Library/Developer/Xcode/DerivedData 可删 下次打开/编译变慢,需要重新索引与构建 优先清理(最直接释放缓存空间)

二、分项说明

1) 模拟器(CoreSimulator)

  • 路径:~/Library/Developer/CoreSimulator
  • 说明:包含模拟器设备数据。
  • 结论:可以删除;如果基本不用模拟器,可删除 devices 目录内容来释放较大空间。

2) 真机(DeviceSupport)

  • 路径:~/Library/Developer/Xcode/iOS DeviceSupport
  • 说明:真机调试时生成的设备符号文件。
  • 结论:建议选择性删除不用的设备版本;常用设备版本保留,避免频繁重建影响调试效率。

3) 打包(Archives)

  • 路径:~/Library/Developer/Xcode/Archives
  • 说明:Xcode 打包归档历史。
  • 结论:可以删除,但要先确认是否需要保留线上版本的归档记录。

4) 项目缓存(DerivedData)

  • 路径:~/Library/Developer/Xcode/DerivedData
  • 说明:构建缓存与索引。
  • 结论:建议优先清理;能快速释放缓存空间。代价是后续首次编译和索引会变慢。

三、实操建议(个人整理)

  1. 优先清理DerivedData(快速释放缓存空间)。
  2. 选择性清理iOS DeviceSupport(删除不用的设备/系统版本,常用的保留)。
  3. 按需清理CoreSimulator(尤其不用模拟器时)。
  4. 补充清理:过期 Archives(先保留可回滚版本)。
  5. 清理前先确认:
    • 是否有线上紧急回滚需要的归档;
    • 哪些真机系统版本仍在日常调试;
    • 是否有正在使用的模拟器环境数据需要保留。

四、快速命令(可选)

先看大小再删,避免误操作。

# 查看各目录体积
sudo du -sh ~/Library/Developer/CoreSimulator \
  ~/Library/Developer/Xcode/iOS\ DeviceSupport \
  ~/Library/Developer/Xcode/Archives \
  ~/Library/Developer/Xcode/DerivedData

# 删除 DeviceSupport
rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport/*

# 删除 Archives
rm -rf ~/Library/Developer/Xcode/Archives/*

# 删除 DerivedData(谨慎)
rm -rf ~/Library/Developer/Xcode/DerivedData/*

五、一句话总结

Xcode 清理的核心是:优先清理 DerivedData 释放缓存;DeviceSupport 只删不用的设备版本,常用版本保留;再按需处理模拟器与旧归档。

不越狱能抓到 HTTPS 吗?在未越狱 iPhone 上抓取 HTTPS

2026年2月27日 15:49

这个问题在 iOS 调试中反复出现。

很多人听到“HTTPS”“证书校验”“SSL Pinning”,第一反应就是,是不是必须越狱?

这篇文章在不越狱设备上分别测试三种情况:

  • 普通 HTTPS
  • 启用证书校验的 App
  • 启用双向认证的 App

环境:

  • iPhone(未越狱)
  • 一台 Windows + 一台 Mac
  • 代理工具(Charles / Proxyman)
  • 设备本机抓包工具 SniffMaster

一、代理抓包:不越狱的第一条路径

先测试最基础的方式:代理抓包。

操作步骤

  1. 启动 Charles(或 Proxyman)
  2. 确认代理端口正在监听
  3. iPhone 与电脑连接同一 Wi-Fi
  4. 在 iPhone 的 Wi-Fi 设置中填写代理地址与端口
  5. 在手机上安装并信任证书
  6. 用 Safari 打开一个 HTTPS 网站

如果 Safari 能完整显示请求和响应,说明:

  • 代理路径没问题
  • HTTPS 解密生效
  • 不需要越狱

二、普通 App 的 HTTPS 测试

在同样的代理环境下,打开一个普通测试 App。

结果:

  • 请求可以出现在 Charles 中
  • HTTPS 内容可正常解密
  • 请求体与响应体完整

这一步可以确认在未启用额外安全校验的情况下,不越狱完全可以抓到 HTTPS。


三、遇到证书校验(SSL Pinning)

接下来测试一个启用了证书校验的 App。

操作保持不变,只替换测试 App。

现象:

  • App 提示网络错误
  • Charles 中只出现握手失败或无请求记录

代理路径仍然有效,Safari 仍然可以抓到数据。

说明:

  • 阻断发生在 App 内部
  • 系统信任代理证书不代表 App 会信任

在这里继续重复安装证书不会改变结果。


四、是否必须越狱才能继续?

不越狱依然有两种路径可以尝试。

路径一:分析握手层

可以通过底层抓包确认:

  • 是否存在 TLS ClientHello
  • 是否建立 TCP 连接

如果 TLS 握手存在,说明流量确实发出,只是代理无法接管。


路径二:设备本机抓包

这里切换抓包方式。

使用 SniffMaster 进行设备本机 HTTPS 抓包

SniffMaster 支持通过 USB 在电脑上直接抓取 iOS 设备流量。

操作步骤

  1. 用 USB 将 iPhone 连接电脑
  2. 保持设备解锁并点击“信任此电脑”
  3. 启动 SniffMaster
  4. 在设备列表中选择对应 iPhone
  5. 按提示安装驱动与描述文件
  6. 进入 HTTPS 暴力抓包模式
  7. 点击开始
  8. 触发 App 请求

没有配置 Wi-Fi 代理,也没有安装代理证书。 暴力抓包


五、证书校验 App 的抓包结果

在设备抓包模式下测试同一个启用证书校验的 App。

结果:

  • 请求可以看到
  • HTTPS 内容显示正常
  • 未出现握手失败

区别来自抓包场景。

代理模式依赖替换证书,设备直接抓包不依赖中间人证书。


六、当请求体为空时的判断

如果抓到的 HTTPS 中:

  • URL 可见
  • Header 可见
  • Body 为空

这与越狱无关,而与签名有关。

若测试的是 App Store 下载的应用,需要:

  1. 获取 IPA
  2. 使用 iOS 开发证书重签
  3. 重新安装
  4. 再次抓包

完成后,请求体与响应体可完整显示。


七、双向认证(mTLS)的测试

在双向认证场景中:

  • 代理抓包会在握手阶段失败
  • 设备级抓包仍可观察到 TLS 会话

关键点是抓包工具是否依赖代理替换证书

参考链接:www.sniffmaster.net/tutorial/zh…

iOS相机权限获取

作者 傅里叶
2026年2月27日 11:25
语言:Flutter
问题:获取相机弹窗的权限不出来,iOS系统设置里面对应app也没有相机选项。是宏没有打开
解决方法:打开宏

Podfile

# post_install do |installer|
#   installer.pods_project.targets.each do |target|
#     flutter_additional_ios_build_settings(target)
#     # 为所有 Pod 目标启用 dSYM 生成
#     target.build_configurations.each do |config|
#       if config.name == 'Release'
#         config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
#       end
#     end
#   end
# end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)

    target.build_configurations.each do |config|

      if config.name == 'Release'
        config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
      end

      #  permission_handler 必须的宏配置
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
        '$(inherited)',

        'PERMISSION_CAMERA=1',

      ]

    end
  end
end

这段 Podfile 的 post_install 配置是正确的


一、post_install 是什么 🔧

post_install do |installer|

意思是:

CocoaPods 安装完成后执行这段脚本。

也就是在执行:

pod install

之后运行。

用途:

  • 修改 iOS 编译参数
  • 给插件加配置
  • 修复 Flutter / iOS 插件问题

Flutter 权限插件基本都用这个。


二、installer.pods_project.targets.each do |target|

installer.pods_project.targets.each do |target|

意思是:

遍历所有 Pod 插件。

例如:

  • permission_handler
  • mobile_scanner
  • ffmpeg_kit
  • camera
  • etc

这段代码会对 所有插件生效

相当于:

给所有插件统一设置编译参数。


三、flutter_additional_ios_build_settings(target)

flutter_additional_ios_build_settings(target)

意思:

Flutter 自动生成的 iOS配置。

它会自动设置:

  • Swift版本
  • 架构支持
  • Bitcode
  • iOS版本兼容性

这是 Flutter 官方必须的一行。

不能删。


四、target.build_configurations.each do |config|

target.build_configurations.each do |config|

意思:

遍历 Debug / Release 编译模式。

iOS有三种模式:

  • Debug(开发)
  • Profile(性能分析)
  • Release(发布)

这段代码:

对每个模式分别设置参数。


五、DEBUG_INFORMATION_FORMAT = dwarf-with-dsym

if config.name == 'Release'
  config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
end

意思:

Release版本生成 dSYM 文件。

作用:

  • 崩溃日志解析
  • Crashlytics
  • TestFlight 崩溃分析

否则:

  • 崩溃日志看不懂

这是一个好习惯配置 👍

和权限无关。


六、最重要的一段(权限宏)⭐

config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [  '$(inherited)',  'PERMISSION_CAMERA=1',]

这是关键。

意思:

给 iOS 编译器设置宏定义。

等价于:

#define PERMISSION_CAMERA 1

告诉 permission_handler:

编译相机权限代码。


七、$(inherited) 是什么

'$(inherited)',

意思:

保留系统原来的设置。

如果不写:

可能破坏 Flutter 默认配置。

必须保留。

相当于:

在原来的基础上增加权限宏。


八、PERMISSION_CAMERA=1 是什么 ⭐

'PERMISSION_CAMERA=1',

意思:

打开相机权限模块。

permission_handler 内部代码类似:

#if PERMISSION_CAMERA
// 相机权限代码
#endif

如果没有:

#if PERMISSION_CAMERA

条件不成立。

相机代码不会编译进去。

结果就是:

  • request() 不弹窗
  • denied
  • 设置里没有权限

之前就是这个问题。


九、整体逻辑总结(核心理解)

配置现在等价于:

安装所有插件
Flutter配置生效
Release版本生成崩溃符号
开启相机权限模块

流程:

pod install
   ↓
执行 post_install
   ↓
给所有插件加 PERMISSION_CAMERA=1
   ↓
Xcode 编译
   ↓
permission_handler 包含相机权限代码
   ↓
request() 正常弹窗

十、建议升级为完整权限配置(推荐)🔥

建议改成:

config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [  '$(inherited)',  'PERMISSION_CAMERA=1',  'PERMISSION_PHOTOS=1',  'PERMISSION_MICROPHONE=1',]

原因:

以后大概率会用:

  • 相册
  • 麦克风
  • 视频

否则未来又会踩坑。


十一、最关键结论

现在这段:

'PERMISSION_CAMERA=1',

就是让:

Permission.camera.request();

真正生效的原因。

没有它:

iOS 会表现为:

  • 不弹窗
  • denied
  • 设置没有权限

flutter存储知识点总结

作者 zhangkai
2026年2月26日 16:36

一、数据存储

1、本地持久化存储SharedPreferences

SharedPreferences为轻量级存储,存储少量简单数据,键值对形式,不适合大量、复杂数据的存储。

import 'package:shared_preferences/shared_preferences.dart'; 
// 存储数据 
Future<void> saveData() async { 
// 获取 SharedPreferences 实例 
final prefs = await SharedPreferences.getInstance(); 
// 存储不同类型的数据 await prefs.setString('user_token', 'abc123456'); // 字符串
await prefs.setInt('user_age', 25); // 整数 a
wait prefs.setBool('is_login', true); // 布尔值 
await prefs.setDouble('height', 1.75); // 浮点数 
await prefs.setStringList('hobbies', ['读书', '运动']); // 字符串列表 } 

// 读取数据 Future<void> readData() async { 
final prefs = await SharedPreferences.getInstance(); 
// 读取数据(第二个参数是默认值,避免 null) 
String? token = prefs.getString('user_token') ?? ''; 
int age = prefs.getInt('user_age') ?? 0;
bool isLogin = prefs.getBool('is_login') ?? false; 
print('token: $token, age: $age, isLogin: $isLogin'); 
} 
// 删除数据 
Future<void> removeData() async { 
final prefs = await SharedPreferences.getInstance(); await prefs.remove('user_token'); 
// 删除单个键 
// await prefs.clear(); 
// 清空所有数据 }

2、Provider存储

Provider 是运行时的内存状态管理工具非本地持久化存储,Provider 存储的数据只在 App 运行时有效,重启后丢失。它的核心价值是让数据在多个 Widget 之间共享、响应式更新,是 “内存级” 的数据存储与共享方案。适用于需要跨组件共享、实时响应更新的运行时数据。 Provider的原理是基于 Flutter 原生的 InheritedWidget 实现的,而 InheritedWidget 的核心特性就是通过 Context 向上查找共享数据

  • 从 Provider 中读取 / 监听数据:必须依赖 context(因为要确定查找的上下文范围);

  • 从 Provider 中修改数据:通常也需要 context,但有替代方案(无需 Context);

  • 初始化 / 注入 Provider:不需要 context(在根节点创建时)。

2.1 定义数据模型
import 'package:flutter/foundation.dart';

class ContractDataModel extends ChangeNotifier {
  // ========== 核心数据字段 (仅保留3个示例) ==========

  // 1. 房源类型名称 (来自第一步)
  String _goodsTypeName = '';

  // 2. 租客姓名 (来自第二步)
  String _customerName = '';

  // 3. 租金单价 (来自第三步)
  String _unitPrice = '';

  // ========== Getters ==========
  String get goodsTypeName => _goodsTypeName;
  String get customerName => _customerName;
  String get unitPrice => _unitPrice;

  // ========== 统一更新方法 ==========
  /// 更新核心数据
  /// 只要传入的值不为 null 就更新,允许空字符串覆盖原有数据
  void updateCoreInfo({
    String? goodsTypeName,
    String? customerName,
    String? unitPrice,
  }) {
    if (goodsTypeName != null) _goodsTypeName = goodsTypeName;
    if (customerName != null) _customerName = customerName;
    if (unitPrice != null) _unitPrice = unitPrice;

    // 通知监听者重建 UI
    notifyListeners();
  }

  // ========== 验证数据是否完整 (示例) ==========
  bool validateCoreInfo() {
    if (_goodsTypeName.isEmpty) return false;
    if (_customerName.isEmpty) return false;
    if (_unitPrice.isEmpty) return false;
    return true;
  }

  // ========== 获取所有数据的 Map ==========
  Map<String, dynamic> toMap() {
    return {
      'goodsTypeName': _goodsTypeName,
      'customerName': _customerName,
      'unitPrice': _unitPrice,
    };
  }

  // ========== 清空所有数据 ==========
  void clear() {
    _goodsTypeName = '';
    _customerName = '';
    _unitPrice = '';

    notifyListeners();
  }
}
2.2 更新provider中的数据
final contractModel = Provider.of<ContractDataModel>(context, listen: false);
contractModel.updatePersonInfo(
  customerCertificateId: _customerCertificateId,
  customerName: _customerName,
);
2.3 获取provider,提取内部的数据
late _contractDataModel = Provider.of<ContractDataModel>(context, listen: false);
if(_contractDataModel.signClientType == '1'){//企业
  _customerCertificateType = 'Z';
}else{
  _customerCertificateType = '';
}

tips: 使用late的作用是什么? late 是用来修饰延迟初始化变量的关键字。

  • late用来声明变量时无法立即赋值的问题,Provider依赖context,而 context 通常在 Widget 的 build 方法、initState的位置才能获取,声明无法获取从而报错。
  • 允许变量非空但延迟复制。(Dart空安全核心)Dart 开启空安全后,未用 ? 标记的变量必须声明时赋值或用 late 修饰。

image.png

二、Provider 流程页面剖析

image.png

应该这样理解:

✅ Provider 是在 CreateReserveStepPage 中创建的(第52行)

✅ 三个 Step Widget 各自有自己独立的 context

✅ 但它们都是 ChangeNotifierProvider 的子孙节点

✅ 所以它们都能通过各自的 context 向上查找,找到同一个 Provider 实例

或者说:

✅ 三个 Widget 的 context 都能访问到在 CreateReserveStepPage 中创建的 Provider,因为它们都在 Provider 的子树中。

如下:

class CreateReserveStepPage extends StatefulWidget {
final Map params;
const CreateReserveStepPage({Key key,this.params}) : super(key: key);
@override
_CreateReserveStepPage createState() => _CreateReserveStepPage();
}

class _CreateReserveStepPage extends State<CreateReserveStepPage> {
final BrnMetaHorizontalStepsManager _stepsManager = BrnMetaHorizontalStepsManager();
int _currentIndex = 0;
bool _isCompleted = false;
Timer _timer;
int _elapsed = 0;
Map<String, dynamic> _contractParams = {};
final List<String> _stepTitles = ['合同信息', '租客/入住人信息', '账单/补充信息'];
// 验证回调函数
Function _validateContractWidget;
Function _validatePersonWidget;
Function _validateBillWidget;
// 保存数据回调函数
Function _savePersonWidget;
Function _saveBillWidget;
// 添加ScrollController
final ScrollController _scrollController = ScrollController();
void initState() {
  super.initState();
}

@override
void dispose() {
  _scrollController.dispose();
  super.dispose();
}

@override
Widget build(BuildContext context) {
  return ChangeNotifierProvider(
    create: (_) => ContractDataModel(),
    builder: (providerContext, child) {
      return Scaffold(
        appBar: BrnAppBar(
          title: '创建签约',
          leading: IconButton(
            icon: Icon(Icons.arrow_back_ios_new, color: Colors.black),
            onPressed: () => BoostUtil.finish(),
          ),
        ),
        body: Column(
          children: [
            _stepsManager.buildSteps(
              steps: _stepTitles.map((title) => BrunoStep(stepContentText: title)).toList(),
              currentIndex: _currentIndex,
              isCompleted: _isCompleted,
            ),
            const SizedBox(height: 24),
            Expanded(
              child: SingleChildScrollView(
                controller: _scrollController,
                child: _buildStepContent(_currentIndex),
              ),
            ),
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
              decoration: BoxDecoration(
                color: Colors.white,
                border: Border(top: BorderSide(color: Color(0xFFF0F0F0), width: 1)),
              ),
              child: _buildBottomButtons(providerContext),
            ),
          ],
        ),
      );
    },
  );
}

Widget _buildStepContent(int index) {
  switch (index) {
    case 0:
      return ReservationStepContractWidget(
        params: this.widget.params,
        onDataChanged: _handleContractDataChanged,
        onValidateCallback: (validateFunc) {
          _validateContractWidget = validateFunc;
        },
      );
    case 1:
      return ReservationStepPersonWidget(
        onDataChanged: _handlePersonDataChanged,
        onValidateCallback: (validateFunc) {
          _validatePersonWidget = validateFunc;
        },
        onSaveCallback: (saveFunc) {
          _savePersonWidget = saveFunc;
        },
      );
    case 2:
      return ReservationStepBillWidget(
        onDataChanged: _handleBillDataChanged,
        onValidateCallback: (validateFunc) {
          _validateBillWidget = validateFunc;
        },
        onSaveCallback: (saveFunc) {
          _saveBillWidget = saveFunc;
        },
      );
    default:
      return SizedBox();
  }
}

网络知识点 - TCP/IP 四层模型知识大扫盲

作者 齐生1
2026年2月26日 11:46

一、计网基础概念

第一章先总体回顾一下

1.1 OSI 七层模型与 TCP/IP 四层模型

OSI 模型 TCP/IP 核心职责 常见协议 iOS 关联
7.应用层 4. 应用层 提供应用服务接口,定义数据格式与交互语义 HTTP, HTTPS, DNS, WebSocket NSURL、URLSession
6.表示层 (并入应用层) 加密、压缩、格式转化 SSL/TLS, JSON, JPEG, UTF-8
5.会话层 (并入应用层) 会话管理、状态保持 TLS 握手、RPC、Session
4.传输层 3. 传输层 端到端通信、可靠传输 TCP、UDP、QUIC Socket
3.网络层 2. 网络层 路由寻址、分片 IP、路由、ICMP、ARP
2.数据链路层 1. 网络接口层 MAC寻址、帧封装 MAC、Wi-Fi、Ethernet
1.物理层 (并入网络接口层) 比特流传输 光线、电缆、无线信号

网络通信是一条从 应用层 → 内核网络栈 → 网络接口 → 传输介质 → 对端主机 的完整路径。

1.2 数据封装与解封装

数据在网络中是如何传递的?

  • 以 TCP 为例:
你的 App 数据:{"name":"张三"}
      │
      ▼ ④ 应用层:加 HTTP 头(方法、路径、Host、…)
┌──────────────────────────────────────┐
│ HTTP Header │ JSON: {"name":"张三"}   │ ← 消息(Message)
└──────────────────────────────────────┘
      │
      ▼ ③ 传输层:加 TCP 头(源端口、目的端口、序列号、…)
┌────────────────────────────────────┐
│ TCP Header │ HTTP Header │ JSON    │ ← 段(Segment)
└────────────────────────────────────┘
      │
      ▼ ② 网络层:加 IP 头(源IP、目的IP、TTL、协议号=6)
┌──────────────────────────────────────────────┐
│ IP Header │ TCP Header │ HTTP Header │ JSON  │ ← 包(Packet)
└──────────────────────────────────────────────┘
      │
      ▼ ① 网络接口层:加 MAC 头(源MAC、目的MAC) + 尾部校验(FCS)
┌────────────────────────────────────────────────────────────────┐
│ MAC Header │ IP Header │ TCP Header │ HTTP Header │ JSON │ FCS │ ← 帧(Frame)
└────────────────────────────────────────────────────────────────┘
  • 以 UDP 为例:
你的 App 数据:{"query":"example.com"}
      │
      ▼ ④ 应用层:加上应用层协议头(例如 DNS / 自定义协议)
┌──────────────────────────────────────────────┐
│ DNS Header │ JSON: {"query":"example.com"}   │ ← 消息(Message)
└──────────────────────────────────────────────┘
      │
      ▼ ③ 传输层:加 UDP 头(源端口、目的端口、长度、校验和)
┌──────────────────────────────────────────────┐
│ UDP Header │ DNS Header │ JSON               │ ← 数据报(Datagram)
└──────────────────────────────────────────────┘
      │
      ▼ ② 网络层:加 IP 头(源IP、目的IP、TTL、协议号=17)
┌────────────────────────────────────────────────────────┐
│ IP Header │ UDP Header │ DNS Header │ JSON             │ ← 包(Packet)
└────────────────────────────────────────────────────────┘
      │
      ▼ ① 网络接口层:加 MAC 头(源MAC、目的MAC) + 尾部校验(FCS)
┌────────────────────────────────────────────────────────────────────────────┐
│ MAC Header │ IP Header │ UDP Header │ DNS Header │       JSON       │  FCS │ ← 帧(Frame)
└────────────────────────────────────────────────────────────────────────────┘

接下来按照数据封装的顺序,依次回顾一下各个层级的知识。


二、应用层(应用层 + 表示层 + 会话层)

App 层逻辑,包括 DNS、HTTP、HTTPS、WebSocket、缓存、认证体系。

2.1 DNS 域名解析

作用:  将域名 https://some.com 解析为 IP 地址,是一切请求的起点。

  • 从 输入网址 -> 页面返回,过程是怎样的?

    • 解析顺序: 浏览器缓存 → OS 缓存 → hosts → 本地 DNS → 根服务器 → 顶级域服务器 → 权威服务器;
    • 返回 IP 后进行 TCP(三次握手)→ TLS 握手 → HTTP 请求。
    ┌──────────────────────────────────────┐
    │   访问 https://api.example.com/path   │
    └──────────────────────────────────────┘
    ① 用户输入网址
       └──> 浏览器解析 URL
             协议 = https、主机 = api.example.com、端口 = 443、路径 = /path
    
    ② DNS 解析域名(api.example.com → IP)
       │
       ├─ 1) 查 [本机缓存](浏览器 DNS 缓存 → OS DNS 缓存 → hosts 文件)
       │
       ├─ 2) 未命中 → 请求 [本地 DNS 服务器](路由器 / 运营商)
       │
       └─ 3) 仍未命中 → [本地 DNS 服务器] 发起 [迭代查询](由它代跑,浏览器只等结果)
             │
             ├─ 问 [根 DNS](.)
             │  └─ 回答:"去问 .com 的服务器"
             ├─ 问 [顶级域 DNS](.com)
             │  └─ 回答:"去问 example.com 的权威服务器"
             └─ 问 [权威 DNS](example.com)
                └─ 回答:"api.example.com = 203.0.113.8" ✓
                   └─ 本地 DNS 缓存结果(根据 TTL),返回给浏览器            
    
    ③ 建立 TCP 连接(三次握手)
    
    ④ TLS 握手(HTTPS 特有,在 TCP 之上建立加密信道)
    
    ⑤ 发送 HTTP 请求(数据从上到下逐层封装)
       └──> [1.2 数据封装与解封装] 知识点
    
    ⑥ 服务器处理请求并返回响应
       │
       ├─ 1) 解封帧(拆 MAC -> ... -> 拿到 HTTP 请求)
       │
       ├─ 2) 业务处理(路由匹配 -> 鉴权 -> 查库 -> 生成结果)
       │
       └─ 3) 构建 HTTP 响应,逐层封装,发回客户端
    
    ⑦ 浏览器接收响应
    
    ⑧ 连接关闭(四次挥手,或保持复用)
       ├─ HTTP/1.1 Keep-Alive → 连接放入连接池,后续请求复用
       ├─ HTTP/2 → 同一连接上继续多路复用
       └─ 不再需要时 → 四次挥手断开:
    
  • DNS 劫持

    • 现象:
      • App -> 运营商 DNS 服务器(可能被劫持)-> 返回错误的 IP
    • 解决方案:HTTPDNS(绕过运营商,App 直连 DNS 服务)
      • App -> 通过 HTTP 直接请求 HTTPDNS 服务器(绕过运营商)-> 返回正确的 IP

2.2 HTTP 协议

HTTP(80)、HTTPS(443)

HTTP 是一种基于 “请求-响应” 的无状态的应用层协议,每次请求都是独立的。

最初就是为浏览器与 Web 服务器设计的。

2.2.1 HTTP 基本信息

  • HTTP 请求 = 请求行 + 请求头 + 空行 + 请求体
POST /api/users HTTP/1.1                ← 请求行:方法、路径、版本
Host: api.example.com                   ← 请求头:多行参数
...
...
                                        ← 空行:分隔头和体
{"name":"张三","email":"z@example.com"}  ← 请求体
  • HTTP 响应 = 状态行 + 响应头 + 空行 + 响应体
HTTP/1.1 201 Created                     ← 状态行:版本、状态码、描述
Content-Type: application/json           ← 响应头
...
...
                                         ← 空行
{"id":456,"name":"张三","created":true}   ← 响应体


2.2.2 HTTP 常见的请求方法

方法 语义 幂等? 安全? 有请求体? 典型场景
GET 获取资源 通常没有 获取用户列表、详情页
POST 创建资源/提交数据 注册、下单、上传
DELETE 删除资源 通常没有 删除订单
HEAD 同 GET 但只要头部 没有 检查资源是否更新
  • 幂等: 执行 1 次和执行 N 次效果相同。
    • GET 请求 10 次,拿到的是同一份数据,幂等✅;
    • DELETE 删 10 次,资源还是被删了(第 2 次返回 404),幂等✅;
    • 但 POST 创建订单 10 次,可能创建 10 哥订单,不幂等❌;
  • 安全: 不会修改服务器资源。
    • GET、HEAD 不会修改服务器资源,只读,安全✅;
    • POST/DELETE 有写操作,不安全❌;

思考: 实际项目中,为什么大部分是 POST 而非 GET?大部分场景不是只需要 “读” 吗?

  • 为安全、加密、签名、防重放、复杂度等。
  1. 请求体加密

    • 很多项目会对请求参数做 AES 等对称加密,将整个参数序列化后加密放在 request body 中传输。GET 请求没有 body,参数只能拼在 URL query string 里,无法做 body 级别的加密。
  2. 签名机制与 HTTP 方法绑定

    NSString *source = [NSString stringWithFormat:@"POST&%@&%@", pathEncoding, paramEncoding];
    NSString *hash = [self HmacSha1:kAppKey data:source];
    
    • 常见的 API 签名方案会把 HTTP 方法作为签名原文的一部分,客户端和服务端按同一规则生成签名并校验。一旦签名协议绑定了 POST,改用 GET 会导致签名不一致、请求被拒绝。
  3. 公共参数太多,URL 长度受限

    • 每个请求通常会自动携带大量公参(设备信息、版本号、签名、时间戳、MD5、...),GET 的请求参数在 URL 里:
      • URL 容易超长,超出 中间代理/CDN 的长度限制;
      • 参数结构复杂时,编码笨重;
  4. 敏感信息不易暴露在 URL 中

    • GET 请求的参数在 URL 里,会被以下环节明文记录:
      • 服务端 access log
      • CDN 日志
      • 浏览器/WebView历史记录
      • 网络抓包/运营商等
    • 而用户凭证(uid/sid/token)、设备指纹、签名等都是敏感数据。
  5. 防重放机制的需要

    • 为了防止请求被截获后重放,通常每个请求都会带上 nonce(随机数)和 millisecond(时间戳),并参与签名计算,GET 请求的缓存机制反而会造成干扰。
    • POST 天然不会被缓存,与防重放设计更契合。
  6. 统一方案降低复杂度

    • 如果 GET 和 POST 混用,就需要【签名逻辑、加密方案、服务端解析逻辑、网关/中间件的安全策略】 等都需要区分两套,维护成本升高。

2.2.3 HTTP 常见的状态码

|状态码 | 说明 |
| --- |  -- |
|`1xx`(上传前试探)|`100`: 服务器说"继续发吧",用于大文件上传前的试探<br>`101`: 协议升级,WebSocket 握手就用这个|
|`2xx`(成功)|`200`: 最常见的 OK|
|`3xx`(重定向)|`301`: 永久重定向|
|`4xx`(客户端错误)|`400`: 请求格式错误<br>`401`: 没登录或Token过期<br>`403`: 登录了但没权限<404>资源不存在|
|`5xx`(服务端错误)|`500`: 服务器崩了|

2.3 HTTP 缓存机制

HTTP 缓存分为两阶段机制: 强缓存(freshness)→ 协商缓存(validation)

“先看时间(强缓存),再问服务器(协商缓存)。”

  1. 首次请求
    • 服务器返回资源 + 缓存控制信息(如 Cache-Control, ETag, Last-Modified)。
  2. 强缓存阶段(freshness)
    • 客户端检查本地缓存是否仍在有效期(由 Cache-Control: max-age 或 Expires 判断)。
    • 若未过期 → 直接使用本地副本,不访问服务器。
  3. 协商缓存阶段(validation)
    • 强缓存过期或被标记需验证,则 客户端带验证头请求服务器
      • If-None-Match: <etag>
      • 或 If-Modified-Since: <time>
    • 服务器判断资源是否变化:
      • 未变化 → 304 Not Modified(仅返回头部,客户端复用旧内容);
      • 已变化 → 200 OK(新资源内容)。
  • 常见 Header:
分类 Header 作用
强缓存 Cache-Control: max-age=3600 指定可直接使用的秒数
Expires: 老式写法,被 Cache-Control 覆盖
协商缓存 ETag / If-None-Match 内容标签验证
Last-Modified / If-Modified-Since 修改时间验证
其他 Vary 声明缓存与哪些请求头有关
private / public 是否允许代理缓存
  • 常见配置:
场景 Header 示例
静态资源 Cache-Control: public, max-age=31536000, immutable
动态接口 Cache-Control: no-cache + ETag
敏感数据 Cache-Control: no-store

2.4 Cookie、Session、Token —— 认证三兄弟

HTTP 是无状态协议 ———— 服务器不记得你是谁。每次请求都是独立的,但是有很多场景需要 “记住用户”(登录态、用户身份等),于是就有了他们仨。

名称 存储位置 主要作用 特点
Cookie 浏览器 / 客户端 存放少量数据,携带 Session ID 每次请求自动携带到服务器
Session 服务端 保存用户状态(如登录态) 有状态,需要共享。
依赖 Cookie 或 URL 中的 Session ID
Token 客户端 身份凭证(常为加密签名) 服务端无状态验证,跨端通用

Web 用 Session,App 常用 Token 鉴权。

  • Cookie

    • 本质: HTTP 头中由服务器通过 Set-Cookie 下发的 键值对。客户端保存后,在后续请求 自动携带 Cookie 头。
    • 示例:
      • Set-Cookie: session_id=abc123; HttpOnly; Secure; Max-Age=3600
      • Cookie: session_id=abc123
  • Session(服务端状态)

    • 流程:

      1. 用户登录 -> 服务端验证成功,生成唯一 Session ID;
      2. 服务端保存登录信息(uid、权限等)到内存或 redis;
      3. Session ID 下发给客户端(通常通过 Cookie);
      4. 后续请求客户端自动携带 Session ID, 服务器查表恢复状态。
    • 特点:

      • 状态保存在服务器(有状态);
      • 适合小规模 / 单机服务;
      • 分布式时需要共享 Session (如 Redis 集中存储)。
  • Token(无状态身份验证)

    • 原理: 服务端不保存状态,只验证 Token 的合法性。
    • 常见类型:
      • JWT(JSON Web Token):Header.Payload.Signature 三段式 Base64 编码。
    • 验证流程:
      1. 登录成功后生成 Token(带用户信息 + 过期时间 + 签名)。
      2. 客户端保存(如 iOS Keychain).
      3. 每次请求带头部: Authorization: Bearer <token>
      4. 服务端验签(是否过期、签名是否匹配)。
    • 特点:
      • 无需服务器保存状态;
      • 一旦签发,撤销复杂(需要黑名单机制);
      • Token 通常短期有效,需要配合 Refresh Token 使用。

Session 与 Token 对比

项目 Session Token
状态保存 服务端 客户端(无状态)
可扩展性 弱(需共享 Session) 强(验证即可)
安全性 依赖 Cookie 保护 依赖签名/加密
登出控制 服务端可立即失效 需黑名单或等待过期
常见场景 Web 登录态 移动端 / API 鉴权

2.5 HTTPS 与 TLS

TLS 是 SSL 的后继协议,SSL 已被淘汰。

HTTPS = HTTP + TLS。TLS 在 TCP 之上、HTTP 之下,提供三大安全保障:

保障 含义 实现方式
加密(Encryption) 防窃听 对称加密(AES)
认证(Authentication) 防伪造 数字证书 + CA 体系
完整性(Integrity) 防篡改 MAC(消息认证码)

HTTPS = HTTP + 加密 + 认证 + 完整性

TLS 握手流程:

image.png

  1. 客户端发送 随机数与算法列表
  2. 服务端返回 证书与随机数
  3. 客户端 验证证书,生成 会话密钥
  4. 使用 非对称算法 安全交换 对称密钥,后续双方使用 对称密钥加密通信

image.pngTLS 1.3 优化了流程:

  • 握手仅需 1-RTT(一次往返),更快;
  • 默认强加密算法(如 AES-GCM、ChaCha20);
  • 支持 0-RTT 快速重连。

iOS 强制使用 TLS1.2+(ATS),支持证书 Pinning。

类型 用途 特点
非对称加密(RSA、ECDHE) 握手阶段,用于密钥协商 安全但慢
对称加密(AES、ChaCha20) 传输阶段,用同一密钥加解密数据 快速

2.6 WebSocket 协议

HTTP 是"你问我答"(请求-响应)模式。客户端不问,服务器不答。但很多场景需要服务器 主动推送.

  • 在 WebSocket 之前,人们用各种"土办法"模拟:
    方案 原理 缺点
    轮询(Polling) 客户端定时发 HTTP 请求(如每 3 秒一次) 浪费带宽和电量,延迟高
    长轮询(Long Polling) 客户端发请求,服务器有数据时才响应,否则挂起直到超时 服务器资源开销大
    SSE(Server-Sent Events) 服务器单向推送 只能服务器→客户端,不支持双向

而 WebSocket 才是真正的解决方案:全双工、持久连接、双方可以随时发数据。

  • iOS 实践:  使用 URLSessionWebSocketTask。

WebSocket 握手

  • 本质: 就握手本质就是一个 HTTP 请求,只不过请求目的不是获取数据,而是 请求协议升级
  • 步骤:
    1. 客户端发送一个特殊的 HTTP 请求 GET /chat HTTP/1.1 ← 还是普通的 HTTP 请求 Host: server.example.com Upgrade: websocket ← "我想升级为 WebSocket 协议" Connection: Upgrade ← "这是一个升级请求" Sec-WebSocket-Key: dGhlIHNhbXBsZQ== ← 随机 Base64 值(防伪造) Sec-WebSocket-Version: 13 ← WebSocket 协议版本 Origin: example.com ← 来源(可选,用于安全校验)
    2. 服务器同意升级 HTTP/1.1 101 Switching Protocols ← 101 = "我同意切换协议了" Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGz... ← 基于 Key 计算的值(验证对方确实懂 WebSocket)
    3. 协议升级完成
      • 从此刻起,这条 TCP 连接不再说 HTTP 了,双方改说 WebSocket 的帧协议(Frame Protocol),双向随时发数据,没有请求-响应的限制。
握手前(HTTP):                  握手后(WebSocket):
┌──────────────┐               ┌──────────────┐
│   HTTP/1.1    │  ── 升级 ──→  │   WebSocket   │
│   文本协议     │               │   二进制帧协议  │
│   请求-响应    │               │   全双工       │
│   头部几百字节  │               │   帧头 2~14字节 │
└──────────────┘               └──────────────┘
       ↑                              ↑
  还是跑在 TCP 之上              还是同一条 TCP 连接
  (如果 wss:// 则还有 TLS)     (连接没断,只是协议变了)
  • Sec-WebSocket-Key / Sec-WebSocket-Accept 是做什么的?
    • 简单的 防伪造机制, 不是加密,验证对方是否是 WebSocket 服务器。

WebSocket vs HTTP 对比

维度 HTTP WebSocket
通信模式 请求-响应(半双工) 全双工
连接 短连接或 Keep-Alive 持久连接
头部开销 每次请求带完整头部(几百字节) 握手后每帧只有 2~14 字节头部
服务器推送 不支持(只能客户端发起) 支持
URL 协议 http:// / https:// ws:// / wss://
适用场景 API 调用、资源获取 实时通信、推送、游戏

iOS 一般使用原生的 URLSessionWebSocketTask(iOS 13+)。

为什么有些项目使用自定义协议(例如[包长度|protobuf序列化数据]),不使用 WebSocket?

WebSocket 自定义协议
建连开销 TCP 握手 + HTTP 升级握手(多一个 RTT) TCP 握手后直接发数据
帧头开销 2~14字节(opcode/mask/长度) 4字节(只写长度就够了)
心跳 固定的 ping/pong 自定义 Protobuf 心跳(可带业务数据)
压缩 整个连接统一压缩策略 不同消息可选不同的压缩策略
传输层 只能 TCP 可以 TCP / KCP 等随时切换
浏览器兼容 ✅这是它存在的意义 ❌但是 App 不需要

2.5 HTTP 版本演进(HTTP/1.0 -> HTTP/1.1 → HTTP/2 → HTTP/3)

  • HTTP/1.0

    • 引入请求头、状态码。
    • 问题:
      • 最初版本,每次请求都需要重新建立连接。
  • HTTP/1.0 -> HTTP/1.1()

    • 问题: HTTP/1.0 每次请求都要新建 TCP 连接,用完就断开,资源浪费;
    • 改进:
      • 持久连接(Keep-Alive): 默认不断开,复用同一个 TCP 连接发送多个请求;
      • 管道化(Pipelining): 连续发送 多个请求,不用等待上一个响应回来。
    • 仍有问题:
      • 对头阻塞(HTTP 层): HTTP/1.1 规定响应必须按照请求顺序返回,如果第 1 个请求慢了,后续全阻塞。
  • HTTP/1.1 -> HTTP/2(大幅性能提升,显著减少延迟与连接数量)

    • 改进:
      • 二进制分帧: 不再是文本协议,把数据拆成带有 Stream ID 标记的帧(Frame)传输;
      • 多路复用: 一个 TCP 连接上 并行传输 多个请求和响应的帧,互不阻塞;
        • ‼️每个帧都标记了 Stream ID,接收方根据 ID 把帧重新组装成各自的响应。帧可以交错发送,谁先好就先发谁—— HTTP 层的队头阻塞 解决了。
      • 头部压缩(HPACK): HPACK 用静态表+动态表+哈夫曼编码大幅压缩头部;
      • 服务器推送(Server Push): 服务器主动推送资源;
    • 仍有问题:
      • ‼️多路复用虽然解决了 HTTP 层的队头阻塞,但底层 TCP 的对头阻塞还在——TCP 丢一个包,整个连接上所有 Stream 都得等重传。
        • ‼️因为底层用的还是 一条 TCP 连接,TCP 的特性 保证字节流严格有序到达所有 Stream 共享一条字节流,空洞就等,等到补齐为止。
  • HTTP/2 -> HTTP/3(基于 QUIC,连更低的头部延迟与更快的握手)

    • 改进:

      特性 HTTP/2 (TCP) HTTP/3 (QUIC/UDP)
      传输层 TCP UDP(自己实现可靠传输)
      队头阻塞 TCP 层有 完全消除(各 Stream 独立)
      握手延迟 TCP 握手 + TLS 握手 QUIC 内置 TLS,1-RTT
      重连 全新握手(WiFi→蜂窝) 0-RTT 恢复(快速重连)
      连接迁移 基于四元组,换 IP 就断 Connection ID 标识,换 IP 不断
      • ‼️解决 TCP 层对头阻塞
        • 不用 TCP 了,在 UDP 上自己造一个传输层,让每个 Stream 都有独立的序号和重传机制,每个 Stream 有自己独立的字节流,互不干扰。
      • 对 iOS 端的意义: 用户在 WiFi 和蜂窝之间可以做到无感切换
      传统 TCP(HTTP/2):
      用户走出公司(WiFi)→ 走上街(蜂窝) → IP 地址变了 → TCP 连接断了 → 重新握手
      QUIC(HTTP/3):
      用户走出公司(WiFi)→ 走上街(蜂窝) → IP 变了但 Connection ID 没变 → 连接无缝切
      

三、传输层:TCP、UDP、QUIC、KCP

3.1 TCP

TCP 是一个 面向连接、可靠、有序、全双工 的传输协议。在建立连接前,双方需要达成 3 个共识:

  1. 确认通信能力: 双向通信,双方都能收能发。
  2. 交换初始序列号(ISN): 每个字节都有序号,双方要各自告知自己的起始序号。
  3. 防止历史连接的干扰: 网络中的旧报文不能让新连接误认为是有效的。

三次握手就是为了解决这三件事。


3.1.1 TCP 三次握手

image.png

  • 过程:
    • 第 1 次: C -> 连接请求 (SYN) -> S
    • 第 2 次: S -> 收到请求后,发送连接请求的确认请求 (SYN+ACK) -> C
    • 第 3 次: C -> 收到请求后,发送确认请求的确认请求 (ACK) -> S

常见问题:

1. 为什么不是 2/4 次握手,而是 3 次握手?

  • 2 次握手,双端状态可能不一致,存在 “半开连接” 和 “旧连接干扰” 问题。

  • 3 次握手 的作用:

    1. 确认双向通信可达:
      • 3 次握手保证 C、S 双方相互得知对方的收发能力正常;
      • 如果只有 2 次握手,S 无法得知 C 的接收能力是否正常。
    2. 防止旧连接干扰:
      • 3 次握手通过新的 ISN 和确认机制,可以识别出过期连接并丢弃。
      • 如果只有 2 次握手,S 可能错误建立 “脏连接”,造成状态混乱。
    3. 建立可靠传输基础:
      • 3 次握手双方交换初始序列号,建立有序的、基于字节流的协议。

2. 如果第 3 次握手丢失,服务端、客户端分别如何处理?

第 3 次握手丢失会造成短暂的 “半开连接”,但 TCP 的 重传与超时机制 最终会恢复一致性。

  • 客户端
    • 进入 ESTABLISHED,认为连接成功,继续等待 S 响应或者发送数据。
  • 服务端
    • 仍在 SYN_RECEIVED,等待 ACK,定时器到期后会重传 SYN+ACK。
    • C 收到重传的 SYN+ACK 后,会再次发送 ACK;
    • 若多次重传仍无回应,S 会放弃连接,并释放 半连接资源
    • C 尝试发数据,若 S 未进入 ESTABLISHED,可能收到 RST 响应。

3. 为什么 ISN(初始序列号)不固定?

  • 防止旧连接干扰:
    • 网络中可能残留旧包,若新连接的 ISN 与旧连接相同,旧报可能被误认为是当前连接的数据。
  • 增强安全性:
    • 防止攻击者通过预测序列号来伪造 TCP 报文(TCP 盲注入攻击、会话劫持);
    • RFC 要求 ISN 必须 随时间变化且不可预测,现代操作系统(Linux/iOS/macOS)也都采用 “时间戳+随机扰动” 动态生成方式。

4. 3 次握手的过程中是否携带数据?

标准的 TCP 行为 是不会在握手阶段携带数据的:

  • 前 2 次握手 不携带数据,是因为 连接尚未建立接收方未分配接收缓冲区,若携带数据,可能造成安全与资源浪费问题(攻击者伪造 SYN 报文携带大量数据)。
  • 第 3 次握手 理论上可以携带数据,因为 C 已经进入 ESTABLISHED 状态了。但是大多实现出于简化和安全考虑,也不在 ACK 中携带数据。
    • 实现简化:
      • S 的 接收缓冲区和应用层 socket 可能还没准备好,处理逻辑复杂。
    • 安全问题:
      • 防止 DoS 攻击和数据误处理(若 ACK 丢失或重传,可能导致同一份数据被重复处理,S 在连接未确认时读渠道未认证的数据,会产生安全隐患(伪造、注入等风险))。

特殊情况: TCP Fast Open (TFO) 允许在 SYN 报文中携带应用数据并提前发出,实现 “0- RTT 建连”,但需要 S 支持。

5. “半开连接” 是怎么产生的?

  • 概念: 半开连接(Half-Open Connection) 是指连接的两端状态不同步:一方认为连接已建立或仍存在,另一方实际上未建立或已断开。
  • 后果: 半连接堆积(如 SYN Flood 攻击)。
  • 检测手段: 内核(SYN Cookies、防火墙限速)、应用层(心跳检测、超时挥手)。
  • 握手阶段丢包: 第 3 次握手丢失,S 进入 SYN_RECEIVED,客户端已进入 ESTABLISHED
    • 服务端 会将半开连接存放在 半连接队列 中。
  • 已连接后异常断线: C 退出 App,S仍认为连接存在,处于 ESTABLISHED,等待数据。
    • 需要依赖 KeepAlive 心跳 检测。
  • 关闭阶段异常: 某端未正确完成四次挥手(如 FIN/ACK 丢失)。
    • 双方状态不一致(FIN_WAIT / CLOSE_WAIT)。

扩展一下

1. 全双工 vs 半双工

全双工: 指通信双方可以 同时 进行发送和接收数据。

半双工: 双方都可以发送数据,但 不能同时,只能 “你说完我再说”。

模式 含义 能否同时发送/接收 TCP 中体现
单工 单向通信 几乎不用
半双工 双向但不同时 早期物理层通信
全双工 双向同时 TCP 是全双工协议
半关闭 一方发送关闭,但可接收 仅一方向关闭 TCP 四次挥手中的状态

2. 全连接队列 vs 半连接队列

服务端 TCP 内核实现层面的关键概念,

全连接队列: 存放 3 次握手已完成,但还未被应用层 accept() 接收的连接。

半连接队列: 存放已收到客户端 SYN,但 3 次握手尚未完成 的连接。

握手过程:

步骤 状态 队列
C -> 发送 SYN S 进入 SYN_RECIEVED 加入 半连接队列
S -> 发送 SYN+ACK,等待 ACK C 进入 ESTABLISHED 半连接队列等待确认
C -> 回 ACK 包 S 进入 ESTABLISHED 半连接队列 移入 全连接队列

特点:

  • 如果第 3 次握手迟迟不到,连接会在队列中等待一段时间,知道超市或超过最大重传次数,就删除这个 半连接
  • 全连接队列 已🈵,新握手完成的连接会被 丢弃/拒绝
  • 应用层调用 accept() 是,才会将连接从 全连接队列 中取出。

3.1.2 TCP 四次挥手

image.png

  • 过程:
    • 第 1 次: C -> FIN -> S,客户端主动发送 FIN 关闭发送通道,服务端收到 FIN 后 关闭接收通道
    • 第 2 次: S -> ACK -> C,服务器仍可继续 发送剩余数据
    • 第 3 次: S -> FIN -> C,服务端发送完所有数据后,主动发送 FIN 关闭发送通道,客户端收到 FIN 后 关闭接收通道
    • 第 4 次: C -> ACK -> S,服务端收到 ACK 后 立即关闭连接,客户等待 2MSL 后 彻底关闭连接,确保最后的 ACK 能被对方收到并防止旧包干扰。

常见问题:

1. 为什么是 “4 次挥手”,而不是 “3 次”?

  • 因为 TCP 是 全双工协议,双方的发送和接收通道是 独立的,每个方向都必须 单独关闭,因此有了 “4 次” 挥手。
    • 当一方(主动方)发出 FIN,只表示 “不再发送数据”,但是 “还能接收数据”;
    • 另一方(被动方)可能还没有发完数据,所以不能立即 FIN;
    • 另一方(被动方)必须等到自己也准备关闭时,再单独发出 FIN;
    • 因此需要一次 FIN 一次 ACK,再一次 FIN 一次 ACK,总共四次,确保双方都能 安全、完整地 关闭。

2. TIME_WAIT 是什么?

  • 主动关闭连接的一方,在发送最后一个 ACK 后会先进入 TIME_WAIT 状态,等待一段时间(2MSL)再彻底关闭连接(进入 CLOSED)。
    • MSL(Max Segment Lifetime): 网络中一个 TCP 报文可能存在的最长时间。
  • 为什么不直接进入 CLOSED 呢?
    1. 保证 “最后一个 ACK” 可靠传输
      • 如果 C 立即关闭,S 没收到 ACK,会重发 FIN;
      • 若 C 已关闭,就无法回应,导致服务端一直处于 LAST_ACK 状态。
    2. 防止旧连接的干扰
      • 如果立即关闭,并重新使用相同四元组(IP、PORT、协议),网络中延迟处理的旧包可能被当作新连接的数据;
      • TIME_WAIT 期间确保旧包在网络中全部消失后才关闭。

3. 为什么 TIME_WAIT 要持续 2 x MSL?

  • 一段 MSL 是为了 确保本连接最后发送的 ACK 能到达对方
  • 另一段 MSL 是为了 确保旧连接中的报文在网络中彻底消失

4. TIME_WAIT 常见问题

问题 原因 对策
TIME_WAIT 太多,占用端口 客户端大量主动关闭连接 1. 调整 tcp_tw_reuse、tcp_tw_recycle(Linux 旧版);2. 使用长连接;3. 服务端主动关闭
TIME_WAIT 导致端口耗尽 并发短连接太多 增大临时端口范围;采用连接池或 HTTP keep-alive
服务端出现大量 CLOSE_WAIT 服务端未调用 close()正常关闭 检查应用层逻辑,确保及时释放

5. TIME_WAITCLOSE_WAIT 区别

状态 所在端 触发条件 表示意义
TIME_WAIT 主动关闭方 发完最后 ACK 等待 2MSL 等待旧包消失,保证连接彻底关闭
CLOSE_WAIT 被动关闭方 收到对方 FIN,尚未发送自己的 FIN 应用层未 close(),导致资源占用

6. RST 什么时候出现?

  • 已关闭的连接 发送数据;
  • 未建立的连接 发送请求;
  • 半连接超时被清理;
  • 异常关闭;

3.1.3 TCP vs UDP

一句口诀:

  • TCP 是一种面向连接、可靠的、一对一的、面向字节流的、首部 20-60 字节的、传输层通信协议。
  • UDP 是一种无连接的、不可靠的、任意连接个数的、面向报文的、首部 8 字节的、传输层通信协议。
维度 TCP UDP
连接性 面向连接(三次握手) 无连接的(不需要事先建立连接)
可靠性 可靠(数据包校验、包重排、丢弃重复数据包、ACK 机制、超时重传机制、拥塞控制、流量控制) 不可靠(可能丢包、乱序)
通信模式 一对一 一对任意数量(单播、多播、广播)
数据传输单元 面向字节流(无边界,可能粘包/拆包) 面向报文(有边界,保留报文长度)
首部开销 较大,20~60 字节 很小,固定 8 字节
传输效率 相对较低(需建立连接、确认、控制机制) 极高(无控制开销,延迟低)
适用场景 要求 数据准确完整 的场景,如 FTP、HTTP、SMTP、SSH 要求 实时性高、速度快 的场景,如音视频、通话、直播、DNS 查询、游戏等
  • TCP 特性详解
    1. 核心特性:

      • 面向连接: 三握四挥
      • 可靠传输: 一系列复杂机制确保数据 准确、有序、不重复 地送达
      • 全双工通信: 连接建立后,双方可同时进行数据收发。
    2. 可靠性保障机制: 0. 三次握手、四次挥手

      1. 序列号与确认应答(ACK): 每个字节都有唯一序列号,接收方通过返回 ACK 确认收到。
      2. 超时重传(RTO): 发送数据后启动计时器,若在 RTO(超时重传时间)内未收到 ACK,则重发数据。RTO 动态计算,通常略大于 RTT(往返时延)。
      3. 数据包排序: 利用序列号对数据包排序。
      4. 丢弃数据包: 根据序列号识别并丢弃重复包。
      5. 校验和: 校验数据在传输中是否出错,出错则丢弃等待重传。
      6. 流量控制: 通过接收方通告的窗口大小,动态调整发送速率,防止接收方缓冲区溢出。
      7. 拥塞控制: 通过拥塞窗口和四种算法(慢开始、拥塞避免、快重传、快恢复)感知并应对网络拥塞,降低整体丢包率。
        • RTT 往返时延
          • 由链路的传播时间、末端系统出的处理时间、路由缓存中的排队和处理时间组成。
          • 最后一个因素会 随着网络拥塞程度而变化,所以 RTT 一定程度上也反映网络拥塞程度
    3. 面向字节流与粘包问题

      • 字节流: TCP 将数据视为无结构的字节流,不保留消息边界。发送方多次写入数据可能被合并(粘包)或拆分(拆包)发送。
      • 粘包解决方案:
        • 先传包大小,再传包内容;
        • 固定包长度;
        • 设置结束标志;
    4. 核心机制:

      • 滑动窗口:
        • 实现流量控制的核心数据结构,它标识了在无需等待确认的情况下,发送方 能连续发送的数据范围,极大 提高传输效率
      • 流量控制:
        • 目的是 保护接收方,防止接收方被淹没,控制对象为端到端(rwnd),接收方通过 ACK 包中的 “窗口大小” 字段,告知发送方自己 剩余的缓冲区容量,从而 控制发送方的发送速率
      • 拥塞控制:
        • 目的是 保护网络,防止网络过载,控制对象为全网(cwnd),通过感知网络拥塞程度(如丢包、RTT增长),动态调整 “拥塞窗口” 的大小,控制 向网络注入数据的全局速率
    5. TCP 缓冲区

      • 发送缓冲区: 存储 已发送未确认、以及待发送 的数据,每个字节都有序列号,在收到 ACK 确认的数据才会从缓冲区移除。
      • 接收缓冲区: 存储 已接收但未被应用层读取,以及乱序到达 的数据,其剩余空间大小通过窗口通告给发送方,确保接收缓冲区不溢出。

  • UDP 特性详解
    1. 核心特性

      • 无连接: 直接发送数据,无需建立连接,开销小。
      • 不可靠传输: 不提供 ACK、重传、排序 等保障机制,数据可能 丢失、重复、乱序
      • 面向报文: 对应用层交下来的报文,既不合并也不拆分,一次发送一个完整的报文,保留消息边界。
    2. 优缺点

      • 优点:
        • 速度快、延迟低: 无连接、无控制开销。
        • 头部开销小: 仅 8 字节。
        • 支持多播/广播: 可以高效向多个目标发送数据。
        • 无阻塞控制: 在网络拥塞时仍能保持发送速率,适合实时应用。
      • 缺点:
        • 不保证可靠性: 需要由 应用层 自行处理 丢包、重复、乱序 等问题。
        • 易导致网络拥塞: 缺乏拥塞控制,若大量发送可能加剧网络拥塞。
    3. UDP 报文结构

      • 头部(8 字节):源端口(2)、目的端口(2)、长度(2)、校验和(2)。
    4. 不可靠性的应用层解决方案

      1. 增加序列号
      2. 引入ACK与重传机制
      3. 实现流量控制
    5. UDP缓冲区

      • 发送缓冲区: 发送时,将数据放入缓冲区后立即发送,并从缓冲区清除不停留;
      • 接收缓冲区: 接收时,将数据放入缓冲区供应用读取。

单独讲一下 拥塞控制 与 流量控制
  • 拥塞控制:

    • 本质: 拥塞控制的本质是发送方通过一个 拥塞窗口 变量,来动态探测并适应网络传输能力,尽可能高效利用可用带宽。
      • 拥塞窗口(cwnd):
        • 发送方根据自己 对网络拥塞程度的评估 而维护的一个窗口值,它代表了 “在当前网络情况下,我能安全发送多少数据,而不造成拥塞”。
      • 发送窗口的最终大小:
        • 发送方在任一时刻,实际能发送的数据了 = min(cwnd,rwnd)
      • 慢启动门限(ssthresh):
        • 一个状态切换的阈值。当 cwnd < ssthresh,使用慢开始算法;当 cwnd >= ssthresh,使用拥塞避免算法。
    • 影响: 网络拥塞时 TCP 继续发包可能会导致数据包丢失或时延,这时 TCP 就会重传,导致网络更加拥塞
    • 拥塞前兆:
      • 数据包延迟显著增加;
      • 丢包率上升;
      • 网络吞吐量下降;
        1. 超时重传(RTO 超时): 严重拥塞信号。
          • 网络非常拥堵,必须 “急刹车”,然后从最低速重新开始指数增长。
        2. 收到 3 个重复的 ACK: 轻度拥塞信号。
          • 网络只是轻度丢包,数据还在流动,可以适度减速。
    • 拥塞控制:
    初始状态
        ↓
    慢启动(指数增长)
        ↓ (cwnd >= ssthresh)
    拥塞避免(线性增长)
        ↓
    [网络事件发生]
        ├─ 超时重传 ──→ 慢启动重启(cwnd=1)
        └─ 3个重复ACK ──→ 快重传 + 快恢复 ──→ 拥塞避免
    

  • 流量控制:

    • 本质: 本质是控制供需平衡,解决收发双方的速率匹配问题,防止发送方发送速率超过接收方的处理能力,导致接收方缓冲区溢出和数据丢失
    • 核心机制:滑动窗口
    发送缓冲区(发送方维护)
    ┌───────────────────────────────────────────────────┐
    │ 已发送且已确认 │  已发送未确认 │  可发送未发送 │ 不可发送 │
    │  (可清除)     │  (等待ACK)   │  (在窗口内)  │(超出窗口)│
    ├───────────────────────────────────────────────────┤
    ◄── 已确认部分 ──►◄─────────── 发送窗口 ────────────►
    
    接收缓冲区(接收方维护)
    ┌──────────────────────────────────────┐
    │ 已接收已确认  │ 可接收未接收   │ 不可接收 │
    │ (待应用读取)  │  (在窗口内)   │(超出窗口)│
    ├──────────────────────────────────────┤
    ◄── 已使用部分 ─►◄─────── 接收窗口 ───────►
    

四、网络层:IP

4.1 作用

逻辑寻址 + 路由转发

  • 核心协议:IP、ICMP、ARP、NAT、路由协议(RIP/OSPF/BGP)

4.2 IP 基础

版本 地址长度 示例
IPv4 32 位 192.168.1.1
IPv6 128 位 2001:db8::1
  • IP 报文由 【头部 + 数据】 组成,头部含源 IP、目标 IP、TTL、协议号。
  • 若包太大,网络层负责分片与重组。
    • 负责寻址与分片;
    • TTL 防死循环;
    • 协议号区分上层(6=TCP,17=UDP)。

4.3 ICMP

  • Internet Control Message Protocol,用于诊断和错误报告。
  • 常见命令:ping(测试连通性)、traceroute(路由追踪)。

4.4 路由机制

  • 静态路由:手动配置;
  • 动态路由:路由器间自动交换信息(RIP、OSPF、BGP)。
  • 默认网关:未知目标的转发出口。

五、网络接口层(链路层、物理层):WiFi、蜂窝

5.1 职责

  • 封装帧、寻址、差错检测(FCS)、介质访问控制。
  • 负责同一局域网内的 节点到节点通信

5.2 MAC 地址与 ARP

  • 每个网卡唯一的 48 位地址(00-1C-42-7A-xx-xx)。
  • ARP(地址解析协议):根据 IP 查 MAC 地址。
    • 逆向为 RARP。

5.3 常见协议

类型 协议 作用
有线 Ethernet 主流局域网协议
无线 Wi-Fi(IEEE 802.11) 无线局域网标准
点对点 PPP 拨号链路协议

六、三方库:AFNetworking

6.1 AFNetworking

AFNetworking 本质上就是对苹果 NSURLSession 的面向对象封装,把 delegate 回调模式变成更易用的 Block 模式,同时加了一套序列化体系。

整体结构:

AFNetworking 4.0
│
├── 核心层
│   ├── AFURLSessionManager        会话管理器(核心中的核心)
│   └── AFHTTPSessionManager       HTTP 便捷管理器
│
├── 序列化层
│   ├── AFHTTPRequestSerializer    请求序列化(拼参数、设 Header)
│   │   ├── AFJSONRequestSerializer
│   │   └── AFPropertyListRequestSerializer
│   ├── AFHTTPResponseSerializer   响应序列化(解析数据)
│   │   ├── AFJSONResponseSerializer
│   │   ├── AFXMLParserResponseSerializer
│   │   └── AFImageResponseSerializer
│   └── AFSecurityPolicy           SSL 证书校验策略
│
├── 辅助层
│   └── AFNetworkReachabilityManager  网络状态检测
│
└── UIKit 扩展 (可选)
    ├── UIImageView+AFNetworking
    ├── UIButton+AFNetworking
    └── AFNetworkActivityIndicatorManager
  • AFURLSessionManager(一切的基础)
    • 持有:
      • NSURLSession *session ← 苹果原生会话
      • NSOperationQueue *operationQue
      • AFSecurityPolicy *securityPolicy
      • AFNetworkReachabilityManager *reachabilityManager
      • NSMutableDictionary *mutableTaskDelegatesKeyedByTaskIdentifier (每个 Task 对应一个 delegate, 管理回调)
    • 做了什么?
      • 实现 NSURLSessionDelegate 全家桶
      • 把 delegate 回调 →转化成→ Block 回调
      • 给每个 NSURLSessionTask 配一个 AFURLSessionManagerTaskDelegate,这个内部 delegate 负责收集数据、计算进度、最终回调。
      • 管理 Task 的生命周期 (创建/取消/暂停/恢复)
      • SSL 证书校验 (通过 AFSecurityPolicy)
    • 关键方法:
      • 数据任务(用于普通 HTTP 请求,请求与响应体都在内存中): dataTaskWithRequest:completionHandler:
      • 上传任务(用于 “上传本地文件” 到服务器,底层基于 http(s)): uploadTaskWithRequest:fromFile:progress:completionHandler:
      • 下载任务(用于 “下载文件” 到本地磁盘): downloadTaskWithRequest:destination:progress:completionHandler:
  • AFHTTPSessionManger(继承自 AFURLSessionManager, HTTP的便捷入口)
    • 新增:
    • 提供 RESTful 风格方法:
    • 内部流程:

序列化体系

  • 这是 AFNetworking 最核心的设计模式 —— 请求/响应序列化器可替换
请求序列化器 (怎么把参数变成 HTTP 请求)
────────────────────────────────────────

AFHTTPRequestSerializer (默认)
  · Content-Type: application/x-www-form-urlencoded
  · 参数编码: key1=value1&key2=value2
  · 设置通用 Header: User-Agent / Accept-Language

AFJSONRequestSerializer
  · Content-Type: application/json
  · 参数编码: JSON 格式 {"key1":"value1"}
  
  响应序列化器 (怎么把响应数据变成对象)
────────────────────────────────────────
AFHTTPResponseSerializer (默认)
  · 不做任何解析, 直接返回 NSData

AFJSONResponseSerializer
  · 验证 Content-Type 是否为 JSON
  · NSJSONSerialization 解析 → NSDictionary / NSArray

AFImageResponseSerializer
  · 验证 Content-Type 是否为图片
  · 解析 → UIImage

6.1.1 一个请求在 AF 内部的完整流程

manager.POST(url, parameters, headers, success, failure):   业务调用
    
    
 AFHTTPRequestSerializer.request:          请求序列化
    
    
    返回 NSMutableURLRequest
    
    
 AFURLSessionManager.dataTask:           创建 Task,绑定 delegate,注册回调。
    
    
 task.resume()   请求发出去了
    
    
 NSURLSession delegate 回调 (AF 接管):        接收数据,回调 AFURLSessionManagerTaskDelegate
    
    
 AFURLSessionManagerTaskDelegate:        汇总数据
    
    
 AFJSONResponseSerializer.response:     响应序列化
    
    
    返回 NSDictionary
    
    
 dispatch_group:           回到主线程

6.1.2 AFNetworking 使用优化

  • AFHTTPSessionManager: 可以注入 Cronet 以支持 QUIC / HTTP2‘
  • AFHTTPRequestSerializer: 可以增加 AES128 加密等;
  • AFJSONResponseSerializer 可以增加对应的 AES128 加密、加 GZip/Brotli 解压逻辑等。

6.1.3 AFNetworking vs 直接使用 NSURLSession

NSURLSession AF 封装后
要自己实现 4 个 delegate 协议 Block 回调,几行代码搞定
要自己拼 URL、编码参数 manager.POST(url, params) 一行搞定
要自己管理 Task 生命周期 AF 自动管理
要自己解析 JSON responseSerializer 自动解析
要自己做 SSL 校验 AFSecurityPolicy 配置即可
要自己检测网络状态 AFNetworkReachabilityManager 现成的

6.1.4 AFNetworking vs Protobuf

  • AF 序列化: 把参数 → 变成 HTTP 协议能理解的格式
  • Protobuf: 把数据 → 变成一种紧凑的二进制编码格式

AF 序列化把字典变成 HTTP 能传的文本(HTTP 协议规范),Protobuf 把对象变成 Socket 能传的二进制。 前者为了兼容 HTTP 标准,后者为了追求极致的小和快。

AF 序列化 Protobuf
格式 文本 (JSON / 表单) 二进制
可读性 人能直接看 看不懂,需要 .proto 文件才能解
体积 小(1/3~1/5)
解析速度 快(10~100x)
Schema 无,运行时动态解析 有,编译时生成代码(GPBMessage 子类)
用在哪 HTTP 请求/响应 TCP 长连接的数据包
为什么选它 HTTP 标准就是 JSON/表单 长连接要求低延迟、省流量

七、总结

落实到 App 开发中,常见链路可能是这样的:

整体链路关系:

┌─────────────────────── 应用层 ───────────────────────────┐
│                                                         │
│  业务协议:      HTTP API / Protobuf / RTC                │
│  安全:          AES 加密 / HMAC-SHA1 签名   / 防重放       │
│  域名管理:            业务统一管理                         │
│  域名解析:           HTTPDNS / 系统 DNS                   │
│                                                         │
├─────────────────────── 传输层 ───────────────────────────┤
│  TCP (GCDAsyncSocket)                                   │
│  UDP + KCP (GCDAsyncUdpSocket + KCP)                    │
│  QUIC (Cronet)                                          │
│                                                         │
├─────────────────────── 网络层 ───────────────────────────┤
│  IP (v4/v6)                                             │
│                                                         │
├─────────────────────── 接口层 ───────────────────────────┤
│  WiFi / 蜂窝 / 断网检测                                   │
└─────────────────────────────────────────────────────────┘

HTTP 链路:

┌─────────────────────── 应用层 ───────────────────────────┐
                                                         
   业务入口                                              
     ObjC:  xxxxHTTP.facebookLogin(...)                  
     Swift: xxxxAPI(params).observable.subscribe(...)    
                                                        
                                                        
   调度中心                                              
     ObjC:  业务统一错误码处理等                             
     Swift: MoyaProvider  (插件链处理)                     
                                                         
                                                         
   请求模型 (两侧做同样的事, 语言不同)                       
       · 填充公共参数: uid, sid, device_id, version...     
       · 生成防重放:   nonce (UUID) + millisecond (时间戳)  
       · 计算签名:     token (HMAC-SHA1)                   
                      sign  (HMAC-SHA1)                   
       · 合并业务参数                                       
                                                         
                                                         
   序列化 & 加密                                          
     ObjC:  HTTPAESRequestSerialization                   
     Swift: MoyaAESHandler.prepare                        
       · 按路径判断是否需要 AES128 加密请求体                  
       · 设置请求头 X-ENCRYPTED-VERSION                     
                                                         
                                                         
   HTTP                                                
     ObjC:  AFNetworking    AFHTTPSessionManager         
     Swift: Alamofire       Session                      
                                                         
                                                         
   NSURLSession (两侧共用)                                
     · SessionConfiguration 注入 Cronet                    
     · Cronet 尝试 QUIC  降级 HTTP/2  降级 HTTP/1.1       
                                                          
├─────────────────────── 传输层 ───────────────────────────┤
                                                         
          QUIC (Cronet, UDP)  TCP (系统)                
                                                        
                                                        
                       TLS 握手                           
                                                         
├─────────────────────── 网络层 ───────────────────────────┤
                                                          
  IP 路由                                                  
                                                          
├─────────────────────── 接口层 ───────────────────────────┤
                                                          
  WiFi / 蜂窝                                             
                                                          
└──────────────────────────────────────────────────────────┘

TCP 链路:

┌─────────────────────── 应用层 ───────────────────────────┐
│                                                         │
│  ① 建连 (App启动 / 登录成功触发)                           │
│                          │                               │
│                          ▼                               │
│  ② 认证 (连接建立后)                                      │
│       · 构建请求: sid + deviceId + version                │
│                          │                               │
│                          ▼                               │
│  ③ 封包:Protobuf 等                                      │
│                          │                               │
│                          ▼                               │
│  ④ Socket 发送                                            │
│                          │                               │
│                          ▼                               │
├─────────────────────── 传输层 ────────────────────────────┤
│                                                          │
│  TCP (GCDAsyncSocket)  /  KCP + UDP (GCDAsyncUdpSocket)  │
│                                                          │
├─────────────────────── 网络层 ────────────────────────────┤
│                                                          │
│  IP 路由                                                  │
│                                                          │
├─────────────────────── 接口层 ───────────────────────────┤
│                                                          │
│  WiFi / 蜂窝                                             │
│                                                          │
└──────────────────────────────────────────────────────────┘

保护机制:

┌─────────────────────── 应用层 ───────────────────────────┐
│                                                         │
│  心跳保活                                                │
│     →  NSTimer 定时发送心跳包                             │
│                                                         │
│  守护进程 (ConnectorDaemon)                             │
│    ├── TCP 守护: 连续 n 次失败 → 切备用域名                │
│    └── KCP 守护: n 秒内 n 次超时 → 切回 TCP               │
│                                                        │
│  重连策略                                                │
│    TCP 断开      → 0.5s 自动重连                          │
│    bind 失败     → 1s 重试 bind                          │
│    域名故障      → 切备用域名                              │
│    KCP 故障      → 切回 TCP 连同一域名                     │
│    前后台切换    → 发探测包验证, 失败则重连                   │
│                                                          │
└──────────────────────────────────────────────────────────┘

百款出海社交 App 一夜下架!2026,匿名社交的生死劫怎么破?

作者 iOS研究院
2026年2月25日 20:15

2026年2月24日,出海社交领域迎来标志性的“黑色星期二”,百余款社交类App在无任何预警、无邮件通知、无申诉通道的情况下,被App Store集体下架。即便部分应用近期刚完成版本更新、运营状态平稳,也未能幸免。此次事件引发行业震动,苹果的清理行动究竟是偶然误伤还是定向整治?下架风暴的背后暗藏哪些监管逻辑?出海社交开发者如何突破困境、实现可持续发展?本文将深入拆解事件本质,梳理监管趋势,提供合规生存路径。

ScreenShot_2026-02-25_194516_417.png

ScreenShot_2026-02-25_194451_015.png

定向整治而非偶然误伤,四大市场同步发力

此次App Store下架行动并非随机操作,而是覆盖美国、澳大利亚、巴西、新加坡四大核心市场的定向清理,各市场虽审查重点略有差异,但整治核心高度统一,均聚焦于高风险社交场景。

美国作为全球最核心的应用市场,下架应用表面涵盖AI音乐、职场社交、旅游、育儿等多个品类,但核心筛选标准清晰——凡是包含“Live Chat”“Video Chat”“Meet New Friends”等关键词、以陌生人实时互动为核心功能的社交应用,均成为清理重点。

新加坡与澳大利亚的清理逻辑高度一致,对匿名社交类应用实施“零容忍”政策,大量主打“匿名聊天”“视频聊天”的产品被集中移除,其中不乏Aloha Live - Anonymous Chat、Xonder: Anonymous Chat & Vent等直接以“匿名”为核心卖点的应用,凸显两地对不可追溯社交模式的严格监管态度。

巴西市场的清理范围进一步扩大,除纯社交应用外,春辉乐玩、玩伴Vibe等具备旅游属性的轻度社交产品也被纳入下架名单。这一举措背后,是巴西市场将用户数据安全与未成年人保护纳入核心审查维度,审查标准提升至历史新高。

中国开发者高频踩雷:四类高危产品触发监管红线

梳理此次被下架的中国开发者相关产品,可发现其普遍存在明确的“高危特征”,均精准触碰了全球监管红线,具体可分为四大类:

1. 匿名树洞类产品

以默言、nimi-i人专属匿名聊天为代表,这类产品精准定位职场人、社恐群体的表达需求,主打“匿名对话”“无社交压力”等核心卖点,部分产品甚至取消点赞、推荐、动态广场等功能,极致强化匿名属性。但在监管层面,匿名意味着用户行为不可追溯,此类模式被明确界定为“高风险交互模式”,极易成为不良信息传播的载体,从而触发监管处罚。

2. 速配交友类产品

连连婚恋、LivMe-Meet new friend等产品均以“陌生人速配”为核心模式,前者面向职场人群提供免费婚恋交友服务,后者主打全球范围内的随机匹配聊天。此类产品的核心痛点的在于,多数中小开发团队难以承担7×24小时实时内容审核的成本,缺乏完善的审核机制,导致诈骗、色情等违法违规信息极易滋生,成为监管重点整治对象。

3. AI情感伴侣类产品

Joiy、ItsMee等产品将AI技术与情感社交深度结合,推出AI聊天、情绪匹配、专属AI聊天机器人等功能,看似是产品创新,实则触碰监管敏感点。AI技术本身并非违规核心,但当AI被用于模拟人类进行情感交流,且存在触达未成年人的可能时,监管容忍度降至零。此次下架也明确释放信号:情感类AI社交已成为全球监管的下一重点领域。

4. 马甲工具/社区类产品

部分产品以工具、垂直社区为外壳,暗藏社交属性,例如摄影社区CNU-顶尖视觉精选,虽以摄影内容分享为核心,但包含UGC内容发布、用户私信互动等社交功能,最终也被纳入清理范围。这一现象表明,只要涉及用户互动与内容传播,无论产品外在形态如何,均需遵守社交应用监管规范,不存在“法外之地”。

双重监管合围:苹果新规与全球法律形成监管合力

此次下架风暴的爆发,并非苹果单独行动,而是苹果平台规则升级与全球各国监管政策收紧形成的合力,推动出海社交行业正式进入“强合规时代”。

苹果平台规则升级:匿名社交被明确禁止

2026年2月6日,苹果悄然更新《App Store审核指南》,在1.2章节“用户生成内容”中,明确将“随机或匿名聊天”与色情内容、人身威胁、欺凌等列为App Store禁入类型,并保留“未经通知即可移除应用”的权利。

此前广泛应用于陌生人社交的Chatroulette式随机匹配模式,曾是行业核心创新点,如今已被定义为高风险功能。苹果的监管逻辑清晰:匿名+随机社交模式需要极致的内容审核能力,而多数中小开发团队难以承担相应成本,为规避平台风险,采取“一刀切”的清理策略。

全球各国监管收紧:未成年人保护成核心红线

如果说苹果新规是“平台层面的管控”,全球各国的法律政策则是“市场层面的约束”,且均以未成年人保护为核心,进一步压缩不合规产品的生存空间:

——巴西、澳大利亚、新加坡:自2月24日起,下载18+应用需通过苹果年龄验证;巴西额外规定,包含“开箱抽奖”等类赌博机制的应用,直接评级为18+,直接切断此类社交+游戏类产品的未成年人用户市场。

——美国:犹他州《应用商店责任法案》已于2025年5月生效,要求应用商店强制验证用户年龄,未成年人账号需关联家长账号,开发者违规将面临家长最高1000美元/次的索赔,苹果为规避“连坐”风险,进一步提高应用审核标准。

——欧洲:欧盟近期认定TikTok的“成瘾性设计”(如无限滚动、自动播放)违反《数字服务法案》,拟处以全球年收入6%的罚款;西班牙更推进“禁止16岁以下未成年人使用社交媒体”的政策,进一步强化对未成年人的保护。

综上,此次下架风暴是全球监管层对社交产品的一次“全面清算”,过去“先野蛮生长、后合规整改”的出海模式已彻底失效。

2026年出海社交合规生存指南:三大路径实现突围

面对全球监管收紧的大环境,出海社交开发者若想实现可持续发展,核心在于放弃侥幸心理、坚守合规底线,以下三条路径可作为破局关键:

路径一:放弃匿名模式,搭建实名/强认证体系

若产品商业模式依赖“用户匿名、无需对言行负责”的核心逻辑,需尽快完成转型。未来社交产品的核心底线是“可追溯”,即便采用昵称体系,也需搭建完善的持久账户体系,通过手机号验证、身份信息核验等强认证方式,确保用户行为可追溯、可管控,从源头降低不良信息传播风险。

路径二:将合规融入产品功能,适配全球监管要求

苹果推出的“申报年龄范围API”不应被视为运营负担,而应作为核心功能进行适配。开发者可针对不同年龄段用户设计差异化内容与功能:对未成年人开启严格的内容过滤、使用时间管理机制;对成年人提供合规范围内的社交服务。这种“分龄管理”模式,不仅能满足全球监管要求,更能提升产品公信力,成为打入欧美主流市场的核心优势。

路径三:严控AI功能风险,建立完善的内容过滤机制

随着AI技术在社交领域的广泛应用,AI陪聊、AI生成头像、AI匹配等功能成为产品创新方向,但需严格把控风险。开发者在引入AI功能前,需明确三大核心问题:AI训练数据是否合法合规?是否存在生成涉黄、涉政等敏感内容的可能?是否会诱导未成年人做出危险行为?无论采用何种大模型,均需建立严格的输出过滤机制,即便牺牲部分产品趣味性,也要确保内容绝对安全——海外市场中,单一违规内容(如AI生成的疑似儿童违规图片),即可导致应用永久下架,开发者甚至需承担刑事责任。

结语:合规是出海社交的唯一生路

2026年2月24日的下架风暴,只是全球社交领域监管收紧的一个开端。随着全球数字治理体系的不断完善,过去依赖技术红利、模式创新就能快速出海的时代已一去不复返,合规能力将成为出海社交开发者的核心竞争力。

对于在此次风暴中下架的产品,行业深感遗憾;而对于仍在坚守的开发者,需重新审视产品逻辑,主动拥抱监管、搭建完善的合规体系。唯有坚守合规底线,才能在全球出海赛道中长久立足——2026年,合规才是出海社交的唯一生存通行证。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

WKWebView 中 iframe 无法监听原生 JSBridge 回调的完整分析

作者 Neon1204
2026年2月25日 15:09

WKWebView 中 iframe 无法监听原生 JSBridge 回调的完整分析

一、问题标题

WKWebView 场景下,Web 项目通过 iframe 加载三方页面,为什么无法在纯 Web 层监听到 recharge / newTppClose 等原生 JSBridge 回调?


二、问题描述

在 iOS App 中,使用 WKWebView 加载前端 Web 项目。Web 项目内部通过 iframe 嵌入三方页面(跨域)。

三方页面在某些业务节点(如充值、关闭页面)会调用以下接口:

window.JSBridgeService.recharge(arg)
window.JSBridgeService.newTppClose(arg)

原有方案 中:

  • iOS 原生通过 WKUserScript 向 WebView 注入 JSBridgeService
  • 三方页面调用后,iOS 原生可以正常收到回调
  • 原生再通过注入 JS 或其他桥接方式通知 Web 项目

现在的目标是:

去掉 iOS 原生中转,直接让 Web 项目与三方 iframe 通信,在 Web 层监听到 recharge / newTppClose 消息。

但实际情况是:

  • Web 项目中无论通过 addEventListenerpostMessage、函数重写等方式
  • 都无法监听到这两个回调

三、定位问题:为什么 Web 一定监听不到?

3.1 原生注入的 JSBridge 本质是什么?

iOS 原生注入的代码如下(简化):

window.JSBridgeService = {
  recharge: (arg) => {
    recharge.postMessage(JSON.stringify(arg || {}))
  },
  newTppClose: (arg) => {
    newTppClose.postMessage(JSON.stringify(arg || {}))
  }
}

这里有一个非常关键的认知点

recharge / newTppClose 并不是 JavaScript 世界里的函数或事件,而是 WKWebView 提供的 Native Message Handler 代理对象


3.2 JSBridge 调用链路分析

实际调用链路如下:

┌─────────────┐
│ 三方 iframe │
└──────┬──────┘
       │ 调用
       ▼
window.JSBridgeService.recharge()
       │
       ▼
┌──────────────────────────┐
│ WKWebView messageHandler │  ← JS Runtime 到此为止
└──────────┬───────────────┘
           │
           ▼
      iOS 原生代码

重点:

  • 这个调用 不会进入 DOM Event Loop
  • 不会触发任何 JS Event
  • 不支持冒泡、捕获、监听

因此,在 Web 层以下方式全部无效:

window.addEventListener('recharge', ...)
window.onrecharge = ...
Object.defineProperty(...)
Proxy(...)

3.3 为什么 window.postMessage 方案行不通?

很多人会下意识把这个问题类比为 postMessage,但两者完全不是一个层级的东西

对比项 window.postMessage WKWebView messageHandlers
标准 Web 标准 iOS 私有实现
是否可监听
是否可冒泡
JS 可代理
跨 iframe

结论:

WKWebView 的 messageHandler 是一个「JS → Native 的单向黑洞通道」,JS 只能调用,不能监听。


四、解决方案分析

4.1 为什么原来的「原生中转方案」一定可行?

原有架构实际上是:

iframe
  ↓
JSBridgeService.recharge()
  ↓
WKWebView
  ↓
iOS 原生(协议翻译)
  ↓
注入 JS / dispatchEvent
  ↓
Web 项目监听

iOS 原生在其中承担了一个关键角色

协议翻译器(Native → Web Event)

示例:

window.dispatchEvent(
  new CustomEvent('tpp:recharge', { detail: payload })
)

4.2 纯 Web 场景下有哪些可行方案?

✅ 方案一(推荐 & 唯一标准):三方 iframe 支持 postMessage

三方页面:

window.parent.postMessage({
  type: 'recharge',
  payload: {}
}, '*')

主页面:

window.addEventListener('message', (e) => {
  if (e.data?.type === 'recharge') {
    // 业务处理
  }
})

这是唯一的纯 Web 正解。


❌ 方案二:跨域 iframe 注入 / Hook(不可行)
  • iframe 跨域
  • 浏览器同源策略限制
  • CSP 限制

👉 无法实现


⚠️ 方案三:三方通过 URL / hash / storage 通知

例如:

  • 修改 location.hash
  • 写入 localStorage

Web 监听:

window.addEventListener('hashchange', ...)

该方案依赖三方实现,稳定性与可维护性较差。


五、最终结论(工程视角)

  • recharge / newTppClose 不是 JS 事件
  • 它们是 WKWebView Native Message Handler
  • JS 世界 无法监听、劫持或转发
  • 不经过原生或三方改造,纯 Web 无解

如果三方页面只支持 JSBridge 调用: 👉 必须保留一个桥接层(原生或 SDK)


六、相关知识点总结

  • WKWebView messageHandlers 工作机制
  • JS Runtime 与 Native Runtime 的边界
  • iframe 跨域通信模型
  • window.postMessage 原理
  • Hybrid 架构中「协议翻译层」的重要性

这类问题本质不是技术实现问题,而是平台能力边界问题。理解边界,比写代码更重要。

iOS Swift:蓝牙 BLE 连接外设CoreBluetooth

作者 tangbin583085
2026年2月24日 17:59

在 iOS 与智能硬件(手环、传感器、控制模块等)交互中,BLE(Bluetooth Low Energy)是最常用的通信方式。本文将基于 CoreBluetooth + Swift,给出一套工程可用的连接外设代码,并总结开发中最常遇到的注意事项。

适用场景:BLE 设备连接、读写特征、订阅通知、接收回包、断线重连。

一、准备工作

1)Info.plist 权限配置(必须)

iOS 13+ 起必须给出蓝牙使用说明,否则扫描/连接会失败或系统拒绝。

<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接外设进行数据通讯</string>

如果你还需要后台持续蓝牙通信:需要 capability + plist 配置(后文注意事项会讲)。

2)导入框架

import CoreBluetooth

二、BLE 连接的标准流程

  1. 初始化 CBCentralManager
  2. 蓝牙开启后开始扫描
  3. 找到目标外设并连接
  4. 发现 Service
  5. 发现 Characteristic
  6. 找到写特征(Write)与通知特征(Notify)
  7. 开启通知并开始收发数据

三、代码:BluetoothManager(可直接用)

下面给出一个轻量但工程化的 BLE 管理器,支持:

  • 按名称/服务 UUID 过滤
  • 扫描超时
  • 连接成功后自动发现服务/特征
  • 自动开启 Notify
  • 写入数据(支持写入响应/无响应)
  • 断开回调(可扩展重连)
  • 简单的写入节流(避免写太快导致丢包) ``

你只需要把 UUID 替换成你设备的即可。

import Foundation
import CoreBluetooth

final class BluetoothManager: NSObject {
    
    static let shared = BluetoothManager()
    
    // MARK: - Public callbacks (按需扩展)
    var onStateChanged: ((CBManagerState) -> Void)?
    var onDiscovered: ((CBPeripheral, NSNumber) -> Void)?
    var onConnected: ((CBPeripheral) -> Void)?
    var onDisconnected: ((CBPeripheral, Error?) -> Void)?
    var onReceiveData: ((Data, CBCharacteristic) -> Void)?
    
    // MARK: - CoreBluetooth
    private var central: CBCentralManager!
    private(set) var peripheral: CBPeripheral?
    
    private var writeChar: CBCharacteristic?
    private var notifyChar: CBCharacteristic?
    
    // MARK: - Config (替换为你的设备 UUID)
    /// 推荐:用 Service UUID 过滤扫描,效率更高、结果更准
    private let targetServiceUUID = CBUUID(string: "FFF0")
    private let writeCharUUID      = CBUUID(string: "FFF1")
    private let notifyCharUUID     = CBUUID(string: "FFF2")
    
    /// 可选:按名称过滤(如果设备名称稳定)
    private let targetNamePrefix = "MyBLE"
    
    // MARK: - Scan control
    private var scanTimer: Timer?
    private let scanTimeout: TimeInterval = 10
    
    // MARK: - Write throttle
    private var writeQueue: [Data] = []
    private var isWriting = false
    
    private override init() {
        super.init()
        // queue 建议用串行队列,避免回调并发导致状态错乱
        let queue = DispatchQueue(label: "com.tangbin.ble.queue")
        central = CBCentralManager(delegate: self, queue: queue)
    }
    
    // MARK: - Public APIs
    
    /// 开始扫描
    func startScan() {
        guard central.state == .poweredOn else { return }
        stopScan()
        
        // 只扫目标 Service:更省电更精准(强烈推荐)
        central.scanForPeripherals(withServices: [targetServiceUUID], options: [
            CBCentralManagerScanOptionAllowDuplicatesKey: false
        ])
        
        startScanTimeoutTimer()
    }
    
    /// 停止扫描
    func stopScan() {
        if central.isScanning {
            central.stopScan()
        }
        scanTimer?.invalidate()
        scanTimer = nil
    }
    
    /// 连接外设
    func connect(_ p: CBPeripheral) {
        stopScan()
        peripheral = p
        peripheral?.delegate = self
        
        central.connect(p, options: [
            CBConnectPeripheralOptionNotifyOnDisconnectionKey: true
        ])
    }
    
    /// 主动断开
    func disconnect() {
        guard let p = peripheral else { return }
        central.cancelPeripheralConnection(p)
    }
    
    /// 发送数据(写入队列节流)
    func send(_ data: Data, withResponse: Bool = false) {
        guard let p = peripheral, let w = writeChar else { return }
        let type: CBCharacteristicWriteType = withResponse ? .withResponse : .withoutResponse
        
        // 如果写入无响应,也建议做节流,避免外设来不及处理
        writeQueue.append(data)
        pumpWriteQueue(peripheral: p, characteristic: w, type: type)
    }
    
    // MARK: - Private
    
    private func startScanTimeoutTimer() {
        scanTimer?.invalidate()
        scanTimer = Timer.scheduledTimer(withTimeInterval: scanTimeout, repeats: false) { [weak self] _ in
            self?.stopScan()
        }
        RunLoop.main.add(scanTimer!, forMode: .common)
    }
    
    private func pumpWriteQueue(peripheral p: CBPeripheral,
                                characteristic c: CBCharacteristic,
                                type: CBCharacteristicWriteType) {
        guard !isWriting else { return }
        guard !writeQueue.isEmpty else { return }
        
        isWriting = true
        let packet = writeQueue.removeFirst()
        
        // 注意:此处写入发生在 central 的队列上
        p.writeValue(packet, for: c, type: type)
        
        // withoutResponse 的情况下不会走 didWriteValueFor,所以用延迟释放
        if type == .withoutResponse {
            DispatchQueue.global().asyncAfter(deadline: .now() + 0.02) { [weak self] in
                self?.isWriting = false
                self?.pumpWriteQueue(peripheral: p, characteristic: c, type: type)
            }
        }
    }
}

// MARK: - CBCentralManagerDelegate
extension BluetoothManager: CBCentralManagerDelegate {
    
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        onStateChanged?(central.state)
        
        if central.state == .poweredOn {
            // 可按需自动扫描
            // startScan()
        } else {
            // 蓝牙关闭/不可用时要清空状态
            stopScan()
            peripheral = nil
            writeChar = nil
            notifyChar = nil
        }
    }
    
    func centralManager(_ central: CBCentralManager,
                        didDiscover peripheral: CBPeripheral,
                        advertisementData: [String : Any],
                        rssi RSSI: NSNumber) {
        
        // 如果还想按名称做二次过滤
        if let name = peripheral.name, name.hasPrefix(targetNamePrefix) {
            onDiscovered?(peripheral, RSSI)
            connect(peripheral)
            return
        }
        
        // 不按名称过滤也行:只要服务 UUID 已经过滤,通常就很准
        onDiscovered?(peripheral, RSSI)
        connect(peripheral)
    }
    
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        onConnected?(peripheral)
        
        // 连接后发现服务
        peripheral.discoverServices([targetServiceUUID])
    }
    
    func centralManager(_ central: CBCentralManager,
                        didFailToConnect peripheral: CBPeripheral,
                        error: Error?) {
        onDisconnected?(peripheral, error)
    }
    
    func centralManager(_ central: CBCentralManager,
                        didDisconnectPeripheral peripheral: CBPeripheral,
                        error: Error?) {
        onDisconnected?(peripheral, error)
        
        // 可选:重连策略(示例:延迟重连)
        // DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        //     central.connect(peripheral, options: nil)
        // }
    }
}

// MARK: - CBPeripheralDelegate
extension BluetoothManager: CBPeripheralDelegate {
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard error == nil else { return }
        guard let services = peripheral.services else { return }
        
        for s in services {
            if s.uuid == targetServiceUUID {
                peripheral.discoverCharacteristics([writeCharUUID, notifyCharUUID], for: s)
            }
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral,
                    didDiscoverCharacteristicsFor service: CBService,
                    error: Error?) {
        guard error == nil else { return }
        guard let chars = service.characteristics else { return }
        
        for c in chars {
            if c.uuid == writeCharUUID { writeChar = c }
            if c.uuid == notifyCharUUID { notifyChar = c }
        }
        
        // 开启通知(Notify)
        if let n = notifyChar {
            peripheral.setNotifyValue(true, for: n)
        }
        
        // 可选:读取一次初始值
        // if let n = notifyChar { peripheral.readValue(for: n) }
    }
    
    func peripheral(_ peripheral: CBPeripheral,
                    didUpdateNotificationStateFor characteristic: CBCharacteristic,
                    error: Error?) {
        // 通知开关状态回调
    }
    
    func peripheral(_ peripheral: CBPeripheral,
                    didUpdateValueFor characteristic: CBCharacteristic,
                    error: Error?) {
        guard error == nil else { return }
        let data = characteristic.value ?? Data()
        onReceiveData?(data, characteristic)
    }
    
    func peripheral(_ peripheral: CBPeripheral,
                    didWriteValueFor characteristic: CBCharacteristic,
                    error: Error?) {
        // 只有 withResponse 才会进这个回调
        isWriting = false
        if let w = writeChar {
            pumpWriteQueue(peripheral: peripheral, characteristic: w, type: .withResponse)
        }
    }
}

四、调用示例(在 ViewController 里)

final class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let ble = BluetoothManager.shared
        
        ble.onStateChanged = { state in
            print("BLE state:", state.rawValue)
            if state == .poweredOn {
                ble.startScan()
            }
        }
        
        ble.onConnected = { p in
            print("已连接:", p.name ?? "unknown")
        }
        
        ble.onReceiveData = { data, ch in
            let hex = data.map { String(format: "%02x", $0) }.joined()
            print("收到数据((ch.uuid)):", hex)
        }
    }
    
    func sendCommand() {
        // 举例:发送一段 hex 指令
        let hex = "aabbccdd"
        let data = Data(hexString: hex) // 见下方扩展
        BluetoothManager.shared.send(data, withResponse: false)
    }
}

也可以复用我之前写的 ParseDataTool(Swift版)来做 Hex/Data 转换。


五、附:Data 十六进制扩展(可选)

extension Data {
    init(hexString: String) {
        let clean = hexString.replacingOccurrences(of: " ", with: "")
        var data = Data()
        var idx = clean.startIndex
        while idx < clean.endIndex {
            let next = clean.index(idx, offsetBy: 2)
            let byteStr = clean[idx..<next]
            if let b = UInt8(byteStr, radix: 16) {
                data.append(b)
            }
            idx = next
        }
        self = data
    }
}

六、开发注意事项(非常关键)

1)强烈建议:扫描时指定 Service UUID

更快、更省电、更准确
避免扫描全部 nil 会扫到大量无关设备,影响体验

central.scanForPeripherals(withServices: [targetServiceUUID], options: nil)

2)不要用 peripheral.name 当唯一标识

name 可能为空、可能变化。更靠谱的是:

  • 过滤 Service UUID
  • 使用 peripheral.identifier(UUID)做缓存识别(重连)

3)写数据别太快,否则丢包/外设卡死

BLE 写入速度过快常见问题:

  • 外设缓冲区溢出
  • 回包延迟或丢失
  • iOS 侧 write 被吞

建议做写入节流(本文示例已经做了队列 + 20ms 间隔)


4)区分 withResponse / withoutResponse

  • .withResponse:可靠,有回调 didWriteValueFor
  • .withoutResponse:速度快,但无写入确认,建议配合队列节流

实战建议:

  • 协议关键指令用 .withResponse
  • 大数据(如 OTA)用 .withoutResponse + 节流 + 外设 ACK

5)Notify 要记得开启(很多人漏掉)

外设回包多数走 Notify(通知),不打开你永远收不到数据:

peripheral.setNotifyValue(true, for: notifyChar)

6)断线是常态:要做重连策略

断线原因很多:

  • 超距
  • 外设省电休眠
  • 手机锁屏/系统资源回收

建议:

  • didDisconnectPeripheral 里做延迟重连
  • 或 UI 提示用户手动重连
  • 结合 peripheral.identifier 记住上次设备

7)后台蓝牙通信(可选)

如果你需要锁屏/后台持续通信:

  • Xcode → Signing & Capabilities → Background Modes → 勾选 Uses Bluetooth LE accessories
  • 并合理控制扫描/连接行为(后台会更耗电)

8)MTU 与分包问题

  • BLE 默认有效载荷常见为 20 字节(不同设备协商后可能变大)
  • 大数据(日志、图片、OTA)一定要做分包 + 协议确认

最后

本文给出了一套 Swift BLE 连接外设的我开发成熟项目过程中的代码,可直接运用在项目中,覆盖了:

  • 初始化、扫描、连接
  • 服务/特征发现
  • Notify 开启、收包回调
  • 写入(带队列节流)
  • 断开处理与重连扩展

如有写错的地方,敬请指正,相互学习进步,谢谢~

春节期间独立开发者从 0 到 1:呼吸训练 iOS App 的工程化落地

作者 SameX
2026年2月24日 16:33

项目:呼吸视界(iOS 已上架)
技术栈:SwiftUI + SwiftData + StoreKit 2 + WidgetKit + ActivityKit

各位新年快乐,春节期间体验到了人挤人,车挤车,闲来无事撸了个一直想做的APP,分享点技术心得大家共勉。

1. 架构目标:把“训练体验”和“增长闭环”同时做出来

这个项目不是只做一个呼吸动画,而是做一条完整链路:

  • 训练引擎:稳定跑节奏(吸气/停顿/呼气)
  • 多感官反馈:视觉 + 音频 + 触觉一致
  • 习惯闭环:课程进度、训练记录、分享卡片
  • 增长入口:提醒、Widget、Live Activity、深链
  • 商业化:订阅、恢复购买、权益门控

核心分层:

  • 状态中枢:breathing-iOS/breathing/Domain/AppStore.swift
  • 页面编排:breathing-iOS/breathing/UI/RootView.swift
  • 能力引擎:breathing-iOS/breathing/Engines/*
  • 数据模型:breathing-iOS/breathing/Data/*
  • 外部触达:breathing-iOS/breathingWidget/* + BreathingLiveActivityManager

2. 单一状态中枢:AppStore 统一收口

AppStore@MainActor + ObservableObject 统一管理业务状态,避免“每个页面自己存一份状态”。

@MainActor
final class AppStore: ObservableObject {
    @Published var activeMode: BreathingMode
    @Published var activeDuration: Int
    @Published var isPro: Bool
    @Published var settings: AppSettings
    @Published var soundEnabled: Bool
    @Published var soundscapeId: String

    let breathingEngine: BreathingEngine
    let hapticsEngine: HapticsEngine
    private let soundscapePlayer = SoundscapePlayer()
    private let liveActivityManager = BreathingLiveActivityManager()
}

同时把订阅商品 ID 固定在内部,避免散落字符串:

private enum ProProductID {
    static let monthly = "com.xun.breathing.pro.monthly"
    static let yearly = "com.xun.breathing.pro.yearly"
    static let all = [monthly, yearly]
}

收益:UI 层只绑定状态,不再承担复杂业务判断;后续加模式/加权益不会牵一发而动全身。


3. 训练引擎:状态机 + 双 Task 保证节奏稳定

BreathingEngine 的关键是“阶段推进”和“总时长倒计时”分离:

@MainActor
final class BreathingEngine: ObservableObject {
    @Published private(set) var phase: BreathPhase = .ready
    @Published private(set) var isPlaying: Bool = false
    @Published private(set) var timeRemaining: Int

    private var cycleTask: Task<Void, Never>?
    private var countdownTask: Task<Void, Never>?
    private var sessionId = UUID()
}

启动时并行两条异步任务:

func start() {
    guard !isPlaying else { return }
    isPlaying = true
    sessionId = UUID()
    timeRemaining = duration

    runCountdown(sessionId: sessionId)
    switch courseType {
    case .standard:
        runBreathingLoop(sessionId: sessionId)
    case .wimHof(let config):
        runWimHofSession(sessionId: sessionId, config: config)
    }
}

倒计时任务只做一件事:

private func runCountdown(sessionId: UUID) {
    countdownTask?.cancel()
    countdownTask = Task { [weak self] in
        guard let self else { return }
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: 1_000_000_000)
            guard sessionId == self.sessionId, self.isPlaying else { return }
            self.timeRemaining = max(0, self.timeRemaining - 1)
            if self.timeRemaining <= 0 {
                self.completeSession()
                return
            }
        }
    }
}

收益:暂停/恢复/切模式时行为稳定,不会出现“相位跳变”或“倒计时错乱”。


4. 音景引擎:缓存 + 淡入淡出,解决听感跳变

音频引擎里最关键是三点:

  • bufferCache:避免每次重新解码 mp3
  • fadeIn/fadeOut:切换音景不突兀
  • updatePlayback:统一播放入口(按 isPlaying/isEnabled 决策)
func updatePlayback(isPlaying: Bool, isEnabled: Bool, soundscapeId: String) {
    guard isEnabled, isPlaying else {
        stop()
        return
    }
    play(soundscapeId)
}

private func loadBuffer(for soundscape: Soundscape) -> AVAudioPCMBuffer? {
    if let cached = bufferCache[soundscape.id] { return cached }
    guard let url = Bundle.main.url(forResource: soundscape.fileName, withExtension: "mp3") else { return nil }
    do {
        let file = try AVAudioFile(forReading: url)
        let frameCount = AVAudioFrameCount(file.length)
        guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else { return nil }
        try file.read(into: buffer)
        bufferCache[soundscape.id] = buffer
        return buffer
    } catch {
        return nil
    }
}

停止时先淡出再停引擎:

fade(to: 0, duration: fadeOutDuration) { [weak self] in
    self?.stopNow(resetSession: resetSession)
}

5. 通知提醒:权限、频率、撤销一体化

提醒模块用 UNUserNotificationCenter,重点是“配置即覆盖”而不是“叠加创建”。

func configure(enabled: Bool, minutes: Int, frequency: ReminderFrequency) async -> Bool {
    if !enabled {
        cancel()
        return true
    }
    let allowed = await requestAuthorizationIfNeeded()
    guard allowed else {
        cancel()
        return false
    }
    schedule(minutes: minutes, frequency: frequency)
    return true
}

按周频次时生成固定 ID,方便后续精确取消:

let id = "\(weekdayPrefix)\(weekday)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request, withCompletionHandler: nil)

6. Live Activity:状态去重,避免无效刷新

Live Activity 不是“每帧都更新”,而是先比对状态,只有变化才推送:

func update(state: BreathingLiveActivityAttributes.ContentState) {
    guard #available(iOS 16.1, *) else { return }
    guard let activity else { return }
    guard state != lastState else { return }
    lastState = state
    Task {
        let content = ActivityContent(state: state, staleDate: nil)
        await activity.update(content)
    }
}

收益:减少无意义更新,降低系统开销。


7. 数据闭环:训练记录 + 课程进度

7.1 会话记录模型(SwiftData)

@Model
final class SessionRecord {
    var id: UUID
    var timestamp: Date
    var modeId: String
    var courseId: String?
    var programId: String?
    var programDay: Int?
    var duration: Int
    var preCheckin: String?
    var postCheckin: String?
}

preCheckin/postCheckin 让“训练前后变化”可追踪,这是后续留存和转化分析的基础字段。

7.2 课程进度推进

static func nextDayIndex(program: BreathingProgram, record: ProgramProgressRecord?) -> Int? {
    let completed = Set(record?.completedDays ?? [])
    for index in program.plan.indices {
        if !completed.contains(index) {
            return index
        }
    }
    return nil
}

这个实现很朴素,但稳定,且便于后续做“断点继续”。


8. Widget 深链:缩短回流路径

Widget 直接绑定深链,用户从桌面可一跳进入训练:

private let quickURL = URL(string: "breathing://start?type=quick")!
private let emergencyURL = URL(string: "breathing://start?type=emergency")!

这比“打开 App -> 选模式 -> 开始”少至少 2 步,对高频场景(焦虑急救/会前调整)很关键。


9. 订阅链路:StoreKit 2 的最小闭环

关键流程:拉商品 -> 发起购买 -> 校验交易 -> finish -> 刷新权益。

func purchaseSelectedProduct() async {
    guard let product = selectedProduct else { return }
    let result = try await product.purchase()
    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
        await transaction.finish()
        await refreshSubscriptionStatus()
    case .pending, .userCancelled:
        break
    @unknown default:
        break
    }
}

恢复购买也单独兜底:

try await StoreKit.AppStore.sync()
await refreshSubscriptionStatus()

10. 工程复盘:最值得复用的 4 个点

  1. 状态收口AppStore 统一管理跨页面状态。
  2. 节奏分治:阶段循环和倒计时分为两条 Task。
  3. 增长内建:提醒/Widget/Live Activity 不是后补功能,而是留存系统。
  4. 数据先行:从第一天就保留训练前后字段,后续分析成本最低。

后记:APP已经上架,某书上反响还不错,赚钱是次要的,主要产品有人用,技术有积累就很开心啦。 体验链接:apps.apple.com/cn/app/%E5%… PS:要兑换码好说,哈哈~

7e175ace-ca50-4f8f-8f0f-fbc0a82eecd7.jpg

cbebea05-dd13-4128-bf5e-06d7dce991d5.jpg

开工第一天,别让AI写的代码触发3.2f封号。

作者 iOS研究院
2026年2月24日 14:17

背景

今天是农历正月初八,春节后的第一个工作日。后台有粉丝留言,迎来的开年的第一记重磅打击3.2f待终止通知。

踩线原因也是老生常谈了,严查分类之隐藏功能问题

中英对照.png

老iOSer对于这种情况已经是见怪不怪了,很多时候并非开发者想做某些Sao操作,实属无奈的多。毕竟,有业务苹果不能正面允许,不得已就采用这种上有政策下有对策的打法

原因分析

通过进一步沟通,层层抽丝剥茧。终于定位到踩到隐藏功能的导火索,在AI加持的情况下使用了非公开的API获取业务层面需要的功能权限。从业务的角度来看功能确实实现了,从苹果监管的角度来看调用了越权的API属性。通过键值对的方式Hook数据结果。

实话讲AI背大锅,对于很多跨行的开发者来说,为了满足公司的开发需求保住饭碗使用AI的方式本身没有问题。关键的问题在于,无法Review AI所编写的代码是否合规

所以,AI本质是一把双刃剑,在提高开发效率的同时,也需要额外考虑风控问题。

隐藏功能

隐藏功能的前身是苹果开发者指南中的-2.3.1条款。

主要意在通过一些动态下发的方式,直接或间接干预苹果审核所看到的内容。将符合苹果审核的内容作为A面,顺利通过审核,提高审核通过率。【俗称的AB面,也叫马甲包】

随着AppStore审核规则的加强,对于隐藏功能的判定不仅仅只是单纯的功能切换,而是上升到更为全面的元数据以及概念层面。

简单来说:

少做不做挂羊头卖狗肉的事情,苹果的算法比开发者想象中更加强大

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

春晚、机器人、AI 与 LLM -- 肘子的 Swift 周报 #124

作者 东坡肘子
2026年2月24日 07:49

issue124.webp

春晚、机器人、AI 与 LLM

作为一个观众数量超十亿的电视节目,央视春晚无疑是极佳的展示平台。今年春晚中,多家中国机器人厂商在不同节目中展示了其产品,其中讨论度最高的当属宇树(Unitree)的人形机器人。在表演环节,多款型号的人形机器人完成了大量较为复杂的武术与动态动作展示。与去年偏静态、偏站桩式的呈现相比,今年的动作复杂度与稳定性确实有明显提升,这一点也得到了全球媒体的关注与报道。

春晚之后,社交媒体上的讨论呈现出明显分化。除了对技术进步的惊叹之外,“预编程”、“没有 AI”、“缺乏实用性”等质疑声音同样不少。这在一定程度上反映了公众对机器人技术复杂度的低估——尤其是对运动控制、实时反馈系统和系统级整合难度的认知不足。

需要澄清的一点是:预训练并不等于“录制-回放”。当前人形机器人在此类表演中的确采用了高度规划的动作流程,这与人类舞者、运动员的训练逻辑有相通之处——大量的离线训练与调试构成了动作的基础,但在实际执行过程中,身体仍需依赖动态平衡与即时修正来应对真实环境的扰动。正是这种容错与实时修复能力,才让人形机器人这个天然不稳定的双足系统得以完成高动态的连续动作。

与此同时,近年来大语言模型(LLM)的爆发,让不少人将 LLM 与 AI 等同起来。事实上,AI 作为一个已有数十年发展的领域,远远不止语言理解这一分支。尤其是面对真实的物理世界时,视觉识别、路径规划、运动控制、强化学习等专用模型在工业与实体系统中的使用量,依然远高于 LLM。在机器人领域,真正决定能力上限的往往是感知系统、控制系统以及低延迟反馈算法,而不是语言推理能力。

即便未来为人形机器人引入更强的"认知能力",更适合的路径也未必是直接接入 LLM,而可能是构建能理解物理规律的世界模型(World Models)与具备低延迟响应能力的控制系统——这两点恰恰是 LLM 的固有短板。具身智能(Embodied AI)的挑战,与纯文本推理存在本质差异。

至于“实用性”的问题,功夫或舞蹈确实难以直接对应现实工作场景。但恰恰是这些对平衡性、协调性与动态响应要求极高的动作,为人形机器人这种高度复杂且不稳定的系统提供了极佳的验证场景。它们更像是工程能力的压力测试,展示的是机械设计、电子控制与算法系统整合的成熟度,而非短期商业落地能力。

我个人对于人形机器人未来的市场规模仍然持审慎态度。技术进步与商业普及之间往往存在不小的鸿沟。但从今年春晚所呈现的进步幅度来看,可以合理判断:在未来十年内,机器人或智能机器以某种形式融入日常工作与生活场景,已不再是科幻想象。无论你是否喜欢“机器人”,技术演进的趋势已经十分明确,我们终将需要与它们共存。

至于“机器人奴役人类”的情景,我暂时并不担心。我更现实的担忧是:如果它们在工作中出现 Bug,给我一拳,我真的挨不住。

本期内容 | 前一期内容 | 全部周报列表

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态

近期推荐

如何在不破坏 App 的前提下迁移到 @Observable (How to Migrate to @Observable Without Breaking Your App)

随着越来越多的应用将最低系统版本提升至 iOS 17,@Observable 正在取代 ObservableObject 成为新的状态管理基础设施,但当项目已经深度依赖 ObservableObject + @Published 时,迁移远非简单替换宏即可完成。Pawel Kozielecki 结合一次真实的迁移踩坑经历,从底层机制差异出发,系统梳理了新体系下属性包装器的正确使用方式——用 @State 管理生命周期、用 @Bindable 处理双向绑定、只读场景直接使用普通属性,并特别指出了 @ObservationIgnored、计算属性追踪盲点等容易被忽视的细节。迁移的难点从来不在语法层面,而在于真正厘清“谁拥有 view model 的生命周期”这一根本问题。


验证多个回调按顺序触发 (Testing with Event Streams)

尽管 Swift Testing 提供了丰富的断言 API,但在实际使用中你会发现,并没有一个工具能够完全对应 XCTest 中“验证多个回调按顺序触发”(fulfillment + enforceOrder)的能力。confirmation 既需要嵌套使用,也无法直接校验触发顺序。对此,Matt Massicotte 提出了一种更符合 Swift 并发模型的思路:使用 AsyncStream 收集事件,并封装为一个轻量级的 EventStream 类型——当回调触发时 yield 事件标识,测试结束后通过 collect 获取完整事件序列,再与预期数组进行对比。对于“为什么不直接使用数组”这一疑问,Matt 也给出了充分理由:在存在 @Sendable 约束或 actor 隔离不一致的场景下,直接写入数组会触发并发安全问题,而基于 AsyncStream 的方案则天然符合并发模型的约束。


务必为 SwiftData 模型显式声明 Schema 版本 (If You’re Not Versioning Your SwiftData Schema, You’re Gambling)

SwiftData 的声明式写法与自动迁移能力很容易让人产生“框架会替我处理一切”的错觉,但现实是,一旦模型结构发生变化(字段新增、重命名、关系调整),如果没有显式的 schema version 与 migration plan,就只能依赖隐式推断。一旦推断失败,结果往往不是优雅的迁移,而是崩溃、数据丢失,甚至导致应用无法启动。Mohammad Azam 的建议直接而务实:显式声明 Schema 版本;为未来的结构变化预留迁移路径;将“迁移设计”视为模型设计的一部分,而不是事后补救。

本文的观点同样适用于 Core Data。即便模型完全兼容轻量迁移,为每个发行版本创建对应的模型版本文件(只要发生结构修改),不仅有助于追踪模型演化轨迹,也能在出现问题时实现清晰而可控的回滚。用明确的版本机制约束模型演进,本质上是在为长期维护建立安全边界。


用 Swift 开发 CLI 工具 (How to build a simple CLI tool using Swift)

一个有趣的现象是,在 AI Coding 时代,CLI 正在重焕青春——越来越多的开发者通过构建 CLI 工具来承载自己的 MCP 与 Agent 工作流。Natascha Fadeeva 介绍了如何用 Swift Package Manager 和 Apple 官方的 ArgumentParser 库构建结构化的命令行工具:定义主命令与子命令、处理异步网络请求、最终编译为可独立分发的二进制文件。对于已经熟悉 Swift 的 iOS 开发者来说,这条路径比维护一套 bash/Python 脚本更自然,也更容易随项目一起演进。


在 AI 编程时保持方向感 (Navigation Notes – Agentic coding)

作为一个拥有丰富经验的开发者,Joseph Heck 认为当 AI 能够主动执行任务、生成代码甚至推动改动时,开发者的角色从“逐行实现者”转变为“路径规划者”。真正稀缺的能力不再是写代码的速度,而在“导航”——也就是开发者在复杂代码与多代理环境中如何保持方向感。Joseph 给出了几条建议,例如:在提示词中始终加入"对任何模糊之处向我提问";先让 Agent 制定计划并获得确认,再开始实施;提供确定性的反馈回路(单元测试、编译器错误),让 Agent 能够自我修正;以及将反复使用的指令集沉淀为 Skill 文件等。

Heck 并没有过度渲染“AI 颠覆开发者”的叙事,而是强调一种更冷静的现实:agentic coding 会放大已有的工程能力。如果你本来就善于模块划分与抽象设计,AI 会加速你;如果边界感模糊,AI 只会更快制造混乱。


为 Agent 驱动的 iOS 项目构建可靠交付管线 (Setting up a delivery pipeline for your agentic iOS projects)

当代码的生成、修改与重构开始由 Agent 驱动时,传统的 CI/CD 流程是否仍然足够?Donny Wals 以一次真实经历展开:健身时应用崩溃,他将 Crash Report 交给 Agent 分析,训练结束后 PR 已经准备就绪,合并后 TestFlight 构建随即落地。围绕这一实践,他系统梳理了如何为“agentic iOS 项目”构建一条可靠的交付管线(delivery pipeline),确保自动化改动依然可控、可验证、可发布。

文章的重点并不在某个具体工具,而在流程设计本身。Donny 强调,Agent 生成的代码本质上仍属于“未经人工逐行审查的改动”,因此更需要明确的边界与质量闸口:自动化测试、持续集成与发布流程必须承担最终的交付责任。Agent 可以显著提升实现速度,但工程纪律不能随之放松——速度提升之后,控制机制反而更为关键。


实时掌握 Foundation Models 的上下文消耗 (Tracking Token Usage in Foundation Models)

Apple 的 Foundation Models 运行在设备端,上下文窗口仅 4096 个 token,一旦超出便无法继续对话。iOS 26.4 新增了 token 用量追踪 API,帮助开发者实时掌握上下文消耗情况。Artem Novichkov 系统介绍了四个关键指标:模型上下文总容量(contextSize)、Instructions 的 token 消耗、单条 Prompt 的消耗,以及完整对话记录(Transcript)的累计用量。文章还揭示了一个容易被忽视的细节:当引入 Tool 时,其名称、描述与参数 Schema 会被序列化并计入 token,同一段 Instructions 在附加 Tool 后 token 数从 16 跃升至 79。对于设备端模型而言,token 的可观测性将成为优化体验的基础设施。

工具

App Store Connect CLI

App Store Connect CLI 是由 Rudrank Riyam 开发的非官方 App Store Connect 命令行工具,功能覆盖 TestFlight 管理、构建上传、代码签名、截图自动化、本地化同步、应用审核提交、notarization,以及财务报告下载等完整发布链路。它从设计阶段就强调 Agent 场景,并提供了面向 Agent 的实践文档。若你的发布流程重心在 TestFlight、元数据、提审、签名与 CI 自动化,ASC 可以作为 fastlane 的轻量替代方案之一。


GRDB 7.10.0: 新增 Android、Linux、Windows 支持

GRDB 7.10.0 是一个具有里程碑意义的版本更新:本次正式引入对 Android、Linux、Windows 的支持,并新增通过 Swift Package Manager 使用 SQLCipher(加密数据库)的能力——这两项功能都长期受到社区期待。这意味着这个 Swift 生态中最成熟的 SQLite 封装库,正在从 Apple 平台工具演进为真正的跨平台数据层解决方案。

Gwendal Roué版本公告 中也特别说明,由于 Xcode 尚未支持 package traits,SwiftPM 目前仍会下载未实际使用的依赖;在相关问题解决之前,SQLCipher 支持将以 fork 形式长期维护。


Swift System Metrics

Swift System Metrics 为 Swift 应用(尤其是服务端项目)提供了统一的系统级指标采集能力,例如 CPU 使用率、内存占用、文件描述符数量等,并通过标准化的 Metrics 接口对外暴露,便于接入 Prometheus 等现有监控体系。它并非一个独立的监控系统,而是由 Swift Server Work Group 推动的基础设施组件,旨在与 Swift Metrics 生态对齐,使系统资源指标与应用级指标纳入同一可观测体系。1.0 的发布意味着 API 已趋于稳定,具备生产环境使用条件。对正在构建 Swift 后端服务、或持续完善 Swift 可观测性能力的团队来说,这是一个基础设施层面的关键拼图。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

第三十二章 接下来我们开始做`灭菌整板`页面

作者 君赏
2026年2月23日 13:50

image-20211222183930334

新建 SterilizeWholeBoardPage 空页面

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    
}
struct SterilizeWholeBoardPage: View {
    @StateObject private var viewModel = SterilizeWholeBoardPageViewModel()
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            EmptyView()
        }
        .makeToDetailPage()
    }
}

添加 【灭菌批号】【栈版号】【箱号】

image-20211222185405845

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    /// 灭菌批号
    @Published var sterilizationLotNumber:String = ""
    /// 栈版号
    @Published var stackVersionNumber:String = ""
    /// 箱号
    @Published var caseNumber:String = ""
}
struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                Spacer()
                    .frame(height: 5)
                VStack(spacing: 0) {
                    ScanTextView(title: "灭菌批号",
                                 prompt: "请输入灭菌批号",
                                 text: $viewModel.sterilizationLotNumber)
                    Divider()
                        .padding(.leading, 10)
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.stackVersionNumber)
                    Divider()
                        .padding(.leading, 10)
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.caseNumber)
                }
                .background(.white)
                
                Spacer()
            }
        }
        ...
    }
}

image-20211222192408026

添加 【栈板序号】【物料总体积】【箱数】

image-20211222192919873

struct SterilizeWholeBoardPage: View {
   ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                ...
                VStack {
                    HStack(spacing: 0) {
                        Text("栈板序号")
                            .frame(width: 100, alignment: .leading)
                        Text("1")
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(EdgeInsets(top: 15, leading: 22, bottom: 15, trailing: 22))
                    Divider()
                        .padding(.leading, 10)
                    VStack(spacing: 10) {
                        HStack(spacing: 0) {
                            Text("物料总体积")
                                .frame(width: 100, alignment: .leading)
                            Text("120.86 m³")
                        }
                        .frame(maxWidth: .infinity, alignment: .leading)

                        HStack(spacing: 0) {
                            Text("箱数")
                                .frame(width: 100, alignment: .leading)
                            Text("12")
                        }
                        .frame(maxWidth: .infinity, alignment: .leading)
                    }
                    .padding(EdgeInsets(top: 15, leading: 22, bottom: 15, trailing: 22))
                }
                ...
            }
        }
        ...
    }
}

image-20211223082530347

使用 environment 规范 Title 文本的宽度

.frame(width: 100, alignment: .leading)

大量这种代码我们实在受够了,一个页面如果很多元素,或者其他界面一样的这种对齐呢?不过我们可以通过 environment进行设置。

新增 TitleWidthEnvironmentKey

struct TitleWidthEnvironmentKey: EnvironmentKey {
  /// 设置默认为100 
    static var defaultValue: CGFloat = 100
}

给 EnvironemtValues 扩展属性 titleWidth

extension EnvironmentValues {
    var titleWidth: CGFloat {
        get { self[TitleWidthEnvironmentKey.self] }
        set { self[TitleWidthEnvironmentKey.self] = newValue }
    }
}

将 ScanTextView 中的宽度限制 修改为 titleWidth

struct ScanTextView: View {
   ...
    /// 默认为 100
    @Environment(\.titleWidth) private var titleWidth:CGFloat
    
    init(title:String, prompt:String, text:Binding<String>) {
        ...
    }
    ...
}

封装组件 LimitLeadingWidthView

struct LimitLeadingWidthView<Leading:View, Treading:View>: View {
    @Environment(\.titleWidth) private var leadingLimitWidth:CGFloat
    private let leading:Leading
    private let treading:Treading
    init(@ViewBuilder leading:() -> Leading, @ViewBuilder treading:() -> Treading) {
        self.leading = leading()
        self.treading = treading()
    }
    var body: some View {
        HStack(spacing: 0) {
            leading
                .frame(width: leadingLimitWidth, alignment: .leading)
            treading
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}
struct LimitLeadingWidthView_Previews: PreviewProvider {
    static var previews: some View {
        LimitLeadingWidthView(leading: {
            Text("我是左侧文本")
        }, treading: {
            Text("我是右侧文本")
        })
            .previewLayout(.sizeThatFits)
    }
}

image-20211223100900788

将【栈板序号】【物料总体积】【箱数】更换为 LimitLeadingWidthView 组件

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
               ...
                VStack {
                    LimitLeadingWidthView(leading: {
                        Text("栈板序号")
                    }, treading: {
                        Text("1")
                    })
                    ...
                    VStack(spacing: 10) {
                        LimitLeadingWidthView {
                            Text("物料总体积")
                        } treading: {
                            Text("120.86 m³")
                        }
                        LimitLeadingWidthView {
                            Text("箱数")
                        } treading: {
                            Text("12")
                        }
                    }
                    ...
                }
                ...
            }
        }
        ...
    }
}

将 ScanTextView 内部使用 LimitLeadingWidthView 组件

struct ScanTextView: View {
    ...
    var body: some View {
        LimitLeadingWidthView {
            HStack {
                Text("*")
                    .foregroundColor(Color(uiColor: appColor.c_e68181))
                Text(title)
                Spacer()
            }
        } treading: {
            HStack {
                TextField(prompt, text: $text)
                    .frame(height:33)
                Image("scan_icon", bundle: .main)
            }
        }
        ...
    }
}

扩展 View 新增 limitLeadingWidth 方法

虽然默认值100已经在当前页面足够的展示左侧的内容,我们想要在当前页面根本修改全部LimitLeadingWidthView左侧宽度为110

z

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            ...
        }
        .environment(\.titleWidth, 110)
    }
}

对于.environment(\.titleWidth, 110)这样的方式不是很优雅,使用者还要关心对应Key是什么?我们可以给View做一下扩展。

extension View {
    func limitLeadingWidth(_ width:CGFloat) -> some View {
        self.environment(\.titleWidth, width)
    }
}

此时我们上面的代码就可以变成下面

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            ...
        }
        .limitLeadingWidth(110)
    }
}

这样的写法可以明确用意。

获取栈板序号

image-20211223113058626

栈板序号的值来源于通过灭菌批号查询

新增 @Published 栈板序号用于更新页面

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    ...
    /// 栈板序号
    @Published var palletSerialNumber:String = ""
}

根据【灭菌批号】获取【栈板序号】

class SterilizeWholeBoardPageViewModel: BaseViewModel {
    ...
    
    /// 请求栈板序号
    func requestPalletNumber() async {
        guard !sterilizationLotNumber.isEmpty else {
            showHUDMessage(message: "灭菌批号为空!")
            return
        }
        let api = GetSterilizationSequenceApi(sterilizeBatch: sterilizationLotNumber)
        let model:BaseModel<Int> = await request(api: api)
        guard model._isSuccess, let data = model.data else {
            return
        }
        palletNumber = "\(data)"
    }
}

输入完毕【灭菌批号】获取【栈板序号】

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                VStack(spacing: 0) {
                    ScanTextView(title: "灭菌批号",
                                 prompt: "请输入灭菌批号",
                                 text: $viewModel.sterilizationLotNumber)
                        .onSubmit {
                            Task {
                                await viewModel.requestPalletNumber()
                            }
                        }
                    ...
                }
                ...
            }
        }
        ...
    }
}

设置 TabBar 的背景颜色

image-20211223115539590

突然发现,我们的TabrBar变成了这个样子,应该是我们修改SafeArea导致的。

struct TabPage: View {
    ...
    
    init() {
        ...
        UITabBar.appearance().backgroundColor = .white
    }
    ...
}

获取【物料总体积】【箱数】

继承 PalletBindBoxNumberPageViewModel

物料总体积箱数来源于根据栈版号获取的箱子列表拿到的数据。这个页面输入栈版号箱号是一样的逻辑,我们不如将SterilizeWholeBoardPageViewModel继承于PalletBindBoxNumberPageViewModel

class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
    /// 灭菌批号
    @Published var sterilizationLotNumber:String = ""
    /// 栈板序号
    @Published var palletSerialNumber:String = ""
    
    ....
}
struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                VStack(spacing: 0) {
                    ...
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.palletNumber)
                    ...
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.boxNumber)
                }
                ...
                VStack {
                    LimitLeadingWidthView(leading: {
                        Text("栈板序号")
                    }, treading: {
                        Text(viewModel.palletSerialNumber)
                    })
                    ...
            }
        }
        ...
    }
}

新增 @Published 变量显示【箱号总体积】【箱数】

class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
    ...
  /// 总体积
    @Published var totalCapacity:String = ""
    /// 箱数
    @Published var totalBox:String = ""
    ...
}

因为箱子总体积箱数的数据来源于单条的BoxDetailModel里面的数据,我们监听boxDetailModels的变化,获取第一条元素进行获取。

class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
    ...
    /// 存储 Publisher 取消
    private var cancellabels:Set<AnyCancellable> = []
    
    override init() {
        super.init()
        $boxDetailModels.sink {[weak self] models in
            guard let self = self else {return}
            self.totalCapacity = models.first.flatMap({$0.volume}).map({"\($0)"}) ?? ""
            self.totalBox = models.first.flatMap({$0.total}).map({"\($0)"}) ?? ""
        }
        .store(in: &cancellabels)
    }
    
    ...
}

查询栈板箱子列表和新增删除箱号

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
                ...
                VStack(spacing: 0) {
                    ...
                    ScanTextView(title: "栈版号",
                                 prompt: "请输入栈版号",
                                 text: $viewModel.palletNumber)
                        .onSubmit {
                            Task {
                                await viewModel.requestBoxDetailList()
                            }
                        }
                    ...
                    ScanTextView(title: "箱号",
                                 prompt: "请输入箱号",
                                 text: $viewModel.boxNumber)
                        .onSubmit {
                            Task {
                                await viewModel.addOrRemoveBox()
                            }
                        }
                }
                ...
            }
        }
        ...
    }
}

提炼箱号列表

灭菌整板箱子列表和托盘绑定箱号的箱子列表是一样的,所以,我们可以将灭菌整板的箱子列表进行提炼。

struct BoxListView: View {
    private let models:[BoxDetailModel]
    init(models:[BoxDetailModel]) {
        self.models = models
    }
    var body: some View {
        List {
            ForEach(models) { model in
                BoxDetailView(model: model)
                    .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 0))
                    .listRowBackground(Color.clear)
                    .listRowSeparator(.hidden)
            }
        }
        .listStyle(.plain)
    }
}

修改托盘绑定箱号页面

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                BoxListView(models: viewModel.boxDetailModels)
            }
        }
        ...
    }
}

灭菌整板页面新增BoxListView

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            VStack(spacing: 0) {
               ...
                Spacer()
                    .frame(height: 10)
                BoxListView(models: viewModel.boxDetailModels)
            }
        }
        ...
    }
}

image-20211224105912683

添加 【重置】 【提交】按钮

image-20211224110123490

打印需要调用蓝牙和硬件交互,我们就把打印替换成重置

封装 TextButton

在登录页面,我们有一个类似的登录按钮,决定按照登录按钮的样式封装按钮,方便后面的使用。

struct TextButton: View {
    @StateObject private var appColor = AppColor.share
    private let title:String
    private let action:() -> Void
    init(title:String, action:@escaping () -> Void) {
        self.title = title
        self.action = action
    }
    var body: some View {
        Button(action: action) {
            Text(title)
                .font(.system(size: 16))
                .frame(maxWidth:.infinity)
                .frame(height: 45)
                .background(Color(uiColor: appColor.c_209090))
                .foregroundColor(.white)
                .cornerRadius(5)
        }
        
    }
}

struct TextButton_Previews: PreviewProvider {
    static var previews: some View {
        TextButton(title: "登录", action: {})
            .previewLayout(.sizeThatFits)
    }
}

通过 TextButton 添加 【重置】【提交】

通过 Stack 进行叠加布局

struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            ZStack {
                ...
                VStack {
                    Spacer()
                    HStack {
                        TextButton(title: "重置") {
                            
                        }
                        TextButton(title: "提交") {
                            
                        }
                    }
                    .padding()
                }
            }
        }
        ...
    }
}

重置界面数据

点击重置按钮需要将界面所有的数据清空,界面恢复到刚打开的状态。

class SterilizeWholeBoardPageViewModel: PalletBindBoxNumberPageViewModel {
    ...
    func reset() {
        sterilizationLotNumber = ""
        palletSerialNumber = ""
        palletNumber = ""
        boxNumber = ""
        totalCapacity = ""
        totalBox = ""
        boxDetailModels = []
    }
}
struct SterilizeWholeBoardPage: View {
    ...
    var body: some View {
        PageContentView(title: "灭菌整板", viewModel: viewModel) {
            ZStack {
                ...
                VStack {
                    ...
                    HStack {
                        TextButton(title: "重置") {
                            viewModel.reset()
                        }
                        ...
                    }
                    ...
                }
            }
        }
        ...
    }
}

【提交】灭菌整板

image-20211226172444260

第三十一章 完善箱号列表

作者 君赏
2026年2月23日 13:49

我们已经通过栈版号获取到了箱子列表数据,那么我们用List将数据展示出来。

BoxDetailModel 实现 Identifiable 协议

extension BoxDetailModel: Identifiable {
    var id: String { boxCode ?? "" }
}

List + ForEach 实现列表

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        ... {
            ... {
               ...
                List {
                    ForEach(viewModel.boxDetailModels) { model in
                        BoxDetailView()
                    }
                }
            }
        }
        ...
    }
}

image-20211222112442651

List 构建的是否存在性能问题?

image-20211222113027064

看了视图,核心还是利用UITableView重用的机制,所以使用List展示很多数据,是会走重用机制的。

设置 List 的 Style

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        ... {
            ... {
                ... {
                List {
                ...
                }
                .listStyle(.plain)
            }
        }
        ...
    }
}

image-20211222143008277

通过 listRowInsets 设置 Row 的间隙

显示出来的间隙,明显和我们BoxDetailView的间隙大很多,为了看一下差距,我们给BoxDetailView设置一个红色背景色。

image-20211222143722283

看起来左右留白多一些,上下留白很少。

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(0 ..< 10) { model in
                        BoxDetailView()
                            ...
                            .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))

                    }

                }
                ...
            }
        }
        ...
    }
}

image-20211222155658090

通过 listRowInsets 增加 Cell 之间的间隙

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(0 ..< 10) { model in
                        BoxDetailView()
                            .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 0))
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222163931659

通过 listRowBackground 设置背景颜色

上图完全看不到Cell之间的坚决,我们可以通过listRowBackground进行设置颜色,来区分Cell

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(0 ..< 10) { model in
                        BoxDetailView()
                            ...
                            .listRowBackground(Color.clear)
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222165502211

通过 listRowSeparator 隐藏 Cell 的 Separator

struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(0 ..< 10) { model in
                        BoxDetailView()
                            ...
                            .listRowSeparator(.hidden)
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222170038600

传递 Model 到 BoxDetailView 赋值

struct BoxDetailView: View {
    private let model:BoxDetailModel
    init(model:BoxDetailModel) {
        self.model = model
    }
    var body: some View {
        HStack {
            VStack {
                TitleValueView(...,
                               value: model.skuCode ?? "")
                ...
                TitleValueView(...,
                               value: model.skuBatch ?? "")
            }
            VStack {
                TitleValueView(...,
                               value: model.paperCode ?? "",
                               ...)
                ...
                TitleValueView(...,
                               value: model.boxCode ?? "",
                               ...)
            }
        }
        ...
    }
}
struct PalletBindBoxNumberPage: View {
    ...
    var body: some View {
        PageContentView(title: "托盘绑定箱号", viewModel: viewModel) {
            VStack(spacing:0) {
                ...
                List {
                    ForEach(viewModel.boxDetailModels) { model in
                        BoxDetailView(model: model)
                            ...
                    }
                }
                ...
            }
        }
        ...
    }
}

image-20211222183243829

❌
❌