普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月29日掘金 iOS

WKWebView的重定向(objective_c)

2025年11月29日 18:12

背景

第三方支付回调时需要重定向到app的某个页面,比如支付完成后回到原生订单详情页,这个时间会有两种情况:

1、直接在web页面重定向到app的订单详情页,这个时候只需要实现 WKNavigationDelegate 中的一个核心方法webView:decidePolicyForNavigationAction:decisionHandler: 方法。

2、在支付中心跳转到第三方app然后支付完成后需要跳转回自己的app的订单详情页,这个时候可以采用Scheme方式或者是通用链接的方式解决

wkWebView重定向实现

实现这一目标,您需要让您的 WKWebView 所在的控制器遵循 WKNavigationDelegate 协议,并实现 webView:decidePolicyForNavigationAction:decisionHandler: 方法。

self.webView.navigationDelegate = self; // 设置代理

#pragma mark - WKNavigationDelegate 
- (**void**)webView:(WKWebView *)webView

decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction

decisionHandler:(**void** (^)(WKNavigationActionPolicy))decisionHandler {
    NSURL *url = navigationAction.request.URL;
    NSString *scheme = url.scheme;
    // 1. 检查 URL Scheme 是否是我们的自定义 Scheme
    if ([scheme isEqualToString:@"coolpet"]) {
        // 1.1. 阻止 WKWebView 加载这个 URL
        decisionHandler(WKNavigationActionPolicyCancel);
        // 1.2. 实现了 handleCoolPetURL: 方法
        [self handleCoolPetURL:url];
        // 1.3. 跳转后关闭当前的 WebView 页面
        [self.navigationController popViewControllerAnimated:YES];
        return;
    }
    // 2. 对于其他 HTTP/HTTPS 链接,允许正常加载
    // 特别检查 navigationType 是否是新的主框架加载,例如用户点击了链接
//    if (navigationAction.navigationType == WKNavigationTypeLinkActivated && ![scheme hasPrefix:@"http"]) {
//        // 如果是点击了非 HTTP/HTTPS 的链接(但不是我们自定义的 Scheme),可以根据需要处理,
//        // 比如打开 App Store 或其他应用。这里我们通常允许其他系统 Scheme
//        // 允许继续,但更安全的做法是只允许 http(s)
//        // decisionHandler(WKNavigationActionPolicyAllow);
//    }
    // 3. 默认允许其他所有导航行为(如页内跳转、HTTP/HTTPS 加载等)
    decisionHandler(WKNavigationActionPolicyAllow);
}

// 通过URL跳转对应页面
- (void)handleCoolPetURL:(NSURL *)url {
    NSString *host = url.host;
    NSString *path = url.path;      // 路径: /order/detail
    NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
    NSMutableDictionary *queryParams = [NSMutableDictionary dictionary];
    for (NSURLQueryItem *item in components.queryItems) {
        queryParams[item.name] = item.value;
    }
    // 根据路径判断是否是订单详情页
    if ([host isEqualToString:kAPPUniversalTypeOrderDetailsHost] && [path isEqualToString:kAPPUniversalTypeOrderDetailsPath]) {
        // 获取我们需要的订单号
        NSString *tradeNo = [queryParams[@"tradeNo"] stringValue];
        // 执行跳转

        if (tradeNo.length > 0) {
            dispatch_async(dispatch_get_main_queue(), ^{
                /// 做跳转
            });
        }
    }
}

Scheme方式

第三方支付平台完成支付后,是通过你App的 URL Scheme 来唤醒你的App并携带支付结果的。

  1. 配置 App URL Scheme
  • 操作: 在 Xcode 项目的 Info.plist 或项目设置的 Info 选项卡下的 URL Types 中添加你的 App 的 Scheme。

    • 例如,你可以设置一个 Scheme 叫 myscheme
  1. 处理 App Delegate 中的回调

App 被第三方支付应用唤醒后,系统会调用 AppDelegate 中的特定方法。你需要在这里接收并处理回调 URL。

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, **id**> *)options {
    // 1. 检查是否是你的支付回调 Scheme
    if ([url.scheme isEqualToString:@"myappscheme"]) {
        [self handleCoolPetURL:url];
    }

    // 如果是其他URL(如通用链接),也在这里处理
    // ...
    return NO;
}

通用链接方式

当用户点击一个配置了通用链接的 HTTPS 链接时:

  1. 如果 App 已经安装,系统会直接调用 AppDelegate 中的这个方法。
  2. 如果 App 未安装,该链接会直接在 Safari 中打开。

这个机制的主要优点是安全(基于 HTTPS)和用户体验更好(避免了 URL Scheme 引起的跳转确认和安全问题)。

🔗 通用链接(Universal Links)实现指南

步骤 1: 服务器端配置(Association File)

这是通用链接能够工作的基础。您需要在您的 Web 服务器上创建一个特殊的 JSON 文件,告诉 iOS 系统哪些路径应该由您的 App 处理。

1. 创建 apple-app-site-association 文件
  • 文件名: 必须是 apple-app-site-association(注意,没有 .json 扩展名)。

  • 内容格式(JSON):

    {
        "applinks": {
            "apps": [],
            "details": [
                {
                    "appID": "TeamID.BundleID",
                    "paths": [
                        "/orders/*",    // 匹配所有 /orders/ 下的路径
                        "/products/*",  // 匹配所有 /products/ 下的路径
                        "NOT /account/login/*" // 排除某些路径
                    ]
                }
            ]
        }
    }
    
    • TeamID 您的 Apple Developer Team ID。
    • BundleID 您的 App 的 Bundle Identifier。
    • paths 定义您希望 App 能够处理的 URL 路径。
2. 部署文件
  • 部署位置: 将此文件上传到您的域名根目录或 .well-known/ 目录下。

    • 例如:https://yourdomain.com/apple-app-site-association
    • 或者:https://yourdomain.com/.well-known/apple-app-site-association
  • 内容类型: 确保服务器以正确的 MIME 类型提供此文件:application/jsontext/plain

  • HTTPS: 您的整个网站必须使用 HTTPS

步骤 2: App 端配置(Xcode & Objective-C)

1. 开启 Associated Domains Capability

在 Xcode 中为您的 App 开启 Associated Domains 功能。

  • 路径: Xcode -> 项目设置 -> 目标 (Target) -> Signing & Capabilities 选项卡

  • 操作: 点击 + Capability,添加 Associated Domains

  • 添加域名: 在列表中添加您的域名,格式为:

    applinks:yourdomain.com
    

    注意: 不带 https://http://

2. 在 AppDelegate 中接收回调

当用户点击一个通用链接并唤醒 App 时,系统会调用 AppDelegate 中的 continueUserActivity 方法。您需要在此方法中解析 URL 并进行页面跳转。

// AppDelegate.m

#import "OrderViewController.h" // 假设您的订单处理页面

// ...

- (BOOL)application:(UIApplication *)application 
continueUserActivity:(NSUserActivity *)userActivity 
  restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
    
    // 1. 检查活动类型是否为 Universal Link
    if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
        
        // 2. 获取用户点击的 HTTPS URL
        NSURL *webpageURL = userActivity.webpageURL;
        
        if (webpageURL) {
            NSLog(@"Received Universal Link: %@", webpageURL.absoluteString);
            
            // 3. 将 URL 转发给路由处理方法
            [self handleUniversalLinkURL:webpageURL];
            
            return YES;
        }
    }
    
    return NO;
}

// 通用链接路由处理方法
- (void)handleUniversalLinkURL:(NSURL *)url {
    
    // 示例:解析路径并跳转到订单详情
    if ([url.path hasPrefix:@"/orders/detail"]) {
        
        // 解析查询参数,例如 order_id=12345
        NSString *orderID = [self extractParameter:@"order_id" fromURL:url];
        
        if (orderID.length > 0) {
            dispatch_async(dispatch_get_main_queue(), ^{
                // 执行跳转逻辑
                UINavigationController *nav = (UINavigationController *)self.window.rootViewController;
                OrderViewController *orderVC = [[OrderViewController alloc] init];
                orderVC.orderID = orderID;
                [nav pushViewController:orderVC animated:YES];
            });
        }
    }
}

// 辅助方法 (需要您自行实现,或使用前文提到的 dictionaryWithQueryString: 方法)
- (NSString *)extractParameter:(NSString *)paramName fromURL:(NSURL *)url {
    // ... 解析 url.query 字符串,提取指定参数 ...
    return nil; 
}

iOS 语音房(拍卖房)开发实践

作者 KangJX
2025年11月29日 17:49

本文基于一个真实的iOS语音房项目案例,详细讲解如何使用状态模式来管理复杂的业务流程,以及如何与权限中心协同工作,因为在拍卖房间中不只有不同的房间阶段变化(状态)还有不同角色拥有不同的权限(权限中心)。

业务场景

拍拍房是一个实时拍卖房间系统,类似于语音房间+拍卖的结合体。用户可以在房间内:

  • 作为房主主持拍卖
  • 作为拍卖人上传物品并介绍
  • 作为竞拍者出价竞拍
  • 作为观众观看拍卖过程

核心业务流程

一个完整的拍卖流程需要经历4个明确的阶段:

准备阶段 → 上拍 → 拍卖中 → 定拍 → (循环)准备阶段

每个阶段都有:

  • 不同的允许操作(如只能在准备阶段上传物品)
  • 不同的状态转换规则(如只能从拍卖中进入定拍)
  • 不同的业务逻辑(如只有拍卖中才能出价)

技术挑战

  1. 状态多:4个主要状态,每个状态行为差异大
  2. 转换复杂:状态之间的转换有严格的规则
  3. 权限交织:每个操作还需要考虑用户角色权限
  4. 易扩展性:未来可能增加新的拍卖模式

为什么选择状态模式

❌ 不使用状态模式的问题

如果使用传统的 if-elseswitch-case 来处理:

// 反例:所有逻辑堆砌在一起
func placeBid(amount: Decimal) {
    if currentState == .preparing {
        print("拍卖还未开始")
        return
    } else if currentState == .listing {
        print("拍卖还未正式开始")
        return
    } else if currentState == .auctioning {
        // 执行出价逻辑
        if user.role == .viewer {
            print("观众不能出价")
            return
        }
        if user.id == auctioneer.id {
            print("拍卖人不能给自己出价")
            return
        }
        if amount < currentPrice + incrementStep {
            print("出价金额不足")
            return
        }
        // 终于可以出价了...
    } else if currentState == .closed {
        print("拍卖已结束")
        return
    }
}

问题显而易见

  1. 🔴 代码臃肿:所有状态的逻辑混在一起
  2. 🔴 难以维护:修改一个状态可能影响其他状态
  3. 🔴 不易扩展:增加新状态需要修改多处代码
  4. 🔴 权限混乱:业务逻辑和权限判断纠缠在一起
  5. 🔴 测试困难:无法单独测试某个状态的逻辑

✅ 使用状态模式的优势

// 状态模式:每个状态独立处理
class AuctioningState: RoomStateProtocol {
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        // 只关注拍卖中状态的出价逻辑
        let bid = Bid(...)
        room.addBid(bid)
        return true
    }
}

class PreparingState: RoomStateProtocol {
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        // 准备阶段直接拒绝
        print("拍卖还未开始")
        return false
    }
}

优势明显

  1. 职责单一:每个状态类只关注自己的逻辑
  2. 易于维护:修改某个状态不影响其他状态
  3. 开闭原则:新增状态只需添加新类,不修改现有代码
  4. 清晰直观:状态转换一目了然
  5. 便于测试:可以单独测试每个状态

状态模式设计

整体架构

┌─────────────────────────────────────────┐
│           Room(房间上下文)             │
│  - currentState: RoomStateProtocol      │
│  - changeState(to: RoomState)           │
└──────────────┬──────────────────────────┘
               │ 持有
               ↓
┌─────────────────────────────────────────┐
│      RoomStateProtocol(状态协议)       │
│  + startAuction(room: Room) -> Bool     │
│  + placeBid(room: Room, ...) -> Bool    │
│  + endAuction(room: Room) -> Bool       │
│  + uploadItem(room: Room, ...) -> Bool  │
└─────────────┬───────────────────────────┘
              │ 实现
    ┌─────────┼─────────┬─────────┐
    ↓         ↓         ↓         ↓
┌──────┐ ┌────────┐ ┌────────┐ ┌────────┐
│准备  │ │上拍    │ │拍卖中  │ │定拍    │
│State │ │State   │ │State   │ │State   │
└──────┘ └────────┘ └────────┘ └────────┘

核心组件

1. 状态枚举

enum RoomState: String {
    case preparing      // 准备阶段
    case listing        // 上拍
    case auctioning     // 拍卖中
    case closed         // 定拍
}

2. 状态协议

protocol RoomStateProtocol {
    var stateName: RoomState { get }
    
    // 状态转换
    func startAuction(room: Room) -> Bool
    func endAuction(room: Room) -> Bool
    
    // 业务操作
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool
    
    // 状态描述
    func getStateDescription() -> String
}

状态转换图

┌─────────────┐
│  准备阶段    │ 拍卖人上传物品、设置规则
│  Preparing  │ 房主可以开始拍卖
└──────┬──────┘
       │ startAuction()
       ↓
┌─────────────┐
│    上拍     │ 展示物品信息
│   Listing   │ 倒计时准备(3秒)
└──────┬──────┘
       │ 自动转换 / 房主提前开始
       ↓
┌─────────────┐
│   拍卖中    │ 用户可以出价
│ Auctioning  │ 倒计时重置机制
└──────┬──────┘
       │ endAuction() / 倒计时归零
       ↓
┌─────────────┐
│    定拍     │ 展示成交结果
│   Closed    │ 可以开启下一轮
└──────┬──────┘
       │ startAuction() (开启下一轮)
       ↓
┌─────────────┐
│  准备阶段    │ 回到初始状态
│  Preparing  │
└─────────────┘

具体实现

1. 准备阶段(Preparing)

class PreparingState: RoomStateProtocol {
    var stateName: RoomState { return .preparing }
    
    // ✅ 允许:开始拍卖
    func startAuction(room: Room) -> Bool {
        guard room.currentItem != nil else {
            print("⚠️ 没有拍卖物品,无法开始")
            return false
        }
        
        // 状态转换:准备 → 上拍
        room.changeState(to: .listing)
        
        // 3秒后自动进入拍卖中
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            room.changeState(to: .auctioning)
        }
        
        return true
    }
    
    // ❌ 不允许:出价
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        print("⚠️ 拍卖还未开始,无法出价")
        return false
    }
    
    // ✅ 允许:上传物品
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool {
        room.setAuctionItem(item, rules: rules)
        return true
    }
    
    // ❌ 不允许:结束拍卖
    func endAuction(room: Room) -> Bool {
        print("⚠️ 拍卖还未开始")
        return false
    }
    
    func getStateDescription() -> String {
        return "准备阶段:拍卖人可以上传物品并设置规则"
    }
}

关键点

  • ✅ 只允许上传物品和开始拍卖
  • ✅ 自动触发状态转换(准备 → 上拍 → 拍卖中)
  • ✅ 逻辑清晰,职责单一

2. 上拍阶段(Listing)

class ListingState: RoomStateProtocol {
    var stateName: RoomState { return .listing }
    
    // ✅ 允许:房主提前开始
    func startAuction(room: Room) -> Bool {
        room.changeState(to: .auctioning)
        return true
    }
    
    // ❌ 不允许:出价
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        print("⚠️ 拍卖还未正式开始,无法出价")
        return false
    }
    
    // ❌ 不允许:修改物品
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool {
        print("⚠️ 上拍阶段无法修改物品")
        return false
    }
    
    // ❌ 不允许:结束拍卖
    func endAuction(room: Room) -> Bool {
        print("⚠️ 拍卖还未正式开始")
        return false
    }
    
    func getStateDescription() -> String {
        return "上拍中:展示拍卖物品,倒计时后自动开始"
    }
}

关键点

  • 🎯 过渡状态:用于展示物品信息
  • ✅ 房主可以提前开始
  • ❌ 大部分操作被禁止,保证流程的严谨性

3. 拍卖中(Auctioning)⭐ 核心状态

class AuctioningState: RoomStateProtocol {
    var stateName: RoomState { return .auctioning }
    
    // ❌ 不允许:重复开始
    func startAuction(room: Room) -> Bool {
        print("⚠️ 拍卖已经在进行中")
        return false
    }
    
    // ✅ 允许:结束拍卖
    func endAuction(room: Room) -> Bool {
        room.changeState(to: .closed)
        
        if let winner = room.currentBid {
            room.addSystemMessage("🎉 成交!恭喜 (winner.bidderName) 以 ¥(winner.price) 拍得")
        } else {
            room.addSystemMessage("流拍:没有人出价")
        }
        
        return true
    }
    
    // ✅ 允许:出价(核心逻辑)
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        // 创建出价记录
        let bid = Bid(
            id: UUID().uuidString,
            price: amount,
            bidderId: user.id,
            bidderName: user.nickname,
            timestamp: Date()
        )
        
        // 记录出价
        room.addBid(bid)
        
        print("💰 (user.nickname) 出价 ¥(amount)")
        
        // 这里可以重置倒计时(简化版省略)
        // resetCountdown()
        
        return true
    }
    
    // ❌ 不允许:修改物品
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool {
        print("⚠️ 拍卖进行中,无法修改物品")
        return false
    }
    
    func getStateDescription() -> String {
        return "拍卖中:竞拍者可以出价,倒计时结束后定拍"
    }
}

关键点

  • 💰 核心业务逻辑:处理出价
  • 📊 实时更新:记录每次出价
  • ⏱️ 倒计时机制:有出价时重置(可扩展)
  • 🔄 状态转换:可以结束进入定拍

4. 定拍阶段(Closed)

class ClosedState: RoomStateProtocol {
    var stateName: RoomState { return .closed }
    
    // ✅ 允许:开启下一轮
    func startAuction(room: Room) -> Bool {
        // 重置房间状态
        room.changeState(to: .preparing)
        room.currentItem = nil
        room.currentBid = nil
        room.addSystemMessage("🔄 准备下一轮拍卖")
        return true
    }
    
    // ❌ 不允许:出价
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        print("⚠️ 拍卖已经结束,无法出价")
        return false
    }
    
    // ❌ 不允许:重复结束
    func endAuction(room: Room) -> Bool {
        print("⚠️ 拍卖已经结束")
        return false
    }
    
    // ❌ 不允许:上传物品
    func uploadItem(room: Room, item: AuctionItem, rules: AuctionRules) -> Bool {
        print("⚠️ 拍卖已结束,请开启下一轮")
        return false
    }
    
    func getStateDescription() -> String {
        return "已定拍:拍卖结束,可以开启下一轮"
    }
}

关键点

  • 🎉 展示成交结果
  • 🔄 支持循环拍卖:可以开启下一轮
  • 🔒 所有拍卖操作被锁定

与权限中心协作

设计哲学:分离关注点

┌─────────────────────────────────────┐
│         用户发起操作                 │
└──────────────┬──────────────────────┘
               ↓
┌─────────────────────────────────────┐
│      RoomManager(协调层)           │
└──────────────┬──────────────────────┘
               ↓
        ┌──────┴──────┐
        ↓             ↓
┌──────────────┐ ┌──────────────┐
│ 权限中心      │ │ 状态对象      │
│"能不能做"    │ │"怎么做"      │
└──────────────┘ └──────────────┘

协作流程

class RoomManager {
    func placeBid(user: User, room: Room, amount: Decimal) -> Result<Void, RoomError> {
        // 第一步:权限中心检查"能不能做"
        let result = permissionCenter.checkPermission(
            action: .placeBid,
            user: user,
            room: room,
            metadata: ["amount": amount]
        )
        
        guard result.isAllowed else {
            return .failure(.permissionDenied(result.deniedReason ?? "权限不足"))
        }
        
        // 第二步:状态对象执行"怎么做"
        let success = room.stateObject.placeBid(room: room, user: user, amount: amount)
        
        if success {
            return .success(())
        } else {
            return .failure(.operationFailed("出价失败"))
        }
    }
}

权限规则示例

// 权限中心:检查"能不能做"
PermissionRule(
    action: .placeBid,
    priority: 100,
    description: "只能在拍卖中状态出价"
) { context in
    guard context.room.state == .auctioning else {
        return .denied(reason: "❌ 当前不在拍卖阶段,无法出价")
    }
    return .allowed
}

PermissionRule(
    action: .placeBid,
    priority: 90,
    description: "拍卖人不能给自己出价"
) { context in
    if context.user.role == .auctioneer,
       context.user.id == context.room.currentItem?.auctioneerId {
        return .denied(reason: "❌ 您是拍卖人,不能对自己的物品出价")
    }
    return .allowed
}

为什么要分离?

如果不分离

// ❌ 反例:状态和权限混在一起
class AuctioningState {
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        // 权限判断
        if user.role == .viewer {
            return false
        }
        if user.role == .auctioneer && user.id == auctioneer.id {
            return false
        }
        if amount < currentPrice + increment {
            return false
        }
        
        // 业务逻辑
        room.addBid(...)
        return true
    }
}

分离后

// ✅ 状态对象:只关心业务逻辑
class AuctioningState {
    func placeBid(room: Room, user: User, amount: Decimal) -> Bool {
        room.addBid(...)  // 纯粹的业务逻辑
        return true
    }
}

// ✅ 权限中心:只关心权限验证
PermissionCenter.check(.placeBid, user, room)

优势

  1. 单一职责:状态对象不关心权限
  2. 易于扩展:新增权限规则不影响状态
  3. 易于测试:可以独立测试权限和状态
  4. 灵活配置:权限规则可以动态调整

实际应用场景

场景1:完整拍卖流程

// 1. 创建房间(自动进入准备阶段)
let room = Room(name: "今晚靓号专场", owner: host)
print("房间状态:(room.state.displayName)") // 准备中

// 2. 拍卖人上传物品
let item = AuctionItem(name: "手机号 13888888888", ...)
room.stateObject.uploadItem(room: room, item: item, rules: rules)
// ✅ 准备阶段允许上传物品

// 3. 房主开始拍卖
room.stateObject.startAuction(room: room)
// 状态转换:准备 → 上拍
print("房间状态:(room.state.displayName)") // 上拍中

// 4. 3秒后自动进入拍卖中
// 状态转换:上拍 → 拍卖中
print("房间状态:(room.state.displayName)") // 拍卖中

// 5. 竞拍者出价
room.stateObject.placeBid(room: room, user: bidder1, amount: 120)
// ✅ 拍卖中状态允许出价
print("当前价格:¥(room.currentPrice)") // ¥120

room.stateObject.placeBid(room: room, user: bidder2, amount: 150)
print("当前价格:¥(room.currentPrice)") // ¥150

// 6. 房主结束拍卖
room.stateObject.endAuction(room: room)
// 状态转换:拍卖中 → 定拍
print("房间状态:(room.state.displayName)") // 已定拍
print("成交:(room.currentLeader) - ¥(room.currentPrice)")

// 7. 开启下一轮
room.stateObject.startAuction(room: room)
// 状态转换:定拍 → 准备
print("房间状态:(room.state.displayName)") // 准备中

场景2:错误的操作被拒绝

// 尝试在准备阶段出价
let room = Room(...)
room.stateObject.placeBid(room: room, user: bidder, amount: 200)
// ❌ 输出:"拍卖还未开始,无法出价"
// 返回:false

// 尝试在拍卖中上传物品
room.stateObject.startAuction(room: room)  // 进入拍卖中
room.stateObject.uploadItem(room: room, item: item, rules: rules)
// ❌ 输出:"拍卖进行中,无法修改物品"
// 返回:false

// 尝试在定拍后出价
room.stateObject.endAuction(room: room)  // 进入定拍
room.stateObject.placeBid(room: room, user: bidder, amount: 300)
// ❌ 输出:"拍卖已经结束,无法出价"
// 返回:false

场景3:状态转换的严格性

let room = Room(...)

// ✅ 正确的转换
room.state  // .preparing
room.stateObject.startAuction(room: room)
room.state  // .listing → .auctioning

// ❌ 不允许跳过状态
room.state  // .preparing
room.stateObject.endAuction(room: room)
// 输出:"拍卖还未开始"
// 状态不变,仍然是 .preparing

优势与挑战

✅ 优势

1. 代码组织清晰

对比

传统方式(500行的switch):

func handleOperation() {
    switch currentState {
    case .preparing:
        // 100行代码
    case .listing:
        // 100行代码
    case .auctioning:
        // 200行代码
    case .closed:
        // 100行代码
    }
}

状态模式(每个文件<100行):

PreparingState.swift    // 80行
ListingState.swift      // 60行
AuctioningState.swift   // 100行
ClosedState.swift       // 60行

2. 易于维护

修改"拍卖中"的逻辑:

  • ❌ 传统方式:在500行代码中找到对应的case,小心翼翼地修改
  • ✅ 状态模式:直接打开AuctioningState.swift,放心修改

3. 符合开闭原则

新增"暂停"状态:

  • ❌ 传统方式:修改所有的switch语句,增加新的case
  • ✅ 状态模式:创建PausedState.swift,不修改现有代码

4. 便于测试

// 可以单独测试某个状态
func testAuctioningState() {
    let state = AuctioningState()
    let room = MockRoom()
    let result = state.placeBid(room: room, user: mockUser, amount: 100)
    XCTAssertTrue(result)
}

5. 团队协作友好

多人开发时:

  • 小明负责 PreparingState
  • 小红负责 AuctioningState
  • 小刚负责 ClosedState

互不干扰,Git冲突少。

⚠️ 挑战

1. 类的数量增加

  • 4个状态 = 4个类文件
  • 如果有10个状态,就需要10个文件

应对:合理的文件组织和命名规范

2. 状态转换的复杂性

需要仔细设计状态转换图,避免:

  • 死锁状态
  • 循环转换
  • 无法到达的状态

应对

  • 绘制状态图
  • 编写状态转换测试
  • 文档化转换规则

3. 状态间的数据共享

状态对象是无状态的,数据存储在Room对象中:

class Room {
    var stateObject: RoomStateProtocol  // 当前状态对象
    var currentItem: AuctionItem?       // 状态间共享的数据
    var currentBid: Bid?                // 状态间共享的数据
}

应对

  • 明确哪些数据属于上下文(Room)
  • 哪些数据属于状态对象

4. 调试可能更困难

调用链变长:

ViewController → RoomManager → PermissionCenter → StateObject

应对

  • 添加详细的日志
  • 使用断点调试
  • 编写单元测试

最佳实践

1. 状态对象应该是无状态的

// ❌ 错误:状态对象持有数据
class AuctioningState {
    var currentPrice: Decimal = 0  // 不应该在这里
    var bidHistory: [Bid] = []     // 不应该在这里
}

// ✅ 正确:数据存储在上下文中
class Room {
    var currentPrice: Decimal
    var bidHistory: [Bid]
    var stateObject: RoomStateProtocol
}

2. 使用工厂方法创建状态

class Room {
    func changeState(to newState: RoomState) {
        self.state = newState
        
        // 工厂方法
        switch newState {
        case .preparing:
            self.stateObject = PreparingState()
        case .listing:
            self.stateObject = ListingState()
        case .auctioning:
            self.stateObject = AuctioningState()
        case .closed:
            self.stateObject = ClosedState()
        }
        
        addSystemMessage("房间状态变更为:(newState.displayName)")
    }
}

3. 记录状态转换日志

func changeState(to newState: RoomState) {
    let oldState = self.state
    self.state = newState
    
    // 记录状态转换
    print("🔄 状态转换:(oldState.displayName) → (newState.displayName)")
    
    // 可以添加到数据库或分析系统
    Analytics.trackStateChange(from: oldState, to: newState)
}

4. 验证状态转换的合法性

func changeState(to newState: RoomState) {
    // 验证转换是否合法
    guard isValidTransition(from: self.state, to: newState) else {
        print("⚠️ 非法的状态转换:(self.state) → (newState)")
        return
    }
    
    // 执行转换
    self.state = newState
    self.stateObject = createState(newState)
}

private func isValidTransition(from: RoomState, to: RoomState) -> Bool {
    let validTransitions: [RoomState: [RoomState]] = [
        .preparing: [.listing],
        .listing: [.auctioning],
        .auctioning: [.closed],
        .closed: [.preparing]
    ]
    
    return validTransitions[from]?.contains(to) ?? false
}

5. 提供状态查询接口

extension Room {
    var canStartAuction: Bool {
        return stateObject.startAuction(room: self)
    }
    
    var canPlaceBid: Bool {
        return state == .auctioning
    }
    
    var canUploadItem: Bool {
        return state == .preparing
    }
}

// 使用
if room.canPlaceBid {
    room.stateObject.placeBid(...)
}

6. 编写完整的单元测试

class StatePatternTests: XCTestCase {
    func testStateTransitions() {
        let room = Room(...)
        
        // 测试初始状态
        XCTAssertEqual(room.state, .preparing)
        
        // 测试状态转换
        room.stateObject.startAuction(room: room)
        XCTAssertEqual(room.state, .listing)
        
        // 等待自动转换
        wait(for: 3)
        XCTAssertEqual(room.state, .auctioning)
    }
    
    func testInvalidOperations() {
        let room = Room(...)
        
        // 在准备阶段不能出价
        let result = room.stateObject.placeBid(...)
        XCTAssertFalse(result)
    }
}

总结

何时使用状态模式

适合使用的场景

  1. 对象行为随状态改变而改变
  2. 有明确的状态转换规则
  3. 状态相关的代码较多
  4. 需要避免大量的条件判断

不适合使用的场景

  1. 状态很少(2-3个)
  2. 状态间没有明确的转换规则
  3. 状态逻辑非常简单
  4. 性能要求极高的场景

状态模式的价值

在拍拍房项目中,状态模式:

  1. 将复杂的业务流程结构化
    • 4个状态,4个类,清晰明了
    • 每个状态独立,互不干扰
  1. 提高代码质量
    • 避免了数百行的switch语句
    • 符合单一职责原则
    • 符合开闭原则
  1. 增强可维护性
    • 修改某个状态不影响其他状态
    • 新增状态只需添加新类
    • 状态转换一目了然
  1. 改善团队协作
    • 不同开发者可以独立开发不同状态
    • 减少Git冲突
    • 代码审查更容易
  1. 与权限中心完美配合
    • 状态负责"怎么做"
    • 权限负责"能不能做"
    • 职责清晰,耦合度低

最后的建议

  1. 不要过度设计:如果只有2-3个简单状态,可能不需要状态模式
  2. 绘制状态图:在实现之前先画出状态转换图
  3. 编写测试:为每个状态编写单元测试
  4. 文档化:记录每个状态的职责和转换规则
  5. 逐步重构:可以先用简单方式实现,再重构为状态模式

参考资源

设计模式相关

  • 《设计模式:可复用面向对象软件的基础》- GoF
  • 《Head First 设计模式》

本项目相关

#3 Creating Shapes in SwiftUI

作者 Neo_Arsaka
2025年11月29日 13:44

示例程序

struct ShapesBootcamp: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 4)
            .stroke(
                Color.purple,
                style: StrokeStyle(lineWidth: 4, dash: [10, 5])
            )
            .frame(width: 200, height: 100)
    }
}

形状类型

类型 初始化 几何描述
Circle() 无参 外接最小圆
Ellipse() 无参 外接椭圆
Capsule(style:) .circular / .continuous 两端半圆
Rectangle() 无参 无圆角
RoundedRectangle(cornerRadius:style:) 半径 + 风格 四角等半径

所有形状默认撑满父视图提案尺寸;使用 .frame() 可强制固定宽高。

视觉修饰符

修饰符 功能 示例 备注
.fill(_:) 内部填充 .fill(Color.blue) 支持纯色、渐变
.stroke(_:lineWidth:) 等宽描边 .stroke(.red, lineWidth: 2) 默认线帽 butt
.stroke(_:style:) 高级描边 .stroke(.orange, style: StrokeStyle(...)) 虚线、线帽、线连接
.trim(from:to:) 路径裁剪 .trim(from: 0.2, to: 0.8) 0–1 比例
.frame(width:height:alignment:) 固定尺寸 .frame(200, 100) 形状无固有尺寸
.scale(_:anchor:) 缩放 .scale(1.2) 锚点默认 center
.rotation(_:anchor:) 旋转 .rotation(.degrees(45)) 同上
.offset(x:y:) 平移 .offset(x: 10) 仅视觉偏移
.opacity(_:) 透明度 .opacity(0.5) 0–1
.blendMode(_:) 混合模式 .blendMode(.multiply) 需同级 ZStack
.mask(_:) 遮罩 .mask(Circle()) 支持任意 View
.shadow(color:radius:x:y:) 阴影 .shadow(.black, 4, x: 2, y: 2) 先阴影后形状
.accessibilityHidden(true) 隐藏朗读 见上 纯装饰时推荐

任务速查表

需求 片段
圆角按钮背景 RoundedRectangle(cornerRadius: 12).fill(.accent)
环形进度 Circle().trim(from: 0, to: progress).stroke(.blue, lineWidth: 4)
虚线边框 Rectangle().stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))
胶囊标签 Capsule().fill(Color.gray.opacity(0.2))

性能与可访问性

  1. 矢量路径自动适配 @2x/@3x,无位图失真。
  2. 支持动态颜色与「降低透明度」辅助选项。
  3. 动画复杂时启用 .drawingGroup() 以 Metal 合成,降低 CPU 负担。
  4. 纯装饰形状请附加 .accessibilityHidden(true),避免 VoiceOver 读出「图像」。

#2 Adding Text in SwiftUI

作者 Neo_Arsaka
2025年11月29日 13:13

示例

struct TextBootcampView: View {
    var body: some View {
        Text("Hello, World!".capitalized)   // 格式化字符串
            .multilineTextAlignment(.leading)
            .foregroundColor(.red)
            .frame(width: 200, height: 100, alignment: .leading)
            .minimumScaleFactor(0.1)        // 极限压缩
    }
}

修饰符行为

修饰符 作用 备注 / 坑
.capitalized 先「单词首字母大写」再显示 这是 String 的 Foundation 方法,不是 Text 的修饰符;对中文无效果
.font(.body) 系统动态字体「正文」级别 会随用户「设置-显示与文字大小」变化,无障碍友好
.fontWeight(.semibold) / .bold() 字重 两者可叠加,后写的覆盖前面的
.underline(true, color: .red) 下划线 + 自定义颜色 false 可取消;颜色缺省用 foregroundColor
.italic() 斜体 只对支持斜体的字体有效;中文一般无斜体轮廓
.strikethrough(true, color: .green) 删除线 与 underline 可同时存在
.font(.system(size:24, weight:.semibold, design:.default)) 完全自定义字体 不会响应动态类型,除非自己再包 UIFontMetrics;苹果官方推荐优先用 Font 语义化 API
.baselineOffset(50) 基线偏移 正值上移,负值下移;可做「上标/下标」效果,但别用于整行,会炸行高
.kerning(1) 字符间距 对中文同样生效;负值会让字贴得更紧
.multilineTextAlignment(.leading) 多行文字水平对齐 只在「宽度被限制且文字折行」时生效
.foregroundColor(.red) 文字颜色 iOS 17 起新增 foregroundStyle 支持渐变/材质,旧项目注意版本
.frame(width:200, height:100, alignment:.leading) 给 Text 套固定尺寸 Text 默认是「尺寸自适应」;一旦加 frame,多余文字会被截断除非搭配 minimumScaleFactor
.minimumScaleFactor(0.1) 超长时等比缩小 范围 0.01–1.0;与 lineLimit(nil) 配合可实现「先缩再放」效果

#1 How to use Xcode in SwiftUI project

作者 Neo_Arsaka
2025年11月29日 12:59

Bundle Identifier

在 Xcode 中,Bundle Identifier(包标识符) 是一个唯一标识你 App 的字符串,它在整个 Apple 生态系统中用于区分你的应用

截屏2025-11-29 12.49.26.png

注意事项

  • 必须唯一:Bundle ID 在 Apple 生态系统中必须唯一,不能与其他已上架或未上架的 App 冲突
  • 区分大小写:虽然系统不区分大小写,但建议保持一致
  • 不可更改:一旦上传到 App Store Connect 或使用某些功能(如推送通知、iCloud),Bundle ID 就不能更改
  • 与 App ID 对应:在 Apple Developer 后台,Bundle ID 对应一个 App ID,用于配置证书、推送、iCloud 等功能

程序入口

@main 标识标明这是程序的入口

//
//  SwiftfulThinkingBootcampApp.swift
//  SwiftfulThinkingBootcamp
//
//  Created by Lancoff Allen on 2025/10/23.
//

import SwiftUI

@main
struct SwiftfulThinkingBootcampApp: App {
    var body: some Scene {
        WindowGroup {
//            ContentView()
            AppStorageBootcamp()
        }
    }
}

程序设置界面

如果点击左侧 Navigator 中的第一级目录(SwiftfulThinkingBootcamp),就会进入程序信息设置

其中 Identity -> DisplayName 就是程序显示给用户的名称

截屏2025-11-29 12.55.06.png

昨天 — 2025年11月28日掘金 iOS

flutter 集成flutter_Boost

2025年11月28日 17:22

flutter_Boots 是咸鱼开源的三方框架,主要是为原生和flutter之间混合跳转提供的解决方案,下面说一下集成flutter_Boots的步骤和如何在项目中使用flutter_Boots。

  1. 创建原生工程和flutter module

    1. 使用xcode创建iOS app原生工程,这个比较简单,这里面就不去贴图了。
    2. 创建flutter module,执行命令 flutter create -t module my_flutter_module。
    3. 这样在本地就把iOS工程和flutter module创建好了,如下图: image.png
  2. flutter安装flutter_Boots依赖

    1. 需要注意的是,flutter_boost的高版本需要使用git这种方式去安装依赖。
    2. 安装截图配置依赖,然后执行命令 flutter pub get按钮依赖。

    image.png

  3. ios 配置pod

    1. cd my_ios_app
    2. pod init
    3. 修改podfile文件
    4. pod install
    # Uncomment the next line to define a global platform for your project
    platform :ios, '13.0'
    
    flutter_application_path = '../my_flutter_module'
    load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
    
    target 'my_ios_app' do
      # Comment the next line if you don't want to use dynamic frameworks
      use_frameworks!
    
      install_all_flutter_pods(flutter_application_path)
    
      # Pods for my_ios_app
    
    end
    
    post_install do |installer|
      flutter_post_install(installer) if defined?(flutter_post_install)
    end
    
  4. flutter 编写flutter_boost集成代码

    1. 导入flutter_boost

      import 'package:flutter_boost/flutter_boost.dart';
      
    2. 创建CustomFlutterBinding

      class CustomFlutterBinding extends WidgetsFlutterBinding
          with BoostFlutterBinding {}
      
    3. 测试页面

      class DefaultPage extends StatelessWidget {
        const DefaultPage({super.key});
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('Flutter Boost')),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/one',
                          arguments: {'msg': 'hello from default page 1'});
                    },
                    child: const Text('go to page one'),
                  ),
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/two',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page two'),
                  ),
                  ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/home',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page native home'),
                  )
                ],
              ),
            ),
          );
        }
      }
      
      class OnePage extends StatelessWidget {
        const OnePage({super.key, required this.pramas});
        final Map pramas;
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('page one')),
            body: Text('page one, 参数: ${pramas['msg']}'),
          );
        }
      }
      
      class TwoPage extends StatelessWidget {
        const TwoPage({super.key, required this.pramas});
        final Map pramas;
        @override
        Widget build(BuildContext context) {
          return Scaffold(
            appBar: AppBar(title: const Text('page two')),
            body: Text('page two, 参数: ${pramas['msg']}'),
          );
        }
      }
      
    4. 编写widget和路由代码

      void main() {
        CustomFlutterBinding();
        runApp(const MyApp());
      }
      
      class MyApp extends StatefulWidget {
        const MyApp({super.key});
      
        @override
        State<StatefulWidget> createState() => _MyAppState();
      }
      
      class _MyAppState extends State<MyApp> {
        @override
        Widget build(BuildContext context) {
          return FlutterBoostApp(routeFactory);
        }
      
        Widget appBuilder(Widget home) {
          return MaterialApp(
            home: home,
            debugShowCheckedModeBanner: true,
            builder: (_, __) {
              return home;
            },
          );
        }
      }
      
      Route<dynamic>? routeFactory(
          RouteSettings settings, bool isContainerPage, String? uniqueId) {
        final pramas = (settings.arguments as Map?) ?? {};
        switch (settings.name) {
          case '/':
            return MaterialPageRoute(
                settings: settings, builder: (_) => const DefaultPage());
          case '/one':
            return MaterialPageRoute(
                settings: settings, builder: (_) => OnePage(pramas: pramas));
          case '/two':
            return MaterialPageRoute(
                settings: settings, builder: (_) => TwoPage(pramas: pramas));
          default:
            return null;
        }
      }
      

      flutter端代码集成完毕。

  5. iOS端代码集成

    1. 先创建一个BoostDelegate继承FlutterBoostDelegate,里面主要的逻辑就是实现push原生、push flutter、pop的方法.

      import Foundation
      import flutter_boost
      
      class BoostDelegate: NSObject, FlutterBoostDelegate {
          
          //push导航栏
          var navigationController: UINavigationController?
          
          //记录返回flutter侧返回结果列表
          var resultTable: Dictionary<String, ([AnyHashable: Any]?) -> Void> = [:]
          
          func pushNativeRoute(_ pageName: String!, arguments: [AnyHashable : Any]!) {
              let isPresent = arguments["isPresent"] as? Bool ?? false
              let isAnimated = arguments["isAnimated"] as? Bool ?? true
              var targetViewController = UIViewController()
              if pageName == "/home" {
                  targetViewController = HomeViewController()
              }
              if isPresent {
                  navigationController?.present(targetViewController, animated: isAnimated)
              } else {
                  navigationController?.pushViewController(targetViewController, animated: isAnimated)
              }
          }
          
          func pushFlutterRoute(_ options: FlutterBoostRouteOptions!) {
              let vc: FBFlutterViewContainer = FBFlutterViewContainer()
              vc.setName(options.pageName,
                         uniqueId:options.uniqueId,
                         params: options.arguments,
                         opaque: options.opaque)
              let isPresent = options.arguments["isPresent"] as? Bool ?? false
              let isAnimated = options.arguments["isAnimated"] as? Bool ?? true
              
              //对这个页面设置结果
              resultTable[options.pageName] = options.onPageFinished
              
              if (isPresent || !options.opaque) {
                  navigationController?.present(vc, animated: isAnimated)
              } else {
                  navigationController?.pushViewController(vc, animated: isAnimated)
              }
          }
          
          func popRoute(_ options: FlutterBoostRouteOptions!) {
              //如果当前被present的vc是container,那么就执行dismiss逻辑
              if let vc = self.navigationController?.presentedViewController as? FBFlutterViewContainer,vc.uniqueIDString() == options.uniqueId{
                  
                  //这里分为两种情况,由于UIModalPresentationOverFullScreen下,生命周期显示会有问题
                  //所以需要手动调用的场景,从而使下面底部的vc调用viewAppear相关逻辑
                  if vc.modalPresentationStyle == .overFullScreen {
                      
                      //这里手动beginAppearanceTransition触发页面生命周期
                      self.navigationController?.topViewController?.beginAppearanceTransition(true, animated: false)
                      
                      vc.dismiss(animated: true) {
                          self.navigationController?.topViewController?.endAppearanceTransition()
                      }
                  }else{
                      //正常场景,直接dismiss
                      vc.dismiss(animated: true, completion: nil)
                  }
              }else{
                  self.navigationController?.popViewController(animated: true)
              }
              //否则直接执行pop逻辑
              //这里在pop的时候将参数带出,并且从结果表中移除
              if let onPageFinshed = resultTable[options.pageName] {
                  onPageFinshed(options.arguments)
                  resultTable.removeValue(forKey: options.pageName)
              }
          }
      }
      
  6. 修改Appdelegate文件

     var boostDelegate = BoostDelegate() 
        
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
            // Override point for customization after application launch.
            FlutterBoost.instance().setup(application, delegate: boostDelegate, callback: { engine in
            })
            return true
        }
    
  7. 添加跳转交互

    1. 跳转flutter

       if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
                  appDelegate.boostDelegate.navigationController = self.navigationController
              }
              let ops = FlutterBoostRouteOptions()
              ops.pageName = "/"
              ops.arguments = ["msg":"app"]
              FlutterBoost.instance().open(ops)
      
    2. 跳转原生

       ElevatedButton(
                    onPressed: () {
                      BoostNavigator.instance.push('/home',
                          arguments: {'msg': 'hello from default page 2'});
                    },
                    child: const Text('go to page native home'),
                  )
      

通过以上的集成步骤和代码编写,我们就可以流畅的在flutter和原生之间互相跳转了。

政务App如何真正成为便民好帮手?

作者 FinClip
2025年11月28日 15:32

你是否曾在微信、支付宝、各个政务APP之间反复切换,只为办理一项简单的业务?是否曾因不同平台需要重复注册登录而感到困扰?为何费心费力推出的政务APP,有的让群众真正享受到了“高效办成一件事”的便利,有的却给群众带来了困惑?

项目背景

政务APP作为“互联网 + 政务服务”的核心载体,已然成为提升政府治理能力与服务水平的关键手段。随着《整治形式主义为基层减负若干规定》与《政务数据共享条例》的相继颁布,政务数据整合共享迎来了政策机遇期。然而,政务APP在发展过程中仍面临多重挑战:

重复建设:服务应用在多个App重复开发,标准不一,难以统一管理;

入口分散:服务应用散落各处,缺乏统一入口,导致用户体验碎片化;

更新迟缓:应用开发发布流程繁琐,无法快速响应政策和用户需求; 

集成困难:内部系统标准各异,对接难度大,且数据敏感,安全要求高;

运维复杂:应用缺乏统一治理,各部门各自为政,运维效率和难度增加;

解决方案:携手FinClip,打造全省一体化数据平台

在此背景下,某省单位携手超级应用智能平台FinClip,打造全省一体化数据基础平台,最终形成了定位清晰、协同发展的三大服务入口,全面覆盖便民服务、企业服务与协同办公等场景。

图片

►【便民服务】统一入口,打造核心政务阵地

作为面向民众的统一服务入口,该平台全面整合社保公积金、交通出行、医疗健康、办事服务等核心政务功能,如:育儿补贴、文旅休闲、农林牧渔、民政婚育等,成为民众办理事务的核心平台。

同时,通过构建统一用户体系,实现一次登录、全网通办,有效提升用户服务体验。

►【企业服务】政策服务一站通,精准赋能企业发展

该入口聚焦企业全生命周期服务,整合“政策”与“办事”两大核心板块。

政策板块:汇聚“即申即享”惠企政策与热点资讯,推动政策精准直达、免申即享,助力企业“零跑腿、快兑现”。 

服务板块:集成“高效办成一件事”主题服务,覆盖开办企业、水电气报装、纳税缴费、融资人才等高频事项,实现“找政府、找资金、找人才”一键直达。

►【协同办公】构建政务工作平台,协同业务均在线

FinClip超级应用智能平台, 提供了统一的开发标准与开放架构,降低内部系统对接门槛。

组织在线:支持全程百万用户同事在线、可快速找人、找组织,支持千人千面的通讯录权限,保护隐私安全; 

协同在线:工作通知、待办、消息、日程、会议等关键工作一目了然; 

业务在线:工作台帮助用户整合工作、聚焦业务、满足多种办公场景; 

沟通在线:支持政务工作人员进行安全、可靠的实时在线交流沟通。

图片

技术赋能:高效、降本、自主可控

► 流程化/低代码开发,大幅提升开发效率

利用FinClip IDE的流程化/低代码开发能力,政务流程类应用的开发实现可视化搭建、组件化配置。开发人员可通过拖拽方式快速构建业务流程,后端服务通过标准化接口快速对接。 

实施效果:政务流程类应用开发周期缩短30%,业务需求响应速度显著提升。

► 性能优化成效显著,用户体验大幅提升

通过集成FinClip SDK,政务办事、内部办公两端应用在运行小程序及H5类应用时的性能得到显著优化:应用打开白屏现象得到有效控制,等待时间降低25%;界面加载速度提升20%。

► 跨端兼容,降本增效

FinClip的小程序特性,让应用只需一次开发,便能无缝运行在iOS、Android、鸿蒙,以及各类信创终端上。这意味着政府部门无需为不同的操作系统重复投入研发资源,运营成本能大幅降低50%以上,大幅提升了研发效率和资源利用率。

图片

► 安全可控,信创适配

作为国内首批完成信创全栈适配的小程序平台,FinClip从底层架构上满足自主可控的严苛要求。全面支持鲲鹏、飞腾等国产CPU,兼容统信UOS、麒麟等国产操作系统,并采用国密算法保障数据传输,为政务数据筑起一道坚不可摧的安全堡垒。

图片

实施成效:全省协同效率显著提升

目前,全省一体化平台,已成为省单位移动端服务的核心载体,有效驱动了服务创新加速,为便民、利民政务服务注入了持续动能。

提升用户活跃与留存:通过场景融合与服务整合,月活跃用户超千万,小程序用户数环比增长20%,用户满意度和粘性显著提升。

增强业务敏捷:业务需求平均上线周期缩短70%以上,政策响应速度快人一步,市场竞争力大幅增强。

降低运营成本:生态引入成本降低60%-80%,现有小程序生态迁移成本近乎为零,资源利用效率显著提升。 

保障安全合规:建立完善的数据安全防护体系,实现业务创新与风险控制的平衡,为可持续发展奠定基础。

该省政务平台的成功实践,是FinClip在政务领域深度赋能的标杆案例。未来,FinClip将继续携手各级政府,依托其云原生、中台化、组件化的技术架构,共同推进数字政府建设着眼于群众办事需求,以“高效办成一件事”为牵引,让政务服务更高效、更便捷。

📩 联系我们:FinClip官网免费注册体验或者咨询。

Flutter 图纸标注功能的实现:踩坑与架构设计

作者 明君87997
2025年11月28日 11:20

写在前面

最近在做一个工程验收的项目,有个需求是要在 CAD 图纸上标注问题点。一开始觉得挺简单,不就是显示个图片,点一下加个 Marker 吗?真动手做了才发现,这里面的坑多到怀疑人生。

比如说:

  • 工地现场网络差到爆,必须完全离线
  • 图纸动辄几千像素,加载和交互都卡
  • 业务逻辑一堆,担心后面没法维护
  • 各种坐标系转来转去,脑壳疼

折腾了两周,终于把这个东西搞定了。整个过程中踩了不少坑,也积累了一些经验,所以写篇文章记录一下,顺便分享给有类似需求的朋友。

整体思路

搞这个东西之前,我先理了理需求,发现核心就是:在一张离线图纸上,支持用户点击标注,还得支持区域限制(不能乱点)

听起来简单,但要做好,必须解决几个问题:

  1. **怎么让代码不和业务绑死?**毕竟这个功能不止一个地方用
  2. **怎么管理状态?**标记点、多边形、图纸这些东西状态管理一团乱
  3. **怎么保证性能?**大图加载、高频交互都得优化

想来想去,决定按这个思路来:

CustomMapWidget (视图组件)
     ↓
CustomMapController (控制器,处理逻辑)
     ↓
CustomMapState (状态管理,响应式更新)
     ↓
MapDataSource (抽象接口,业务自己实现)

简单说就是:视图负责展示,控制器负责协调,状态负责响应式更新,业务逻辑通过接口注入

这样的好处是,核心框架和具体业务完全解耦,换个场景只需要实现不同的 DataSource 就行。

关键设计:业务抽象层

这个是整个架构的核心。我定义了一个抽象接口 MapDataSource

abstract class MapDataSource {
  // 加载图纸(可能从本地、可能从服务器)
  Future<MapSourceConfig> loadMapDrawingResource(CrsSimple crs);
  
  // 创建一个标记点(业务自己决定样式)
  Marker addMarker(LatLng point, {String? number});
  
  // 批量加载已有的标记点
  List<Marker> loadMarkers(List<Point<double>>? latLngList, CrsSimple crs);
  
  // 加载多边形(比如房间轮廓、限制区域等)
  dynamic loadPolygons(CrsSimple crs);
}

为什么要这么设计?因为每个业务场景的需求都不一样

  • 验收系统可能需要红色图钉标记问题点
  • 测量系统可能需要数字标记测量点
  • 巡检系统可能需要设备图标

把这些差异抽象出来,让业务层自己实现,核心框架就不用改了。

具体实现

一、状态管理怎么搞

一开始用 Provider 写的,后来发现状态更新太频繁,性能不行。改成 GetX 之后丝滑多了。

class CustomMapState {
  // Flutter Map 的控制器,用来控制缩放、移动等
  MapController mapController = MapController();
  
  // 坐标系统(这个是关键,后面会讲为什么用 CrsSimple)
  final CrsSimple crs = const CrsSimple();
  
  // 配置信息(响应式的,方便动态修改)
  final Rx<MapDrawingConfig> config = MapDrawingConfig().obs;
  
  // 当前使用的图纸
  final Rx<MapSourceConfig?> currentMapSource = Rx<MapSourceConfig?>(null);
  
  // 地图边界(用来做自适应显示)
  LatLngBounds? mapBounds;
  
  // 标记点列表(Rx开头的都是响应式的,改了自动刷新UI)
  final RxList<Marker> markers = <Marker>[].obs;
  
  // 多边形列表(比如房间轮廓)
  final RxList<Polygon> polygons = <Polygon>[].obs;
  
  // 当前正在绘制的点
  final RxList<LatLng> currentDrawingPoints = <LatLng>[].obs;
  
  // 有效区域(用户只能在这个范围内标注)
  List<LatLng> houseLatLngList = [];
}

这里有几个关键点:

  • Rx 系列:GetX 的响应式类型,状态改了UI自动更新,不用手动 setState
  • CrsSimple:简单笛卡尔坐标系,因为图纸用的是像素坐标,不是真的经纬度
  • 多图层分离:标记点、多边形、绘制点分开管理,互不影响

二、控制器的核心逻辑

控制器主要负责协调各个部分,处理用户交互。

初始化流程

_initData() async {
  state.config.value = config;
  try {
    // 调用业务层加载图纸
    var result = await dataSource.loadMapDrawingResource(state.crs);
    state.currentMapSource.value = result;
    state.mapBounds = result.defaultSource.bounds;
  } catch (e) {
    // 这里可能失败,比如文件不存在、网络问题等
    logDebug('加载图纸失败: $e');
  } finally {
    onMapReady(); // 不管成功失败都要走后续流程
  }
}

地图渲染完成的回调

void onMapReady() {
  if (state.isMapReady) return; // 防止重复调用(之前遇到过bug,这里加个保险)
  
  state.isMapReady = true;
  
  // 加载多边形(比如房间轮廓、限制区域等)
  var parameter = dataSource.loadPolygons(state.crs);
  if (parameter['polygonList'] != null) {
    state.polygons.value = parameter['polygonList'];
  }
  
  // 如果有历史标记点,也一起加载进来
  if (config.latLngList.isNotEmpty) {
    state.markers.value = dataSource.loadMarkers(config.latLngList, state.crs);
  }
  
  // 自适应显示整个图纸(不然可能只看到一个角)
  if (state.mapBounds != null) {
    state.mapController.fitCamera(
      CameraFit.bounds(bounds: state.mapBounds)
    );
  }
}

点击事件处理(重点)

这是最核心的逻辑,处理用户在图纸上的点击:

void addDrawingPoint(TapPosition tapPosition, LatLng latlng) {
  // 第一步:坐标转换(从地图坐标转成像素坐标)
  // 为什么要转?因为后端存的是像素坐标,前端显示用的是地图坐标
  Point<double> cp = state.crs.latLngToPoint(
    latlng, 
    state.config.value.serverMapMaxZoom
  );
  
  // 第二步:检查是否超出图纸范围
  // 之前没加这个判断,用户点到图纸外面就报错,体验很差
  if (cp.x < 0 || cp.y < 0 || 
      cp.x > currentMapSource.width ||
      cp.y > currentMapSource.height) {
    showSnackBar('超出图纸范围');
    return;
  }
  
  // 第三步:检查是否在有效区域内
  // 比如验收系统要求只能在房间内标注,不能标到墙外面去
  if (state.houseLatLngList.isNotEmpty &&
      !MapUtils.isPointInPolygon(latlng, state.houseLatLngList)) {
    showSnackBar('请将位置打在画区内');
    return;
  }
  
  // 第四步:通知业务层(让业务层保存数据)
  config.onTap?.call(cp, latlng);
  
  // 第五步:在地图上显示标记点
  addMarker(position: latlng);
}

这个函数看起来简单,但每一步都是踩坑踩出来的:

  • 坐标转换那里,之前 zoom 值没对齐,导致标记点位置偏移
  • 边界检查是测试提的bug,用户点外面会崩
  • 区域约束是产品后来加的需求,还好架构预留了扩展性

三、视图层的设计

视图层就是负责显示,用 Flutter Map 的多图层机制:

@override
Widget build(BuildContext context) {
  return GetBuilder<CustomMapController>(
    tag: tag,  // 用tag支持多实例,不然多个地图会冲突
    id: 'map', // 局部刷新用的,只刷新地图部分
    builder: (controller) {
      return FlutterMap(
        mapController: controller.state.mapController,
        options: _buildMapOptions(),
        children: [
          _buildTileLayer(),      // 底图层(图纸)
          _buildPolygonLayer(),   // 多边形层(房间轮廓)
          _buildMarkerLayer(),    // 标记点层
          ...?children,           // 预留扩展位,可以加自定义图层
        ],
      );
    },
  );
}

Flutter Map 用的是图层叠加的方式,从下往上渲染。顺序很重要,搞错了标记点就被图纸盖住了(别问我怎么知道的)。

底图层的实现

Widget _buildTileLayer() {
  return Obx(() {  // Obx 会监听里面用到的响应式变量
    final currentSource = controller.state.currentMapSource.value;
    
    // 图纸还没加载完,显示loading
    if (currentSource?.defaultSource.localPath?.isEmpty ?? true) {
      return const Center(child: CircularProgressIndicator());
    }
    
    // 加载本地图纸文件
    return OverlayImageLayer(
      overlayImages: [
        OverlayImage(
          imageProvider: FileImage(File(currentSource.defaultSource.localPath)),
          bounds: currentSource.defaultSource.bounds  // 图纸的边界
        )
      ]
    );
  });
}

这里用 OverlayImageLayer 把本地图片当成地图底图,bounds 定义了图片的坐标范围。一开始我还尝试用瓦片图的方式切片加载,后来发现图纸不大(2-3M),直接整图加载反而更简单。

四、工厂模式的应用

为了方便使用,封装了一个工厂类:

class CustomMapFactory {
  static CustomMapWidget createDefault({
    required MapDataSource dataSource,
    required MapDrawingConfig config,
    String? tag,
  }) {
    late CustomMapController controller;
    
    // 检查是否已经创建过(避免重复创建导致内存泄漏)
    if (Get.isRegistered<CustomMapController>(tag: tag)) {
      controller = Get.find<CustomMapController>(tag: tag);
    } else {
      controller = CustomMapController(
        dataSource: dataSource,
        config: config,
      );
      Get.lazyPut(() => controller, tag: tag);  // 懒加载,用的时候才创建
    }
    
    return CustomMapWidget(
      controller: controller,
      tag: tag,
    );
  }
  
  // 页面销毁时记得调用,不然内存泄漏
  static void disposeController(String tag) {
    if (Get.isRegistered<CustomMapController>(tag: tag)) {
      Get.delete<CustomMapController>(tag: tag);
    }
  }
}

使用示例

// 创建地图组件
final mapWidget = CustomMapFactory.createDefault(
  dataSource: MyDataSourceImpl(),  // 你自己的业务实现
  config: MapDrawingConfig(
    serverMapMaxZoom: 8.0,
    onTap: (pixelPoint, latlng) {
      print('点击了坐标: $pixelPoint');
    },
  ),
  tag: 'project_01',  // 用唯一标识,支持多个地图实例
);

踩坑记录

坑一:坐标系统的选择

一开始我用的是常规的地理坐标系(EPSG:3857),结果发现图纸上的坐标根本对不上。后来才明白,CAD 图纸用的是像素坐标,不是经纬度

后端存的坐标是这样的:{x: 1234, y: 5678},单位是像素。而 Flutter Map 默认用的是经纬度坐标。

解决办法是用 CrsSimple(简单笛卡尔坐标系)

// CrsSimple 可以把像素坐标当成"伪经纬度"
final CrsSimple crs = const CrsSimple();

// 地图坐标 → 像素坐标(给后端用)
Point<double> pixelPoint = crs.latLngToPoint(
  latlng, 
  serverMapMaxZoom  // zoom 级别要和后端约定好
);

// 定义图纸的边界
LatLngBounds bounds = LatLngBounds(
  LatLng(0, 0),                      // 图纸左上角
  LatLng(imageHeight, imageWidth)    // 图纸右下角
);

这里有几个坑:

  1. zoom 级别必须和后端一致,不然坐标会偏移。我们约定的是 8
  2. Y 轴方向:CrsSimple 的 Y 轴是向下的,和传统坐标系相反
  3. 小数精度:坐标转换会有浮点误差,存数据库时要注意

坑二:点在多边形内判定

产品要求用户只能在房间内标注,不能标到墙外面去。这就需要判断点是否在多边形内。

我用的是射线法(Ray Casting),原理很简单:从点向右发射一条射线,数射线和多边形边界交点的个数,奇数次就在内部,偶数次就在外部。

static bool isPointInPolygon(LatLng point, List<LatLng> polygon) {
  int intersectCount = 0;
  
  // 遍历多边形的每条边
  for (int i = 0; i < polygon.length; i++) {
    // 取当前点和下一个点(首尾相连)
    final LatLng vertB = 
      i == polygon.length - 1 ? polygon[0] : polygon[i + 1];
    
    // 检查射线是否和这条边相交
    if (_rayCastIntersect(point, polygon[i], vertB)) {
      intersectCount++;
    }
  }
  
  // 奇数次相交说明在内部
  return (intersectCount % 2) == 1;
}

static bool _rayCastIntersect(LatLng point, LatLng vertA, LatLng vertB) {
  final double aY = vertA.latitude;
  final double bY = vertB.latitude;
  final double aX = vertA.longitude;
  final double bX = vertB.longitude;
  final double pY = point.latitude;
  final double pX = point.longitude;
  
  // 优化:快速排除明显不相交的情况
  // 如果AB两个点都在P的上方/下方/左侧,肯定不相交
  if ((aY > pY && bY > pY) || 
      (aY < pY && bY < pY) || 
      (aX < pX && bX < pX)) {
    return false;
  }
  
  // 特殊情况:垂直的边
  if (aX == bX) return true;
  
  // 计算射线与边的交点X坐标(直线方程 y = mx + b)
  final double m = (aY - bY) / (aX - bX);  // 斜率
  final double b = ((aX * -1) * m) + aY;   // 截距
  final double x = (pY - b) / m;           // 交点的X坐标
  
  // 如果交点在P的右侧,说明射线和这条边相交了
  return x > pX;
}

这个算法看起来复杂,其实就是初中的直线方程 y = mx + b。第一次写的时候没考虑垂直边的情况,结果遇到矩形房间就挂了。

坑三:内存泄漏

GetX 虽然好用,但不注意的话很容易内存泄漏。尤其是在列表页,每个 item 都创建一个地图实例,来回滚动几次内存就爆了。

解决方案:

@override
void onClose() {
  if (_isDisposed) return;  // 防止重复释放
  
  super.onClose();
  
  // 释放地图控制器
  state.mapController.dispose();
  
  // 清空所有列表
  state.markers.clear();
  state.polygons.clear();
  state.currentDrawingPoints.clear();
  
  // 重置状态
  state.config.value = MapDrawingConfig();
  state.currentMapSource.value = null;
  state.isMapReady = false;
  
  _isDisposed = true;
}

页面销毁时记得调用:

@override
void dispose() {
  CustomMapFactory.disposeController('project_${projectId}');
  super.dispose();
}

数据模型设计

配置模型

class MapDrawingConfig {
  // 样式相关
  final Color defaultMarkerColor;      // 标记点颜色
  final double defaultMarkerSize;      // 标记点大小
  
  // 缩放相关(这几个参数很重要)
  final double serverMapMaxZoom;  // 后端用的zoom级别(要对齐)
  final double realMapMaxZoom;    // 前端实际最大zoom(影响流畅度)
  final double minZoom;           // 最小zoom(防止缩太小)
  
  // 交互相关
  final bool singleMarker;  // 是否单点模式(有些场景只能选一个点)
  Function(Point<double>, LatLng)? onTap;  // 点击回调
  
  // 数据相关
  List<Point<double>> latLngList; // 已有的标记点(用来回显)
}

配置项不算多,但每个都是实际用到的。一开始想做成超级灵活的配置系统,后来发现太复杂了,就简化成这样。

地图源模型

class MapSource {
  final String localPath;     // 图纸的本地路径
  final LatLngBounds bounds;  // 图纸的边界
  final double height;        // 图纸高度(像素)
  final double width;         // 图纸宽度(像素)
}

class MapSourceConfig {
  final MapSource defaultSource;  // 默认使用的图纸
  
  // 工厂方法:快速创建本地图纸配置
  factory MapSourceConfig.customLocal({
    required String customPath,
    required double height,
    required double width,
  }) { ... }
}

这个模型设计得比较简单,因为我们的需求就是加载一张本地图纸。如果你的场景需要多个图纸切换,可以扩展 availableSources 列表。


性能优化

图层懒加载

没有数据的图层直接返回空 Widget,不渲染:

Widget _buildMarkerLayer() {
  return Obx(() {
    if (controller.state.markers.isEmpty) {
      return const SizedBox.shrink();  // 空图层
    }
    return MarkerLayer(markers: controller.state.markers);
  });
}

局部刷新

用 GetBuilder 的 id 参数实现精准刷新:

update(['map']);  // 只刷新地图,不影响页面其他部分

这个太重要了,之前没加 id,每次更新都全页面刷新,卡得要命。

图片缓存

FileImage 自带缓存,不需要额外处理。但如果图纸特别大(>10M),建议在加载前先压缩一下。


使用指南

第一步:实现数据源接口

根据你的业务需求,实现 MapDataSource

class MyProjectDataSource implements MapDataSource {
  @override
  Future<MapSourceConfig> loadMapDrawingResource(CrsSimple crs) async {
    // 从服务器下载或本地读取图纸
    String localPath = await getDrawingPath();  // 你的业务逻辑
    
    return MapSourceConfig.customLocal(
      customPath: localPath,
      height: 1080,  // 图纸高度
      width: 1920,   // 图纸宽度
    );
  }
  
  @override
  Marker addMarker(LatLng point, {String? number}) {
    // 创建一个标记点(自定义样式)
    return Marker(
      point: point,
      width: 40,
      height: 40,
      child: Icon(Icons.location_pin, color: Colors.red),
    );
  }
  
  @override
  List<Marker> loadMarkers(List<Point<double>>? points, CrsSimple crs) {
    // 加载已有的标记点(比如从数据库读取)
    return points?.map((point) {
      LatLng latlng = crs.pointToLatLng(point, 8.0);
      return addMarker(latlng);
    }).toList() ?? [];
  }
  
  @override
  dynamic loadPolygons(CrsSimple crs) {
    // 加载多边形(房间轮廓、限制区域等)
    return {
      'polygonList': [...],  // 你的多边形数据
      'houseLatLngList': [...],  // 限制区域
    };
  }
}

第二步:创建地图组件

final mapWidget = CustomMapFactory.createDefault(
  dataSource: MyProjectDataSource(),
  config: MapDrawingConfig(
    serverMapMaxZoom: 8.0,
    singleMarker: false,  // 是否单点模式
    onTap: (pixelPoint, latlng) {
      // 用户点击了,这里保存坐标到数据库
      saveToDatabase(pixelPoint);
    },
  ),
  tag: 'project_${projectId}',  // 用唯一ID作为tag
);

第三步:在页面中使用

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('图纸标注')),
      body: mapWidget,
    );
  }
}

// 页面销毁时记得释放资源
@override
void dispose() {
  CustomMapFactory.disposeController('project_${projectId}');
  super.dispose();
}

几个注意事项

  1. zoom 级别要和后端对齐,不然坐标会偏
  2. tag 必须唯一,建议用项目ID或其他唯一标识
  3. 记得释放资源,不然内存泄漏
  4. 图纸路径要正确,文件不存在会报错

总结

这套架构最大的优点是解耦。核心框架不关心你的业务,只负责地图展示和交互。所有业务逻辑都通过 DataSource 接口注入,换个场景只需要写一个新的 DataSource 实现就行。

当然也有一些不足:

  • 对于特别复杂的标注需求(比如绘制曲线、多边形编辑),还需要扩展
  • 大图纸(>10M)的加载性能还有优化空间
  • 离线缓存目前还没做

不过对于大部分场景来说,已经够用了。

如果你也有类似的需求,希望这篇文章能帮到你。有问题欢迎交流!


2024年实战项目总结,代码已脱敏。

深入理解 UINavigationController:生命周期、动画优化与性能调优

2025年11月28日 10:42

在日常开发中,UINavigationController 是我们最常用的容器控制器之一。但你是否真正理解:

  • 页面 push/pop 时,两个 ViewController 的生命周期方法如何调用?
  • 为什么首次进入新页面会卡顿?
  • 如何让导航切换更丝滑?
  • 又该如何定位动画卡顿的“罪魁祸首”?

本文将从 基础生命周期 → 动画优化 → 性能检测 三个层次,带你系统掌握 UINavigationController 的核心机制,并提供可落地的 Objective-C 实践方案。


一、页面切换时的生命周期:谁先谁后?

场景 1:Push 新页面(A → B)

假设当前栈顶是 ViewControllerA,点击按钮 push 到 ViewControllerB

// ViewControllerB 首次创建
- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"B: viewDidLoad");
}

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    NSLog(@"B: viewWillAppear");
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    NSLog(@"B: viewDidAppear");
}
// ViewControllerA 被压入栈底
- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    NSLog(@"A: viewWillDisappear");
}

- (void)viewDidDisappear:(BOOL)animated {
    [super viewDidDisappear:animated];
    NSLog(@"A: viewDidDisappear");
}

调用顺序如下:

B: viewDidLoad
A: viewWillDisappear
B: viewWillAppear
A: viewDidDisappear
B: viewDidAppear

✅ 注意:viewDidLoad 仅在视图首次加载时调用一次。


场景 2:Pop 返回(B → A)

当用户点击返回或手势滑动 pop 回 A:

B: viewWillDisappear
A: viewWillAppear
B: viewDidDisappear
A: viewDidAppear

❗ 关键点:A 的 viewDidLoad 不会再次调用!
所以,若需每次进入都刷新数据,请放在 viewWillAppear: 中。


二、为什么页面切换会卡顿?常见原因

  1. viewDidLoadviewWillAppear: 中执行耗时操作

    • 网络请求、JSON 解析、数据库查询
    • 复杂 Auto Layout 计算
    • 大量子视图创建或图片解码
  2. 首次 push 时构建整个视图层级

    • 导致主线程阻塞,动画掉帧
  3. 离屏渲染(Offscreen Rendering)

    • 圆角 + 阴影 + mask 同时使用
    • 触发 GPU 额外绘制

三、优化策略:让导航切换如丝般顺滑

✅ 1. 异步加载 & 延迟初始化

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 轻量级 UI 初始化
    [self setupUI];
    
    // 耗时任务放后台
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSArray *data = [self fetchHeavyData];
        dispatch_async(dispatch_get_main_queue(), ^{
            [self reloadData:data];
        });
    });
}

⚠️ 切记:UI 更新必须回到主线程!


✅ 2. 预加载目标 ViewController(减少首次卡顿)

// 在父页面中预创建
- (DetailViewController *)cachedDetailVC {
    if (!_cachedDetailVC) {
        _cachedDetailVC = [[DetailViewController alloc] init];
        // 提前触发 loadView,构建视图层级
        UIView *temp = _cachedDetailVC.view;
        (void)temp; // 避免编译器警告
    }
    return _cachedDetailVC;
}

- (IBAction)showDetail:(id)sender {
    [self.navigationController pushViewController:self.cachedDetailVC animated:YES];
}

💡 适用于高频跳转页面(如商品详情、用户主页)。


✅ 3. 自定义转场动画(提升体验)

实现 UINavigationControllerDelegate

// MyNavigationControllerDelegate.m
- (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                   animationControllerForOperation:(UINavigationControllerOperation)operation
                                                fromViewController:(UIViewController *)fromVC
                                                  toViewController:(UIViewController *)toVC {
    if (operation == UINavigationControllerOperationPush) {
        return [[FadePushAnimator alloc] init];
    }
    return nil; // 使用默认 pop 动画
}

自定义动画器(简化版淡入):

// FadePushAnimator.m
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
    return 0.35;
}

- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *container = [transitionContext containerView];
    
    [container addSubview:toVC.view];
    toVC.view.alpha = 0.0;
    
    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromVC.view.alpha = 0.3;
        toVC.view.alpha = 1.0;
    } completion:^(BOOL finished) {
        fromVC.view.alpha = 1.0;
        [transitionContext completeTransition:!transitionContext.transitionWasCancelled];
    }];
}

🎨 自定义动画可用于品牌化设计,但务必保证流畅性。


四、如何检测性能瓶颈?实战工具链

🔧 1. 使用 Xcode Instruments

(1)Core Animation 模板

  • 运行真机,执行 push/pop
  • 观察 FPS 曲线(目标 ≥ 55)
  • 开启调试选项:
    • Color Blended Layers:红色 = 图层混合过多
    • Color Offscreen-Rendered:黄色 = 离屏渲染

(2)Time Profiler 模板

  • 定位 viewDidLoad / viewWillAppear 中的 CPU 热点
  • 检查是否在主线程做 I/O 或复杂计算

📝 2. 代码埋点测耗时

@property (nonatomic, assign) CFTimeInterval appearStartTime;

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    self.appearStartTime = CACurrentMediaTime();
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    CFTimeInterval duration = CACurrentMediaTime() - self.appearStartTime;
    NSLog(@"viewWillAppear → viewDidAppear 耗时: %.2f ms", duration * 1000);
}

若超过 16ms(1帧),就可能影响动画流畅度。


🚨 3. 启用 Main Thread Checker

Xcode 默认开启。若在子线程更新 UI,会立即 crash 并提示:

“Main Thread Checker: UI API called on a background thread”

确保所有 UI 操作都在主线程:

dispatch_async(dispatch_get_main_queue(), ^{
    self.titleLabel.text = newText;
});

五、总结:最佳实践 Checklist

项目 是否做到
viewDidLoad 只做 UI 初始化
✅ 数据加载异步化
✅ 高频页面预加载
✅ 避免离屏渲染(用贝塞尔路径切圆角)
✅ 使用 Instruments 定期检测 FPS
✅ 返回手势未被遮挡

结语

UINavigationController 看似简单,但其背后的生命周期与渲染机制直接影响用户体验。流畅的页面切换不是偶然,而是对细节的极致把控。

希望本文能帮你:

  • 理清生命周期调用顺序
  • 避开常见性能陷阱
  • 掌握一套完整的性能分析方法

真正的高手,不仅写得出功能,更调得稳帧率。

如果你有具体的卡顿案例,欢迎留言交流!


延伸阅读

Apple StoreKit 2 开发指南

作者 Lexiaoyao20
2025年11月27日 19:15

目录

  1. StoreKit 2 核心概念与优势
  2. 基础准备:产品类型与配置
  3. 核心实战 I:获取商品与购买
  4. 核心实战 II:交易验证与监听
  5. 订阅管理:状态、续期与退款
  6. 深度讲解:恢复购买 (Restore Purchases)
  7. 营销功能:折扣与优惠 (Offers)
  8. 测试指南:沙盒 (Sandbox) 与 TestFlight
  9. 最佳实践与常见坑点
  10. 总结

1. StoreKit 2 核心概念与优势

在 StoreKit 2 之前,我们进行内购开发充满了痛苦:复杂的收据验证、晦涩的 API、漏单等... StoreKit 2 利用 Swift 的现代特性(Concurrency)重构了整个框架。

核心优势

StoreKit 2 是 Apple 在 iOS 15+ / macOS 12.0+ 引入的全新内购框架,相比于旧版 StoreKit 具有以下优势:

  • 基于 Swift 并发:使用 async/await 替代回调地狱。
  • 自动交易验证:无需手动解析复杂的 Receipt 文件,系统自动处理 JWS(JSON Web Signature)验证。
  • 交易历史管理:直接通过 API 获取完整的用户购买历史,无需维护复杂的本地数据库。
  • 状态同步:跨设备同步更加顺滑,用户换个手机登录,权益自动同步。

核心概念

概念 说明
Product 商品对象,包含价格、名称、描述等信息
Transaction 交易记录,每次购买产生一个 Transaction
PurchaseResult 购买结果,包含成功、待处理、用户取消等状态
VerificationResult 验证结果,确保交易来自 Apple 服务器
Product.SubscriptionInfo 订阅信息,包含订阅组、续期信息等

流程图解

flowchart LR
    A["App 启动"] --> B["监听交易更新<br/>Transaction Updates"]
    C["用户点击购买"] --> D["获取商品<br/>Products"]
    D --> E["发起购买<br/>Purchase"]
    E --> F{支付结果}
    F -- 成功 --> G["验证交易<br/>Verify"]
    G -- 通过 --> H["发放权益<br/>Unlock Content"]
    H --> I["结束交易<br/>Finish Transaction"]
    F -- 失败/取消 --> J["处理错误 UI"]

    %% 样式定义
    classDef start fill:#E3F2FD,stroke:#2196F3,stroke-width:2px,color:#0D47A1;
    classDef action fill:#FFFFFF,stroke:#90A4AE,stroke-width:2px,color:#37474F;
    classDef decision fill:#FFF8E1,stroke:#FFC107,stroke-width:2px,color:#FF6F00;
    classDef endState fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px,color:#1B5E20;
    classDef error fill:#FFEBEE,stroke:#F44336,stroke-width:2px,color:#B71C1C;

    %% 样式应用
    class A start;
    class B,C,D,E,H action;
    class F decision;
    class I endState;
    class J error;

2. 基础准备:产品类型与配置

在编写代码前,我们首先需要了解 Apple 定义的四种商品类型:

类型 英文名 特点 典型场景
消耗型 Consumable 可重复购买,购买后即消耗 游戏金币、道具
非消耗型 Non-Consumable 一次购买,永久拥有,支持恢复购买 解锁完整版、移除广告、终身会员
自动续期订阅 Auto-Renewing Subscription 按周期扣费,自动续订 视频会员、SaaS 服务
非续期订阅 Non-Renewing Subscription 有效期固定,不自动续费 赛季通行证

环境配置

你可能以为必须先去 App Store Connect 创建商品才能写代码,其实无需这么麻烦,Xcode 提供了一个本地配置文件 (.storekit),让你在没有开发者账号、没联网的情况下也能开发。 操作步骤:

  1. Xcode -> File -> New -> File from Template... -> 搜索 StoreKit Configuration File (或者用快捷键 Command + N).
  2. 不要勾选 "Sync this file with an app in App Store Connect" (除非你已经在 App Store Connect 配置好了商品信息)。
  3. 建好后,在 Xcode 底部点 + 按钮,配置你的商品信息。
  4. 关键一步:点击 Xcode 顶部菜单 Product -> Scheme -> Edit Scheme -> Run -> Options -> StoreKit Configuration,选择你刚才创建的文件。

💡 老鸟经验:建议使用这个本地配置!它不仅能模拟购买成功,还能模拟扣费失败、退款、订阅过期等真实环境很难复现的场景。


3. 核心实战 I:获取商品与购买

我们将创建一个 StoreKitManager 类来管理所有逻辑。

3.1 获取商品信息

import StoreKit

// 定义你的商品 ID 列表
enum ProductID: String, CaseIterable {
    case proMonthly = "com.myapp.pro.monthly" // 订阅
    case removeAds = "com.myapp.remove.ads"   // 非消耗型
    case coins100 = "com.myapp.coins.100"     // 消耗型
}

@MainActor
class StoreKitManager: ObservableObject {
    @Published var products: [Product] = []
    @Published var purchasedProductIDs = Set<String>() // 已买过的 ID (非消耗/订阅)

    // 获取商品列表
    func fetchProducts() async {
        do {
            // 将 String 转换为 Set<String>
            let productIds = Set(ProductID.allCases.map { $0.rawValue })
            // 异步请求商品详情
            let fetchedProducts = try await Product.products(for: productIds)
            // 按价格排序(可选, 看实际需求)
            self.products = fetchedProducts.sorted(by: { $0.price < $1.price })

            // 加载完商品后,立即检查用户当前的购买状态
            await updateCustomerProductStatus()
        } catch {
            print("获取商品失败: \(error)")
        }
    }
}

3.2 商品信息详解

此方法主要方便调试,打印商品信息。

func displayProductInfo(_ product: Product) {
    print("━━━━━━━━━━━━━━━━━━━━━━")
    print("商品 ID: \(product.id)")
    print("名称: \(product.displayName)")
    print("描述: \(product.description)")
    print("价格: \(product.displayPrice)")  // 已格式化的价格字符串
    print("价格数值: \(product.price)")     // Decimal 类型
    print("货币代码: \(product.priceFormatStyle.currencyCode)")
    print("类型: \(product.type)")

    // 订阅专属信息
    if let subscription = product.subscription {
        print("━━━ 订阅信息 ━━━")
        print("订阅组 ID: \(subscription.subscriptionGroupID)")
        print("订阅周期: \(subscription.subscriptionPeriod)")

        // 订阅周期详解
        switch subscription.subscriptionPeriod.unit {
        case .day:
            print("周期单位: \(subscription.subscriptionPeriod.value) 天")
        case .week:
            print("周期单位: \(subscription.subscriptionPeriod.value) 周")
        case .month:
            print("周期单位: \(subscription.subscriptionPeriod.value) 月")
        case .year:
            print("周期单位: \(subscription.subscriptionPeriod.value) 年")
        @unknown default:
            break
        }

        // 介绍性优惠(新用户优惠)
        if let introOffer = subscription.introductoryOffer {
            print("新用户优惠: \(introOffer.displayPrice)")
            print("优惠类型: \(introOffer.paymentMode)")
        }
    }

    print("━━━━━━━━━━━━━━━━━━━━━━")
}

3.2 发起购买流程

StoreKit 2 的购买结果是一个枚举:success, userCancelled, pending

extension StoreKitManager {
    // 购买指定商品
    func purchase(_ product: Product) async throws {
        // 1. 发起购买请求
        let result = try await product.purchase()

        // 2. 处理结果
        switch result {
        case .success(let verification):
            // 购买成功,需要验证签名
            try await handlePurchaseVerification(verification)

        case .userCancelled:
            // 用户点击了取消
            print("User cancelled the purchase")

        case .pending:
            // 交易挂起(例如家长控制需要审批)
            print("Transaction pending")

        @unknown default:
            break
        }
    }

    // 验证与权益发放
    private func handlePurchaseVerification(_ verification: VerificationResult<Transaction>) async throws {
        switch verification {
        case .unverified(let transaction, let error):
            // 签名验证失败,不要发放权益
            print("Verification failed: \(error)")
            // 建议:结束交易,但不发货
            // 如果不 finish,这笔脏数据会每次启动 App 都发过来,卡在队列里
            await transaction.finish()

        case .verified(let transaction):
            // 验证通过
            print("Purchase verified: \(transaction.productID)")

            // 3. 发放权益(更新本地状态)
            await updateUserEntitlements(transaction)

            // 4. 重要:通知 App Store 交易已完成
            await transaction.finish()
        }
    }
}

4. 核心实战 II:交易验证与监听

StoreKit 2 有两个关键的数据源:

  1. Transaction.updates:监听实时的交易流(购买发生时、续订成功时、退款时)。
  2. Transaction.currentEntitlements:查询用户当前拥有的权益(用于恢复购买)。

4.1 监听交易更新 (Transaction Updates)

最佳实践:必须在 App 启动时立即开始监听,以处理应用在后台或未运行时发生的交易(如订阅自动续期)。

extension StoreKitManager {
    // 启动监听任务
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            // 遍历异步序列
            for await result in Transaction.updates {
                do {
                    // 收到新交易(续费、购买、恢复)
                    // 这里复用之前的验证逻辑
                    try await self.handlePurchaseVerification(result)
                } catch {
                    print("Transaction update handling failed")
                }
            }
        }
    }
}

确保它随 App 启动而运行:

// 在 App 入口处调用
@main
struct MyApp: App {
    let storeKitManager = StoreKitManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    // 开启监听
                    await storeKitManager.listenForTransactions()
                }
        }
    }
}

4.2 检查当前权益 (Entitlements)

如何判断用户是不是会员呢? StoreKit 2,你不需要自己存本地数据库,直接调用 Transaction.currentEntitlements 来查询,它只返回当前有效的权益(过期的、退款的会自动过滤掉)。

extension StoreKitManager {
    // 更新用户权益状态
    func updateCustomerProductStatus() async {
        var purchasedIds: [String] = []

        // 遍历当前有效的权益(已自动过滤掉过期订阅、被撤销的交易)
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // 检查是否被撤销(退款)
                if transaction.revocationDate == nil {
                    purchasedIds.append(transaction.productID)
                }
            }
        }

        // 更新 UI 状态
        // self.isPro = purchasedIds.contains(ProductID.proMonthly.rawValue)
        print("User has active entitlements: \(purchasedIds)")
    }
}

5. 订阅管理:状态、续期与退款

订阅比一次性购买复杂,因为需要处理过期、宽限期等状态。

5.1 获取订阅详细

extension StoreKitManager {
    func checkSubscriptionStatus() async {
        // 假设我们只关心 proMonthly 这个组的订阅状态
        guard let product = products.first(where: { $0.id == ProductID.proMonthly.rawValue }) else { return }

        guard let subscriptionInfo = product.subscription else { return }

        do {
            // 获取该订阅组的状态
            let statuses = try await subscriptionInfo.status

            for status in statuses {
                switch status.state {
                case .subscribed:
                    print("用户处于订阅期")
                case .expired:
                    print("订阅已过期")
                case .inGracePeriod:
                    print("处于宽限期(扣费失败但Apple暂未关停),应视为已订阅")
                case .revoked:
                    print("订阅被撤销(退款)")
                case .inBillingRetryPeriod:
                    print("扣费重试中,通常应暂停服务")
                default:
                    break
                }

                // 获取续订信息
                if let renewalInfo = try? verify(status.renewalInfo) {
                    print("自动续订状态: \(renewalInfo.willAutoRenew)")
                    print("自动续订时间: \(renewalInfo.autoRenewalDate)")
                }
            }
        } catch {
            print("Error checking subscription status: \(error)")
        }
    }

    // 辅助泛型方法:解包 VerificationResult
    func verify<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified(_, let error):
            throw error
        case .verified(let safe):
            // ✅ 验证通过,返回解包后的数据
            return safe
        }
    }
}

5.2 识别退款 (Refunds)

Transaction.updates 收到更新,或遍历 currentEntitlements 时:

  1. 检查 transaction.revocationDate 是否不为 nil。
  2. 检查 transaction.revocationReason
if let date = transaction.revocationDate {
    print("该交易已于 \(date) 被撤销/退款")
    // 移除对应的权益
    removeEntitlement(for: transaction.productID)
}

6. 恢复购买 (Restore Purchases)

恢复购买旨在帮助用户在换新手机或重装 App 后,找回之前购买过的非消耗型商品订阅。 而且苹果审核要求必须有“恢复购买”按钮。

概念与误区

  • SK1 vs SK2: 在旧版 SK1 中,必须调用 restoreCompletedTransactions 触发系统弹窗输入密码。
  • SK2 的机制: Transaction.currentEntitlements 已经包含了用户所有的有效权益。通常情况下,应用启动时刷新这个属性,就等同于“静默恢复”。
  • AppStore.sync(): 这是 StoreKit 2 的“显式恢复”接口。只有当用户在 UI 上点击“恢复购买”按钮时,或者你确信数据未同步时,才调用它。它可能会强制弹出 Apple ID 登录框。

示例代码

extension StoreKitManager {
    // 手动恢复购买 (对应 UI 上的 Restore 按钮)
    func restorePurchases() async {
        do {
            // 1. 强制同步 App Store 交易记录
            // 这可能会通过 FaceID/TouchID 验证用户身份
            try await AppStore.sync()

            // 2. 同步完成后,重新检查权益
            await updateCustomerProductStatus()

            // 3. UI 提示
            print("Restore completed successfully")
        } catch {
            print("Restore failed: \(error)")
        }
    }
}

最佳实践

  1. 自动恢复: App 启动时调用 updateCustomerProductStatus()(遍历 currentEntitlements),不要弹窗,静默让老用户获取权益。
  2. 手动恢复: 在设置页提供 "Restore Purchases" 按钮,点击后调用 restorePurchases()
  3. UI 提示: 恢复成功后,若发现用户确实有购买记录,弹窗提示“已成功恢复高级版权益”;若没有记录,提示“未发现可恢复的购买记录”。
  4. 多设备同步: StoreKit 2 自动处理。只要登录同一个 Apple ID,currentEntitlements 会包含所有设备上的购买。

7. 营销功能:折扣与优惠 (Offers)

想给新用户“首月免费”?或者给老用户“回归半价”? StoreKit 2 支持显示推介促销(Introductory Offers)和促销代码(Offer Codes)。

7.1 优惠类型

  • 首次优惠 (Introductory Offer): 通常是针对新订阅用户的特别折扣(如:免费试用 7 天,首月半价)。
  • 促销优惠 (Promotional Offer): 一般是针对现有或回归用户的限时优惠活动,例如续订折扣、节日促销等。
  • 优惠码 (Offer Codes): 一种需要用户输入兑换码的促销方式,可针对新用户、回流用户或特定人群。

7.2 判断是否展示首购优惠

StoreKit 2 可以直接判断当前用户是否符合推介优惠(比如是否已经用过免费试用)。你不需要手写复杂的逻辑。

// 检查是否有优惠
func checkIntroOffer(for product: Product) async {
    if let subscription = product.subscription,
        let introOffer = subscription.introductoryOffer {

        // 检查用户是否有资格享受这个优惠
        // StoreKit 2 会自动根据用户历史判断 isEligible
        let isEligible = await subscription.isEligibleForIntroOffer

        if isEligible {
            if introOffer.paymentMode == .freeTrial {
                print("免费试用 \(introOffer.period.value) \(introOffer.period.unit.localizedDescription)")
            } else {
                print("首月仅需: \(introOffer.price)")
            }
        } else {
            print("原价: \(product.price)")
        }
    }
}

7.3 购买带优惠的商品

对于 首次优惠(Intro Offer),直接调用 product.purchase() 即可,系统会自动应用。
对于 促销优惠(Promotional Offer),需要在购买参数中加入签名(需要服务器生成签名,较复杂,这里不展开介绍)。 如果是 优惠码 (Offer Codes),用户通常在 App Store 系统级界面输入。这里提供一个方法,可以手动弹出兑换码输入框。

// 弹出系统兑换码输入框
func presentCodeRedemptionSheet() {
    if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
        Task {
            try? await AppStore.presentOfferCodeRedeemSheet(in: windowScene)
        }
    }
}

8. 测试指南:沙盒 (Sandbox) 与 TestFlight

8.1 沙盒测试流程

  1. 创建账号: 登录 App Store Connect -> 用户和访问 -> 沙盒 -> 新增测试员。
    • 注意:不要在 iOS 设置中登录此账号!
  2. 登录: 在 App 内点击购买时,系统弹窗要求登录,此时输入沙盒账号。
  3. 管理订阅: iOS 设置 -> App Store -> 沙盒账户 -> 管理。

沙盒环境的时间过得很快:

  • 1 个月 = 5 分钟
  • 1 年 = 1 小时
  • 注意: 订阅会自动续期 5-6 次,然后自动取消。这是为了测试完整的生命周期。

8.2 测试场景 Checklist

  • 新购: 首次购买流程是否顺畅。
  • 续期: 保持 App 打开,观察 Transaction.updates 是否收到续订通知。
  • 过期: 等待沙盒订阅自动过期,检查 App 权益是否收回。
  • 中断购买: 点击购买后,在支付界面取消,App 是否处理了 .userCancelled
  • 退款: 在沙盒设置中找不到退款?需要去 Xcode -> Debug -> StoreKit -> Manage Transactions (如果是本地配置) 或通过 App Store Connect 模拟。

8.3 调试技巧

在 Xcode 中使用 .storekit 配置文件时:

  • Debug -> StoreKit -> Manage Transactions: 可以看到所有本地交易。
  • 模拟退款: 选中交易,右键点击 "Refund Transaction"。
  • 模拟 Ask to Buy: 开启 "Enable Ask to Buy" 模拟家长审批流程。

9. 最佳实践与常见坑点

常见坑 (Pitfalls)

  1. 验证失败: 遇到 VerificationResult.unverified 怎么办?
    • 原因: 可能是越狱设备、中间人攻击或者 Xcode 本地配置证书不匹配。
    • 处理: 绝对不要解锁权益。提示用户“验证失败,请重试”。
  2. App Store Server Notifications:
    • 虽然 StoreKit 2 客户端很强,但为了数据准确性(特别是退款、续费失败),建议后端对接 Server Notifications V2。
  3. 漏单:
    • 如果 App 闪退,transaction.finish() 未调用,下次启动监听 updates 时会再次收到该交易,确保逻辑幂等(重复处理同一笔交易不会出错)。

错误处理最佳实践

enum StoreError: Error {
    case failedVerification
    case userCancelled
    case pending
    case unknown
}

// 友好的错误提示
func errorMessage(for error: Error) -> String {
    if let storeError = error as? StoreKitError {
        switch storeError {
        case .userCancelled: return "您取消了购买"
        case .networkError: return "网络连接失败,请检查网络"
        default: return "购买发生未知错误,请稍后重试"
        }
    }
    return error.localizedDescription
}

发布前 Checklist

发布前请对照这张清单:

  1. App 启动监听了吗? 确保 listenForTransactions 在最早的时机运行。
  2. Finish 所有的交易了吗? 不管成功还是失败(验证不过),都要调用 .finish(),否则队列会堵死。
  3. 是否处理了 .pending 状态(家长控制)?
  4. “恢复购买”按钮是否能正常找回权益?
  5. 是否正确处理了订阅过期和退款?
  6. 是否在 TestFlight 环境下验证过真实服务器的商品?
  7. 不要自己存 Bool 值。 尽量每次启动 App 时通过 Transaction.currentEntitlements 动态计算用户是不是 VIP。本地存个 isPro = true 很容易因为卸载重装或跨设备导致数据不一致。
  8. UI 交互。 购买过程中给个 Loading 转圈圈,不要让用户连续点击或因为网络环境以为卡住了。

10. 总结

StoreKit 2 大大降低了内购开发的门槛。核心记住三点:

  1. 监听: 全局监听 Transaction.updates。
  2. 同步: 使用 Transaction.currentEntitlements 获取当前权益。
  3. 结束: 处理完必须调用 transaction.finish()。

最后,附上一个较为完整的 Demo,地址:StoreKitDemo

昨天以前掘金 iOS

2025年11月27日年解决隐私清单导致审核总是提示二进制无效的问题

作者 90后晨仔
2025年11月27日 12:45

最新新上架一个产品,但是由于有些三方库没有隐私清单的问题导致提交到苹果后台之后总是会提示二进制无效,这里特别说明一下,如果你的app已经是线上的话,貌似没啥问题。(只是问了几个朋友),但是如果你要是新的产品,1.0上线的话那么就会因为这个导致二进制无效无法提交。

  • 提交时后苹果那边给发的邮件内容,有好几个库的警告这里就拿"AFNetworking"举例说明下解决方案。下边是警告:

Please correct the following issues and upload a new binary to App Store Connect. ITMS-91061: Missing privacy manifest - Your app includes “Frameworks/AFNetworking.framework/AFNetworking”, which includes AFNetworking, an SDK that was identified in the documentation as a commonly used third-party SDK. If a new app includes a commonly used third-party SDK, or an app update adds a new commonly used third-party SDK, the SDK must include a privacy manifest file. Please contact the provider of the SDK that includes this file to get an updated SDK version with a privacy manifest. For more details about this policy, including a list of SDKs that are required to include signatures and manifests, visit:

  • 解决方案的思路是自己在打包的时候让Trae帮我写了一个脚本然后给指定的库进行了添加。当然网上也有好多其他的解决方案,自己都尝试过了并没有起作用。脚本内容如下:

set -euo pipefail
FRAMEWORKS_DIR="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}"
# 生成标准的 XML plist 隐私清单(不跟踪、不收集、不使用“需要理由”的 API)
write_manifest_basic() {
  dst="$1"
  mkdir -p "$(dirname "$dst")"
  cat > "$dst" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array/>
</dict>
</plist>

PLIST

}


# 给指定 framework 注入隐私清单(若已有则不覆盖)

inject_manifest_basic() {

  fwdir="$1"

  dst="${fwdir}/PrivacyInfo.xcprivacy"

  if [ -f "$dst" ]; then

    echo "Already present: $(basename "$fwdir")/PrivacyInfo.xcprivacy"

  else

    write_manifest_basic "$dst"

    /usr/bin/plutil -lint "$dst"

    echo "Injected PrivacyInfo.xcprivacy into $(basename "$fwdir")"

  fi

}


# 注入后重新签名,避免签名失效

resign_framework() {

  fwdir="$1"

  if [ "${CODE_SIGNING_ALLOWED:-YES}" = "YES" ] && [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ]; then

    /usr/bin/codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY}" --timestamp=none "$fwdir"

    echo "Resigned $(basename "$fwdir")"

  else

    echo "Skip resign: CODE_SIGNING_ALLOWED=${CODE_SIGNING_ALLOWED:-} EXPANDED_CODE_SIGN_IDENTITY=${EXPANDED_CODE_SIGN_IDENTITY:-}"

  fi

}

  


process_framework() {

  name="$1"

  fw="${FRAMEWORKS_DIR}/${name}"

  if [ -d "$fw" ]; then

    inject_manifest_basic "$fw"

    resign_framework "$fw"

  else

    echo "Skip ${name}: not found at ${fw}"

  fi

}

process_framework "AFNetworking.framework"

  • 具体的配置位置如下:

Snip20251127_1.png

  • 完成上边的配置之后重新打包上传就可以了,如果不放心的小伙伴可以在打包完成之后,导出.ipa的包,然后找到Frameworks这个文件夹,然后在它的下边可以找到AFNetworking.framework的文件夹然后你会看到如下图所示的文件,那么证明你添加成功了。

Snip20251127_2.png

  • 有了上边的文件之后你再次提交审核就不会出现AFNetworking这个库没有隐私清单的警告了。

Swift的Extension简单说明

作者 如此风景
2025年11月26日 20:46

Swift Extension(扩展)是 Swift 中用于给已有类型(类、结构体、枚举、协议)添加功能的核心特性,无需继承、无需修改原类型源码,在 AppDelegate.swift 中可以看到大量 extension AppDelegate { ... } 的核心原因(用于分类管理代码、遵守协议、扩展方法)。

一、核心定义

  • 作用:给任意类型(系统类型如 NSMenu、自定义类型如 AppDelegate)添加方法、计算属性、协议实现、初始化器等,实现「模块化编程」和「代码解耦」。

  • 优势

  1. 避免类体积过大(把不同功能拆分到扩展中);

  2. 无需继承即可扩展功能(比如给 StringInt 加自定义方法);

  3. 集中实现协议方法(代码更清晰);

  4. 系统类型扩展(比如给 NSMenuItem 加通用方法)。

二、基本语法


// 基础语法:扩展已有类型

extension 类型名 {

    // 要添加的功能(方法、计算属性、协议实现等)

}

  


// 带约束的扩展(比如给遵循某协议的类型扩展)

extension 类型名: 协议1, 协议2 where 泛型约束 {

    // 协议方法实现 + 自定义功能

}

  


// 示例(代码中)

extension AppDelegate: ClashProcessDelegate {

    // 实现 ClashProcessDelegate 协议方法

    func startProxyCore() { ... }

}

三、核心功能(结合 AppDelegate 代码实例)

AppDelegate.swift 中大量使用 Extension,是 Swift 模块化编程的典型实践,以下逐一拆解核心用法:

1. 遵守并实现协议(最常用场景)

给已有类扩展并遵守协议,实现协议方法,避免把所有协议方法写在类的主定义中,代码更清晰。

代码实例


// 扩展 AppDelegate 遵守 Clash 核心进程代理协议,并实现协议方法

extension AppDelegate: ClashProcessDelegate {

    func startProxyCore() { ... } // 协议方法:启动核心

    func clashLaunchPathNotFound(_ msg: String) { ... } // 协议方法:处理路径不存在

}

  


// 扩展 AppDelegate 遵守菜单代理协议,实现菜单更新/高亮逻辑

extension AppDelegate: NSMenuDelegate {

    func menuNeedsUpdate(_ menu: NSMenu) { ... } // 菜单即将显示时更新

    func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { ... } // 菜单项高亮

}

核心价值:把「协议实现」和「类核心逻辑」分离,AppDelegate 主定义只保留属性,协议方法集中在扩展中,便于维护。

2. 分类管理类方法(按功能拆分)

把类的不同功能(如「主菜单项点击事件」「配置操作」「崩溃处理」)拆分到不同扩展中,用 // MARK: 标记,代码结构一目了然。

代码实例


// MARK: Main actions - 主菜单项点击事件扩展

extension AppDelegate {

    @IBAction func actionDashboard(_ sender: NSMenuItem?) { ... } // 仪表盘点击

    @IBAction func actionQuit(_ sender: Any) { ... } // 退出点击

}

  


// MARK: Config actions - 配置相关操作扩展

extension AppDelegate {

    @IBAction func openConfigFolder(_ sender: Any) { ... } // 打开配置文件夹

    @IBAction func actionUpdateConfig(_ sender: AnyObject) { ... } // 重载配置

}

  


// MARK: crash hanlder - 崩溃处理扩展

extension AppDelegate {

    func registCrashLogger() { ... } // 注册崩溃日志

    func failLaunchProtect() { ... } // 启动失败保护

}

核心价值

  • 避免 AppDelegate 主定义上千行代码,按功能模块化;

  • 查找功能时直接定位对应 MARK 扩展,无需翻找整个类。

3. 扩展实例/类方法

给任意类型添加自定义方法(系统类型/自定义类型均可),比如给 String 加「验证URL」方法,给 AppDelegate 加「重置代理」方法。

示例(通用场景)


// 扩展系统类型:给 String 加 URL 验证方法

extension String {

    func isValidURL() -> Bool {

        return URL(string: self) != nil

    }

}

  


// 代码中:给 AppDelegate 加实例方法(重置代理)

extension AppDelegate {

    @objc func resetProxySettingOnWakeupFromSleep() { ... } // 睡眠唤醒后重置代理

    @objc func healthCheckOnNetworkChange() { ... } // 网络变化时健康检查

}

4. 扩展计算属性(注意:不能加存储属性)

Extension 可以添加「计算属性」(只读/读写),但不能添加存储属性var xxx: Int = 0 这类带内存占用的属性),因为扩展不允许修改类型的内存布局。

示例


// 扩展 NSMenuItem 加计算属性:是否为代理模式项

extension NSMenuItem {

    var isProxyModeItem: Bool {

        get {

            return self.identifier?.rawValue == "proxyModeItem"

        }

    }

}

  


// 扩展 Int 加计算属性:转文件大小字符串(KB/MB)

extension Int {

    var fileSizeString: String {

        if self < 1024 {

            return "\(self) B"

        } else if self < 1024 * 1024 {

            return "\(Double(self)/1024) KB"

        } else {

            return "\(Double(self)/(1024*1024)) MB"

        }

    }

}

5. 扩展初始化器

给值类型(结构体、枚举)或类添加自定义初始化器,补充原类型的初始化逻辑。

示例


// 扩展自定义结构体:添加便捷初始化器

struct ProxyConfig {

    var port: Int

    var ip: String

}

  


extension ProxyConfig {

    // 便捷初始化器:默认IP为127.0.0.1

    init(port: Int) {

        self.port = port

        self.ip = "127.0.0.1"

    }

}

  


// 使用

let config = ProxyConfig(port: 7890) // ip 自动为 127.0.0.1

6. 带泛型约束的扩展

给泛型类型(如 Array)添加约束扩展,仅对满足条件的泛型生效。

示例


// 仅对元素为 Int 的 Array 扩展求和方法

extension Array where Element == Int {

    func sum() -> Int {

        return reduce(0, +)

    }

}

  


let numbers = [1,2,3]

print(numbers.sum()) // 6

四、关键注意事项(避坑)

  1. 不能添加存储属性  

   Extension 只能加「计算属性」,不能加 var xxx: Int = 0 这类存储属性(Swift 设计限制,避免破坏原类型的内存布局)。

   ❌ 错误:


extension AppDelegate {

    var test: Int = 0 // 编译报错:Extensions may not contain stored properties

}

   ✅ 正确(计算属性):


extension AppDelegate {

    var isProxyRunning: Bool {

        return ConfigManager.shared.isRunning

    }

}

  1. 不能重写原类型的方法  

   Extension 只能添加新方法,不能重写类原有方法(重写需用继承)。

  1. 协议扩展的优先级  

   如果类和扩展都实现了协议方法,类的主定义方法优先级更高;如果多个扩展实现同一方法,编译报错(歧义)。

  1. @objc 兼容  

   给 Objective-C 兼容类型(如 NSObject 子类)扩展的方法,若需被 OC 调用(如 @IBAction、代理方法),需加 @objc


extension AppDelegate {

    @objc func handleURL(event: NSAppleEventDescriptor, reply: NSAppleEventDescriptor) { ... }

}

  1. 静态方法/属性扩展  

   可给类型添加静态方法/计算属性:


extension AppDelegate {

    static let appVersion = AppVersionUtil.currentVersion

    static func logLaunchInfo() {

        Logger.log("Version: \(appVersion)")

    }

}

五、实战场景总结(结合 AppDelegate 代码)

AppDelegate.swift 是 Extension 最佳实践,核心场景:

扩展类型 作用  示例代码位置 
协议实现扩展            分离协议方法,解耦核心逻辑            extension AppDelegate: NSMenuDelegate
功能分类扩展            按业务拆分方法(如配置、菜单、崩溃)  // MARK: Config actions 扩展       
@objc 方法扩展          兼容 OC 运行时(如 URL Scheme 处理) @objc func handleURL(...)          
事件处理扩展            集中管理 IBAction 点击事件            // MARK: Main actions 扩展         

六、扩展 vs 继承(补充)

很多新手会混淆扩展和继承,两者核心区别:

特性         Extension(扩展) 继承(Inheritance)
核心目的     给已有类型添加功能                    基于父类创建子类,重写/扩展功能      
内存布局     不修改原类型内存                      子类有独立内存布局                   
方法重写     不支持                                支持重写父类方法                     
存储属性     不支持                                支持添加存储属性                     
耦合度       低(无需关联原类型源码) 高(子类依赖父类)

总结

Swift Extension 是「模块化编程」的核心,AppDelegate 代码通过扩展可以实现:

  1. 协议方法与核心逻辑分离;

  2. 按业务功能拆分代码(配置、菜单、崩溃、网络等);

  3. 兼容 OC 运行时(@objc 方法);

  4. 扩展自定义方法(如 startProxyCoreresetStreamApi)。

掌握 Extension 的核心是「拆分功能、解耦代码、不入侵原类型」,这也是 Swift 推崇的「组合优于继承」设计思想的体现。

打个广告,帮忙招一个iOS开发的扛把子~

作者 iOS研究院
2025年11月26日 18:08

打破 35 + 职业魔咒|AI 出海创业梦之队诚招 iOS 技术负责人

我们拒绝「35 岁职场干电池」标签,坚信经验是最宝贵的财富 —— 诚邀深耕 iOS 领域的技术大佬,与我们并肩开拓 AI 出海新赛道,在碰撞中创新,在实战中共同成长!

关于我们:无短板的出海「六边形战士」梦之队

  • 核心成员均来自陌陌、米可、莱熙等一线出海团队,深耕泛娱乐赛道多年,打造过多个非游出海明星产品;
  • 运营端手握千万级优质资源,技术核心源自红客联盟,擅长落地黑科技创新玩法;
  • 市场团队是流量运营专家,仅靠出海 0-1 阶段顾问服务,不到两年便实现年营收破百万;
  • 项目已跑通商业闭环,数据表现亮眼,无需依赖融资即可稳定自造血,创业路上底气十足。

我们需要这样的你:iOS 技术领路人

岗位职责

  1. 主导搭建创业公司 iOS 技术体系,负责 AI 驱动型 App 核心架构设计与关键模块开发,深度集成 OpenAI 等第三方 AI 服务;
  2. 攻克海外业务适配难题:完成多语言本地化落地,合规适配 GDPR/CCPA 等海外法规,解决跨地区网络稳定性问题;
  3. 统筹海外 App Store 上架全流程,精准解读审核规则,保障版本顺利上线,高效排查线上突发问题;
  4. 搭建轻量化工程化流程,聚焦 App 启动速度、崩溃率等核心指标,实现性能攻坚与优化。

任职要求

  1. 本科及以上学历,5-10 年 iOS 开发经验,有创业公司或海外 App 完整开发 / 落地经历;
  2. 精通 Swift/Objective-C 及 iOS 核心框架,具备扎实的架构设计能力与复杂项目把控经验;
  3. 有 AI 服务移动端集成实战经验,熟悉接口调用逻辑与数据处理全流程;
  4. 深谙海外 iOS 生态,对 App Store 审核规则、海外合规要求有清晰认知;
  5. 适应创业快节奏,能快速响应并解决性能优化、跨地区适配等复杂技术问题。

加分项

  • 主导过 AI 驱动型 App 海外上架,成功落地美区、欧区等核心市场;
  • 有海外合规改造或性能优化标杆案例,能提供明确数据成果(如崩溃率降低 X%、启动速度提升 X%);
  • 熟悉 Stripe/PayPal 支付集成、Firebase 等海外常用第三方服务,或具备 Flutter 混合开发经验。

投递须知

  1. 工作地点:北京(可出厂开发优先考虑),技术过硬可以接受远程 / 异地;
  2. 为高效匹配,确保你对出海 AI 赛道有强烈意愿,且符合上述核心要求后再投递;
  3. 简历投递邮箱:1689630415@qq.com,邮件主题建议注明「iOS 技术负责人 + 姓名 + 工作年限」。

我们不设年龄焦虑,只看能力与潜力;这里没有层级束缚,只有并肩作战的伙伴。期待你加入,成为我们不可或缺的核心力量,一起在 AI 出海赛道共创下一个爆款!

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

相关推荐

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

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

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

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

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

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

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

iOS 内存管理深度解析:从原理到实践

作者 Sheffi
2025年11月26日 11:37

前言

内存管理是 iOS 开发中最核心的知识点之一,理解透彻的内存管理机制不仅能帮助我们写出高质量的代码,还能有效避免内存泄漏、野指针等常见问题。本文将从底层原理到实际应用,全面剖析 iOS 的内存管理机制。


一、内存管理的演进历程

1.1 MRC 时代(Manual Reference Counting)

在 iOS 5 之前,开发者需要手动管理对象的生命周期:

// MRC 时代的内存管理
NSObject *obj = [[NSObject alloc] init]; // retainCount = 1
[obj retain];                             // retainCount = 2
[obj release];                            // retainCount = 1
[obj release];                            // retainCount = 0,对象被销毁

黄金法则:谁创建(alloc/new/copy/mutableCopy),谁释放(release)。

1.2 ARC 时代(Automatic Reference Counting)

iOS 5 引入 ARC 后,编译器自动在适当位置插入 retain/release 代码:

// ARC 时代 - 编译器自动管理
func createObject() {
    let obj = MyClass()  // 编译器插入 retain
    // 使用 obj
}  // 函数结束,编译器插入 release

⚠️ 重要提示:ARC 不是垃圾回收(GC),它是编译时特性,不会带来运行时开销。


二、引用计数的底层实现

2.1 isa 指针与 SideTable

在 64 位系统中,苹果对 isa 指针进行了优化,采用了 Non-pointer isa 结构:

┌─────────────────────────────────────────────────────────────────┐
                        isa 指针结构(64位)                       
├─────────────────────────────────────────────────────────────────┤
 0       indexed       0: 纯指针  1: 优化的isa              
 1       has_assoc     是否有关联对象                        
 2       has_cxx_dtor  是否有C++析构函数                     
 3-35    shiftcls      类指针(33位)                        
 36-41   magic         用于调试                             
 42      weakly_ref    是否有弱引用                          
 43      deallocating  是否正在释放                          
 44      has_sidetable│ 引用计数是否存储在SideTable           
 45-63   extra_rc      额外的引用计数(19位)                 
└─────────────────────────────────────────────────────────────────┘

2.2 SideTable 结构

当引用计数超出 isa 的存储范围时,会使用 SideTable:

struct SideTable {
    spinlock_t slock;           // 自旋锁,保证线程安全
    RefcountMap refcnts;        // 引用计数表(哈希表)
    weak_table_t weak_table;    // 弱引用表
};

系统维护了一个 SideTables 哈希表,通过对象地址快速定位到对应的 SideTable:

// 获取对象的引用计数
static inline RefcountMap::iterator 
getRefcountMap(objc_object *obj) {
    SideTable& table = SideTables()[obj];
    return table.refcnts.find(obj);
}

2.3 retain 和 release 的源码分析

// objc_object::retain() 简化实现
id objc_object::retain() {
    // 1. TaggedPointer 直接返回
    if (isTaggedPointer()) return (id)this;
    
    // 2. 尝试在 isa 的 extra_rc 中增加引用计数
    if (fastpath(!ISA()->hasCustomRR())) {
        if (fastpath(bits.extra_rc++ < RC_HALF)) {
            return (id)this;
        }
    }
    
    // 3. extra_rc 溢出,转移到 SideTable
    return sidetable_retain();
}

三、四种引用类型详解

3.1 Strong(强引用)

class Person {
    var name: String
    var apartment: Apartment?  // 强引用
    
    init(name: String) {
        self.name = name
        print("\(name) is initialized")
    }
    
    deinit {
        print("\(name) is deinitialized")
    }
}

3.2 Weak(弱引用)

弱引用不会增加引用计数,对象释放时自动置为 nil:

class Apartment {
    let unit: String
    weak var tenant: Person?  // 弱引用,避免循环引用
    
    init(unit: String) {
        self.unit = unit
    }
}

弱引用的底层实现

// weak_table_t 结构
struct weak_table_t {
    weak_entry_t *weak_entries;  // 弱引用入口数组
    size_t    num_entries;        // 弱引用数量
    uintptr_t mask;               // 哈希掩码
    uintptr_t max_hash_displacement; // 最大哈希偏移
};

// 当对象被释放时,清理所有弱引用
void weak_clear_no_lock(weak_table_t *weak_table, id referent) {
    weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
    if (entry == nil) return;
    
    // 将所有指向该对象的弱引用置为 nil
    weak_referrer_t *referrers = entry->referrers;
    for (size_t i = 0; i < entry->num_refs; i++) {
        *referrers[i] = nil;
    }
    
    weak_entry_remove(weak_table, entry);
}

3.3 Unowned(无主引用)

class Customer {
    let name: String
    var card: CreditCard?
    
    init(name: String) {
        self.name = name
    }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer  // 无主引用
    
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
}
特性 weak unowned
引用计数 不增加 不增加
对象释放时 自动置 nil 不处理(悬垂指针)
声明类型 Optional Non-optional
性能 略低(需维护weak表) 较高
安全性 安全 需保证生命周期

3.4 闭包中的引用

class HTMLElement {
    let name: String
    let text: String?
    
    // ❌ 循环引用
    lazy var asHTML: () -> String = {
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    // ✅ 使用捕获列表打破循环
    lazy var asHTMLFixed: () -> String = { [weak self] in
        guard let self = self else { return "" }
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    // ✅ 或使用 unowned(确保闭包执行时 self 存在)
    lazy var asHTMLUnowned: () -> String = { [unowned self] in
        return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
    }
    
    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
    }
}

四、常见内存问题与解决方案

4.1 循环引用

场景一:Delegate 模式

// ❌ 错误示例
protocol DownloadDelegate: AnyObject {  // 注意这里必须用 AnyObject
    func downloadDidComplete()
}

class DownloadManager {
    var delegate: DownloadDelegate?  // ❌ 强引用导致循环
}

// ✅ 正确示例
class DownloadManager {
    weak var delegate: DownloadDelegate?  // ✅ 弱引用
}

场景二:闭包捕获

class NetworkManager {
    var completionHandler: (() -> Void)?
    
    func fetchData() {
        // ❌ 循环引用
        completionHandler = {
            self.handleData()
        }
        
        // ✅ 解决方案1:weak
        completionHandler = { [weak self] in
            self?.handleData()
        }
        
        // ✅ 解决方案2:在不需要时置空
        defer { completionHandler = nil }
    }
    
    func handleData() {
        print("Handle data")
    }
}

场景三:Timer

class TimerHolder {
    var timer: Timer?
    
    func startTimer() {
        // ❌ Timer 对 target 强引用
        timer = Timer.scheduledTimer(
            timeInterval: 1.0,
            target: self,
            selector: #selector(tick),
            userInfo: nil,
            repeats: true
        )
        
        // ✅ 解决方案:使用 block API
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }
    
    @objc func tick() {
        print("Tick")
    }
    
    deinit {
        timer?.invalidate()
        print("TimerHolder deinit")
    }
}

4.2 内存泄漏检测

使用 Instruments - Leaks

步骤:
1. Xcode -> Product -> Profile (⌘I)
2. 选择 Leaks
3. 运行并操作 App
4. 查看泄漏点和调用栈

使用 Debug Memory Graph

// 在特定点触发内存警告,观察对象是否正确释放
#if DEBUG
extension UIViewController {
    func checkMemoryLeak() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
            if self != nil {
                print("⚠️ 可能存在内存泄漏: \(type(of: self!))")
            }
        }
    }
}
#endif

自定义泄漏检测工具

class LeakDetector {
    static let shared = LeakDetector()
    private var trackedObjects: [ObjectIdentifier: WeakBox<AnyObject>] = [:]
    private let queue = DispatchQueue(label: "com.app.leakdetector")
    
    struct WeakBox<T: AnyObject> {
        weak var value: T?
        let className: String
    }
    
    func track(_ object: AnyObject, file: String = #file, line: Int = #line) {
        let id = ObjectIdentifier(object)
        let className = String(describing: type(of: object))
        
        queue.async {
            self.trackedObjects[id] = WeakBox(value: object, className: className)
            print("📍 Tracking: \(className) at \(file):\(line)")
        }
    }
    
    func checkLeaks() {
        queue.async {
            for (id, box) in self.trackedObjects {
                if box.value != nil {
                    print("⚠️ Potential leak: \(box.className)")
                } else {
                    self.trackedObjects.removeValue(forKey: id)
                }
            }
        }
    }
}

五、Autorelease Pool 深度解析

5.1 工作原理

┌──────────────────────────────────────────────────────────────────┐
│                    Autorelease Pool 结构                          │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐            │
│   │   Page 1    │──>│   Page 2    │──>│   Page 3    │            │
│   │  (4096 B)   │   │  (4096 B)   │   │  (4096 B)   │            │
│   └─────────────┘   └─────────────┘   └─────────────┘            │
│         │                 │                 │                     │
│         ▼                 ▼                 ▼                     │
│   ┌───────────┐     ┌───────────┐     ┌───────────┐              │
│   │  obj1     │     │  obj5     │     │  obj9     │              │
│   │  obj2     │     │  obj6     │     │  obj10    │              │
│   │  obj3     │     │  obj7     │     │  ...      │              │
│   │  obj4     │     │  obj8     │     │           │              │
│   │ SENTINEL  │     │           │     │           │              │
│   └───────────┘     └───────────┘     └───────────┘              │
│                                              ▲                    │
│                                              │                    │
│                                           hotPage                 │
│                                          (当前页)                  │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

5.2 源码分析

class AutoreleasePoolPage {
    static size_t const SIZE = PAGE_MAX_SIZE;  // 4096 bytes
    static size_t const COUNT = SIZE / sizeof(id);
    
    magic_t const magic;
    id *next;                    // 下一个可存放对象的位置
    pthread_t const thread;      // 所属线程
    AutoreleasePoolPage *parent; // 父节点
    AutoreleasePoolPage *child;  // 子节点
    uint32_t depth;              // 深度
    
    // 添加对象到 pool
    static inline id *autoreleaseFast(id obj) {
        AutoreleasePoolPage *page = hotPage();
        if (page && !page->full()) {
            return page->add(obj);
        }
        return autoreleaseFullPage(obj, page);
    }
    
    // Pool 的 pop 操作
    static inline void pop(void *token) {
        AutoreleasePoolPage *page = pageForPointer(token);
        id *stop = (id *)token;
        
        // 释放对象
        page->releaseUntil(stop);
        
        // 删除空页
        if (page->child) {
            page->child->kill();
            page->child = nil;
        }
    }
};

5.3 主线程 RunLoop 与 Autorelease Pool

┌──────────────────────────────────────────────────────────────────┐
                    RunLoop  AutoreleasePool                     
├──────────────────────────────────────────────────────────────────┤
                                                                   
   ┌─────────────────────────────────────────────────────────┐    
                        Main RunLoop                             
   └─────────────────────────────────────────────────────────┘    
                                                                  
        ┌─────────────────────┼─────────────────────┐             
                                                               
   ┌─────────┐          ┌─────────┐          ┌─────────┐         
     Entry             Before              Exit            
    (Push)             Waiting             (Pop)           
    Order:             (Pop +             Order:           
     高优先              Push)              低优先           
   └─────────┘          └─────────┘          └─────────┘         
                                                                   
   时机说明:                                                       
   1. kCFRunLoopEntry: 创建 AutoreleasePool (push)                
   2. kCFRunLoopBeforeWaiting: 释放旧pool (pop),创建新pool (push) 
   3. kCFRunLoopExit: 释放 AutoreleasePool (pop)                  
                                                                   
└──────────────────────────────────────────────────────────────────┘

5.4 手动使用 Autorelease Pool

// 场景:大量临时对象的循环
func processLargeData() {
    for i in 0..<100000 {
        // ❌ 不使用 autoreleasepool,临时对象会累积
        let data = createTemporaryData(index: i)
        process(data)
    }
    
    for i in 0..<100000 {
        // ✅ 使用 autoreleasepool,每次迭代后释放临时对象
        autoreleasepool {
            let data = createTemporaryData(index: i)
            process(data)
        }
    }
    
    // ✅ 更优化的方案:批量处理
    let batchSize = 1000
    for batch in stride(from: 0, to: 100000, by: batchSize) {
        autoreleasepool {
            for i in batch..<min(batch + batchSize, 100000) {
                let data = createTemporaryData(index: i)
                process(data)
            }
        }
    }
}

六、Tagged Pointer 优化

6.1 什么是 Tagged Pointer

对于小对象(如小的 NSNumber、NSDate),苹果使用 Tagged Pointer 直接在指针中存储数据:

┌──────────────────────────────────────────────────────────────────┐
│                    Tagged Pointer 结构                            │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│   普通对象指针:                                                   │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │         64位地址指向堆中的对象                            │    │
│   └─────────────────────────────────────────────────────────┘    │
│                              │                                    │
│                              ▼                                    │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │                    堆中的对象                             │    │
│   │  ┌──────┬──────────┬──────────┬─────────────────────┐   │    │
│   │  │ isa  │ refCount │ 其他信息  │      实际数据       │   │    │
│   │  └──────┴──────────┴──────────┴─────────────────────┘   │    │
│   └─────────────────────────────────────────────────────────┘    │
│                                                                   │
│   Tagged Pointer:                                                │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │ 1 │ 类型标记(3位) │           数据值(60位)              │    │
│   └─────────────────────────────────────────────────────────┘    │
│     ↑                                                             │
│   标记位(表明这是Tagged Pointer)                                   │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

6.2 判断 Tagged Pointer

// 通过内存地址判断(仅供理解,实际开发中不需要关心)
func isTaggedPointer(_ obj: AnyObject) -> Bool {
    let pointer = Unmanaged.passUnretained(obj).toOpaque()
    let value = UInt(bitPattern: pointer)
    
    // 在 arm64 上,最高位为 1 表示 Tagged Pointer
    // 在 x86_64 上,最低位为 1 表示 Tagged Pointer
    #if arch(arm64)
    return (value >> 63) == 1
    #else
    return (value & 1) == 1
    #endif
}

6.3 性能优势

// Tagged Pointer 的优势演示
func performanceTest() {
    let iterations = 1_000_000
    
    // 小数字 - 使用 Tagged Pointer
    let start1 = CFAbsoluteTimeGetCurrent()
    for _ in 0..<iterations {
        let num = NSNumber(value: 42)  // Tagged Pointer
        _ = num.intValue
    }
    let time1 = CFAbsoluteTimeGetCurrent() - start1
    
    // 大数字 - 使用普通对象
    let start2 = CFAbsoluteTimeGetCurrent()
    for _ in 0..<iterations {
        let num = NSNumber(value: Int64.max)  // 普通对象
        _ = num.int64Value
    }
    let time2 = CFAbsoluteTimeGetCurrent() - start2
    
    print("Tagged Pointer: \(time1)s")  // 明显更快
    print("普通对象: \(time2)s")
}

七、实战:内存优化最佳实践

7.1 图片内存优化

class ImageLoader {
    // 使用 NSCache 自动管理内存
    private let cache = NSCache<NSString, UIImage>()
    
    init() {
        // 设置缓存限制
        cache.countLimit = 100
        cache.totalCostLimit = 50 * 1024 * 1024  // 50MB
        
        // 监听内存警告
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleMemoryWarning),
            name: UIApplication.didReceiveMemoryWarningNotification,
            object: nil
        )
    }
    
    // 下采样加载大图
    func loadDownsampledImage(at url: URL, targetSize: CGSize) -> UIImage? {
        let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        
        guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else {
            return nil
        }
        
        let maxDimension = max(targetSize.width, targetSize.height) * UIScreen.main.scale
        let downsampledOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize: maxDimension
        ] as CFDictionary
        
        guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampledOptions) else {
            return nil
        }
        
        return UIImage(cgImage: downsampledImage)
    }
    
    @objc private func handleMemoryWarning() {
        cache.removeAllObjects()
    }
}

7.2 大数据处理

class DataProcessor {
    // 分批处理大数组,避免内存峰值
    func processBatched<T>(_ array: [T], batchSize: Int = 1000, handler: ([T]) -> Void) {
        let totalCount = array.count
        var processedCount = 0
        
        while processedCount < totalCount {
            autoreleasepool {
                let endIndex = min(processedCount + batchSize, totalCount)
                let batch = Array(array[processedCount..<endIndex])
                handler(batch)
                processedCount = endIndex
            }
        }
    }
    
    // 使用流式读取大文件
    func processLargeFile(at url: URL, lineHandler: (String) -> Void) {
        guard let fileHandle = try? FileHandle(forReadingFrom: url) else { return }
        defer { try? fileHandle.close() }
        
        let bufferSize = 4096
        var buffer = Data()
        
        while autoreleasepool(invoking: {
            guard let chunk = try? fileHandle.read(upToCount: bufferSize), !chunk.isEmpty else {
                return false
            }
            
            buffer.append(chunk)
            
            while let range = buffer.range(of: Data("\n".utf8)) {
                let lineData = buffer.subdata(in: 0..<range.lowerBound)
                if let line = String(data: lineData, encoding: .utf8) {
                    lineHandler(line)
                }
                buffer.removeSubrange(0..<range.upperBound)
            }
            
            return true
        }) {}
        
        // 处理最后一行
        if let lastLine = String(data: buffer, encoding: .utf8), !lastLine.isEmpty {
            lineHandler(lastLine)
        }
    }
}

7.3 ViewController 内存管理

class BaseViewController: UIViewController {
    // 所有需要取消的任务
    private var cancellables = Set<AnyCancellable>()
    private var tasks = [Task<Void, Never>]()
    
    deinit {
        // 取消所有订阅
        cancellables.removeAll()
        
        // 取消所有 Task
        tasks.forEach { $0.cancel() }
        
        print("\(type(of: self)) deinit")
    }
    
    // 安全地添加通知观察者
    func observe(_ name: Notification.Name, handler: @escaping (Notification) -> Void) {
        NotificationCenter.default.publisher(for: name)
            .sink { [weak self] notification in
                guard self != nil else { return }
                handler(notification)
            }
            .store(in: &cancellables)
    }
    
    // 安全地执行异步任务
    func performTask(_ operation: @escaping () async -> Void) {
        let task = Task { [weak self] in
            guard self != nil else { return }
            await operation()
        }
        tasks.append(task)
    }
}

八、调试技巧

8.1 LLDB 命令

# 查看对象引用计数
(lldb) p CFGetRetainCount(obj as CFTypeRef)

# 查看对象的弱引用
(lldb) p _objc_rootRetainCount(obj)

# 查看所有内存分配
(lldb) memory history <address>

# 查看 Autorelease Pool 中的对象
(lldb) po [NSAutoreleasePool showPools]

# 查看对象的 isa 信息
(lldb) p/x (uintptr_t)object_getClass(obj)

8.2 环境变量

在 Scheme 的 Environment Variables 中添加:

MallocStackLogging = 1          # 记录内存分配堆栈
MallocStackLoggingNoCompact = 1 # 不压缩堆栈信息
OBJC_DEBUG_POOL_ALLOCATION = YES # 调试 Autorelease Pool
NSZombieEnabled = YES           # 检测野指针

8.3 自定义内存追踪

#if DEBUG
class MemoryTracker {
    static let shared = MemoryTracker()
    
    private var allocations: [String: Int] = [:]
    private let queue = DispatchQueue(label: "memory.tracker")
    
    func trackAlloc(_ className: String) {
        queue.async {
            self.allocations[className, default: 0] += 1
        }
    }
    
    func trackDealloc(_ className: String) {
        queue.async {
            self.allocations[className, default: 0] -= 1
        }
    }
    
    func report() {
        queue.async {
            print("=== Memory Report ===")
            for (className, count) in self.allocations where count > 0 {
                print("\(className): \(count) instances")
            }
            print("====================")
        }
    }
}

// 使用方式
class TrackedObject {
    init() {
        MemoryTracker.shared.trackAlloc(String(describing: Self.self))
    }
    
    deinit {
        MemoryTracker.shared.trackDealloc(String(describing: Self.self))
    }
}
#endif

总结

iOS 内存管理是一个深度话题,本文从以下几个方面进行了详细解析:

  1. 引用计数原理:从 MRC 到 ARC 的演进,以及底层 SideTable 的实现
  2. 四种引用类型:strong、weak、unowned 的区别和适用场景
  3. 循环引用:常见场景和解决方案
  4. Autorelease Pool:工作原理和使用时机
  5. Tagged Pointer:小对象优化机制
  6. 实战优化:图片处理、大数据处理等场景的最佳实践
  7. 调试技巧:常用的调试命令和工具

参考资料

Swift UI数据存储

作者 Haha_bj
2025年11月26日 11:34
一. @StateObject 数据存储机制 @StateObject 保存的数据存储在设备内存(RAM)中,是临时存储 数据生命周期 创建时机:视图第一次被创建时 保持时机:视图重新渲染时数据保持不
❌
❌