阅读视图

发现新文章,点击刷新页面。

老司机 iOS 周报 #359 | 2025-12-01

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

文章

🌟 🐕 从「写代码」到「验代码」:AI 搭档写走 3 年,我踩出来的协作路线图

@JonyFang: 这篇文章给我的最大启发是:AI Coding 的价值,不在于让你更快写代码,而在于让你反思、重构「整个工程流程/协作方式」。

把 AI 当成一台「超级写代码机」很容易:但如果你这样想,就容易陷入“生成–调试–纠错–折腾–没效率”的恶性循环。相反,把 AI 当成「半自动化助理 + 智能实习生」,并在团队层面投入标准化 + 自动化 + 质量流程建设 -- 才是值得的、可持续的生产力提升路径。

🐕 Open source case study: Listening to our users

@Barney:文章强调了开源软件相较于 Apple 官方框架的核心优势:与用户的紧密互动。通过 SQLiteData 库的三个案例,用户驱动的功能定制、社区贡献的工具改进、快速修复的共享 bug,充分说明了活跃维护者的价值所在。开源库能在数小时到数周内响应用户需求,无需等待 WWDC 或新版本发布,这种敏捷迭代的开发模式,正是第三方库相比官方框架最值得信赖的地方。

🐎 A deep dive into notifications and messages on iOS 26

@极速男孩:这篇文章分析了 iOS 26 的新消息(Message)API。它作为传统 NotificationCenter 的替代品,提供了编译时的类型安全和并发安全保证,解决了旧 API(如闭包式)易出错的问题。

文章详细对比了新旧 API,并重点介绍了新 API(如 MainActorMessage)的优势。一个关键特性是,新旧 API 可以“桥接”互通,允许开发者逐步迁移。尽管新 API 在观察时获取具体发送者上有限制,但仍是未来的首选方案。

🐎 When To Kill A Project

@含笑饮砒霜:这篇文章是《War Stories》系列访谈的一部分,聚焦 iOS 领域知名开发者戴夫・弗沃(Dave Verwer)30 年职业生涯中的项目经验,核心围绕 “何时该终止一个项目” 展开,通过多个真实案例分享了成功与失败带来的关键启示。戴夫的经验本质上围绕 “理性决策” 展开:无论是失败的项目(及时止损、诚信负责)、误判市场的项目(重视真实需求调研),还是完成使命的成功项目(主动退出),核心都是 “聚焦有价值的部分,砍掉无效投入”。同时,信任、社区协作和对行业趋势的预判,也是项目决策中不可或缺的因素。

🐢 How to Build Scalable White-Label iOS Apps: From Multi-Target to Modular Architecture

@AidenRao:白标 iOS 应用是一个可重用的应用模板,可以针对多个客户 / 品牌进行定制和重新包装。本文探讨了 iOS 白标 App 的架构演进之路,从多 Target 方案的维护困境出发,深入讲解了如何借助模块化思想,通过有效的依赖与配置管理,构建一个更具可维护性与可扩展性的统一代码库。

🐎 Task Identity

极速男孩:文章指出 SwiftUI .task 的常见陷阱:它默认只在视图出现时运行,不会自动响应属性变化。若 View 的入参更新,Task 不会重跑,导致数据不同步。解决方案是使用 .task(id: 依赖项)。通过显式绑定依赖(如 url),当值变化时,SwiftUI 会自动取消旧任务并重启新任务,确保副作用逻辑与最新状态保持一致。

🐎 Pitfalls of Parameterized Tests

@david-clang:本文分享了 Swift Testing 中参数化测试的五大常见陷阱,并提出了关键的最佳实践。其核心思想是:应在测试数据中预先建立清晰的“输入-输出”映射关系,并使用预定义的静态值作为期望结果,从根本上避免测试逻辑与实现逻辑的耦合。

🐎 ScrollView snapping in SwiftUI

@DylanYang:作者向我们介绍了在 SwiftUI 中如何通过设置 scrollTargetBehavior 来调整 ScrollView 滑动的目标位置,除了我们在 UIKit 中熟知的按页滑动 .paging 选项外,还有 .viewAligned 选项允许我们按照 view 的尺寸来决定滑动的终点。文中有较多动图展示,感兴趣的读者可以阅读本文了解更详细的信息。

🐎 Building Peer-to-Peer Sessions: Advertising and Browsing Devices

@Damien:文章详解用 Multipeer Connectivity 框架实现 iOS 近场通信:配置权限、初始化 PeerSessionManager,用 MCNearbyServiceAdvertiser 广播自身并自动接受连接,同时用 MCNearbyServiceBrowser 发现与维护设备列表,附完整 Swift 源码展示如何整合广告、浏览、会话管理等功能,实现设备间的加密点对点通信。

工具

Mole:像鼹鼠一样深入挖掘来清理你的 Mac

@EyreFree:Mole 是一款面向 macOS 系统的工具,可以大概理解为是 CleanMyMac + AppCleaner + DaisyDisk + Sensei + iStat 的聚合,主要作用如下:

  • 系统状态监控:通过 mo status 命令提供交互式仪表盘,实时展示 CPU、内存、磁盘、网络等系统关键指标,支持 Vim 风格导航操作;
  • 系统清理功能:可安全清理系统缓存,提供 --dry-run 预览模式和白名单管理功能,降低误删风险,还支持通过 mo touchid 启用 Touch ID 授权 sudo 操作;
  • 磁盘分析能力:借助 mo analyze 命令分析磁盘占用情况,帮助识别大文件和缓存条目,便于用户释放存储空间。

对于需要管理 macOS 系统资源、进行系统清理或监控的团队成员来说,Mole 是一个实用的工具选择。

🐎 30 分钟解决 Claude 封号问题:程序员的终极自救指南

@阿权:文章详细介绍如何通过自建 VPS 解决 Claude 封号问题,包含完整的服务器搭建、客户端配置和开发工具设置步骤,让你稳定使用 Claude Code 等 AI 开发工具。

关键解决的痛点是:IP 不纯净。使用公共代理(机场 / VPN)易触发 Claude 风控封号。解决思路也很简单:自建 VPS。

代码

🐎 MachOSwiftSection: 🔬 A Swift library for parsing mach-o files to obtain Swift information.

@Kyle-Ye: MachOSwiftSection 是一个用于解析 Mach-O 文件并提取 Swift 元数据信息的 Swift 库。它基于 MachOKit 扩展实现 , 可以从编译后的二进制文件中提取协议描述符(Protocol Descriptors)、协议遵循关系(Protocol Conformance)和类型上下文描述符(Type Context Descriptors)等核心信息。该库同时提供了 Swift 包和命令行工具 swift-section,支持对二进制文件进行多架构分析和信息导出。对于需要进行逆向工程、安全分析或从编译产物生成 Swift 接口文件的开发者来说,这是一个实用的底层工具。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

  • [北京/上海] 京东 - iOS/Android/鸿蒙/前端

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

安康记1.1.x版本发布

安康记新版本已经上线App Store。目前已经更新了独立用药功能,方便一些简单的小毛病可能会自己用药,可以跳过就诊直接进行记录。

图片

原本这个功能已经上线了有几天了,但因为一直测试发现有一些新的问题,这两周也提交了多次修复版本,目前算是相对稳定了,希望各位能更新体验♪(・ω・)ノ

swift的inout的用法

基础用法底层原理高级特性注意事项四个方面详细讲解。

1. 基础概念:为什么要用 inout?

在 Swift 中,函数的参数默认是常量(Constant/let) 。这意味着你不能在函数内部修改参数的值。

错误示例:

func doubleValue(value: Int) {
    value *= 2 // ❌ 报错:Left side of mutating operator isn't mutable: 'value' is a 'let' constant
}

如果你希望函数能修改外部传进来的变量,就需要使用 inout

正确示例:

func doubleValue(value: inout Int) {
    value *= 2
}

var myNumber = 10
// 调用时必须在变量前加 '&' 符号,显式表明这个值会被修改
doubleValue(value: &myNumber) 

print(myNumber) // 输出:20

2. 核心原理:输入输出模型 (Copy-In Copy-Out)

这是面试或深入理解时最重要的部分。虽然 inout 看起来像“引用传递”,但 Swift 官方将其描述为 Copy-In Copy-Out(输入复制,输出复制) ,也就是“值结果模式(Call by Value Result)”。

完整过程如下:

  1. Copy In(输入复制): 当函数被调用时,参数的值被复制一份传入函数内部。
  2. Modification(修改): 函数内部修改的是这个副本
  3. Copy Out(输出复制): 当函数返回时,修改后的副本值被**赋值(写回)**给原本的变量。

底层优化:

  • 对于物理内存中的变量:编译器通常会进行优化,直接传递内存地址(也就是真正的引用传递),避免不必要的复制开销。
  • 对于计算属性(Computed Properties) :必须严格执行 Copy-In Copy-Out 流程(因为计算属性没有物理内存地址,只有 getter 和 setter)。

代码证明(计算属性也能用 inout):

struct Rect {
    var width = 0
    var height = 0
    
    // 计算属性:面积
    var area: Int {
        get { width * height }
        set { 
            // 简单逻辑:假设保持 width 不变,调整 height
            height = newValue / width 
        }
    }
}

func triple(number: inout Int) {
    number *= 3
}

var square = Rect(width: 10, height: 10) // area = 100

// 这里传入的是计算属性 area
// 流程:
// 1. 调用 area 的 get,得到 100,Copy In 给 triple
// 2. triple 将 100 * 3 = 300
// 3. 函数结束,将 300 Copy Out,调用 area 的 set(300)
triple(number: &square.area)

print(square.height) // 输出:30 (因为 300 / 10 = 30)

3. inout 的常见应用场景

A. 交换值 (Standard Swap)

Swift 标准库的 swap 就是用 inout 实现的。

func mySwap<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}

var x = 1
var y = 2
mySwap(&x, &y)
print("x: (x), y: (y)") // x: 2, y: 1

B. 修改复杂的结构体 (Mutating Structs)

当结构体嵌套很深时,使用 inout 可以避免冗长的赋值代码。

struct Color {
    var r: Int, g: Int, b: Int
}

struct Settings {
    var themeColor: Color
}

var appSettings = Settings(themeColor: Color(r: 0, g: 0, b: 0))

// 能够直接修改嵌套深处的属性
func updateBlueComponent(color: inout Color) {
    color.b = 255
}

// 传入路径
updateBlueComponent(color: &appSettings.themeColor)

print(appSettings.themeColor.b) // 255

4. 关键规则与内存安全 (Memory Safety)

这是 Swift 相比 C++ 指针更先进的地方。Swift 编译器会强制执行独占访问权限(Law of Exclusivity) ,防止内存冲突。

规则 1:同一个变量不能同时作为两个 inout 参数传递

如果两个 inout 参数指向同一个变量,会发生“别名(Aliasing)”问题,导致行为不可预测。

var step = 1

func increment(_ number: inout Int, by amount: inout Int) {
    number += amount
}

// ❌ 运行时崩溃或编译错误:Simultaneous accesses to 0x...
// increment(&step, by: &step) 

规则 2:不能将 let 常量或字面量作为 inout 参数

因为它们本质上不可写。

Swift

func change(val: inout Int) {}

// change(val: &5) // ❌ 错误:字面量不可变
let num = 10
// change(val: &num) // ❌ 错误:常量不可变

规则 3:inout 参数在闭包中的捕获(Capture)

inout 参数在逃逸闭包(Escaping Closure)中是不能被捕获的,因为逃逸闭包可能在函数返回后才执行,而那时 inout 的生命周期(Copy-In Copy-Out 过程)已经结束了。

func performAsync(action: @escaping () -> Void) {
    // 异步执行...
}

func badFunction(x: inout Int) {
    // ❌ 错误:Escaping closure captures 'inout' parameter 'x'
    /*
    performAsync {
        x += 1 
    }
    */
}

解决办法: 使用非逃逸闭包,或者显式地捕获变量的副本(如果逻辑允许)。


5. inout vs 类 (Reference Types)

这是一个常见的误区: “类本来就是引用类型,还需要 inout 吗?”

  • 不需要 inout 如果你只想修改类实例内部的属性。
  • 需要 inout 如果你想替换掉整个类实例本身(即改变指针的指向)。

代码对比:

class Hero {
    var name: String
    init(name: String) { self.name = name }
}

// 情况 1:修改内部属性(不需要 inout)
func renameHero(hero: Hero) {
    hero.name = "Batman" // 合法,因为 hero 引用本身没变,变的是堆内存里的数据
}

var h1 = Hero(name: "Superman")
renameHero(hero: h1)
print(h1.name) // Batman

// 情况 2:替换整个实例(需要 inout)
func switchHero(hero: inout Hero) {
    hero = Hero(name: "Iron Man") // 将外部变量指向全新的内存地址
}

var h2 = Hero(name: "Spiderman")
switchHero(hero: &h2)
print(h2.name) // Iron Man

总结

  1. 语法: 定义用 inout,调用用 &
  2. 本质: Copy-In Copy-Out(值结果模式),但在物理内存操作上通常优化为引用传递。
  3. 使用场景: 需要在函数内部修改外部值类型(Struct/Enum)状态,或交换数据。
  4. 限制: 遵守独占访问原则(Exclusivity),不可在逃逸闭包中捕获。

iOS 实现微信读书的仿真翻页

先看效果

仿真翻页效果: 在这里插入图片描述

普通翻页效果:

在这里插入图片描述

实现方案

iOS 中实现翻页效果比较简单,直接使用系统提供的 UIPageViewController 即可做到。

UIPageViewController 是 UIKit 中的分页控制器,它允许用户通过横向或纵向滑动手势在多个页面(ViewController)之间切换,主要配置的两个属性如下:

1)UIPageViewControllerTransitionStyle

  • .pageCurl:仿真翻页
  • .scroll:类似 UIScrollView 自然滑动

2)UIPageViewControllerNavigationOrientation

  • .horizontal:左右翻页
  • .vertical:上下翻页

以仿真翻页配置为例子:

class BookReaderViewController: UIViewController {

    // 模拟书籍数据
    private let bookPages = [
        "第一章:Swift 的起源\n\nSwift 是一种由 Apple 开发的强大且直观的编程语言...",
        "第二章:UIKit 基础\n\nUIKit 提供了构建 iOS 应用程序所需的关键对象...",
        "第三章:动画艺术\n\n核心动画 (Core Animation) 是 iOS 界面流畅的关键...",
        "第四章:高级翻页\n\nUIPageViewController 是实现仿真翻页的神器...",
        "终章:未来展望\n\n随着 SwiftUI 的普及,声明式 UI 正在改变世界..."
    ]

    private var pageViewController: UIPageViewController!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupPageViewController()
    }

    private func setupPageViewController() {
        // 关键设置:transitionStyle = .pageCurl (仿真翻页效果)
        // navigationOrientation = .horizontal (水平翻页)
        pageViewController = UIPageViewController(transitionStyle: .pageCurl,
                                                  navigationOrientation: .horizontal,
                                                  options: nil)

        pageViewController.dataSource = self
        pageViewController.delegate = self

        // 设置初始页面
        if let firstPage = getViewController(at: 0) {
            pageViewController.setViewControllers([firstPage], direction: .forward, animated: false, completion: nil)
        }

        // 将 PageVC 添加到当前 VC
        addChild(pageViewController)
        view.addSubview(pageViewController.view)
        pageViewController.view.frame = view.bounds
        pageViewController.didMove(toParent: self)

        // 解决仿真翻页背面颜色问题 (让背面也是纸张色,而不是默认的半透明或白色)
        // 注意:这是一个比较 Hack 的方法,更完美的做法是自定义背面的 Layer
        pageViewController.view.backgroundColor = UIColor(red: 248/255, green: 241/255, blue: 227/255, alpha: 1.0)
    }

    // 辅助方法:根据索引获取 VC
    private func getViewController(at index: Int) -> BookPageViewController? {
        guard index >= 0 && index < bookPages.count else { return nil }
        return BookPageViewController(index: index, totalPage: bookPages.count, content: bookPages[index])
    }
}

// MARK: - 3. DataSource 实现 (核心逻辑)
extension BookReaderViewController: UIPageViewControllerDataSource {

    // 获取"上一页"
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let currentVC = viewController as? BookPageViewController else { return nil }
        let previousIndex = currentVC.pageIndex - 1
        return getViewController(at: previousIndex)
    }

    // 获取"下一页"
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let currentVC = viewController as? BookPageViewController else { return nil }
        let nextIndex = currentVC.pageIndex + 1
        return getViewController(at: nextIndex)
    }
}

// MARK: - 4. Delegate (可选,用于处理翻页后的状态)
extension BookReaderViewController: UIPageViewControllerDelegate {
    // 这里可以处理 spineLocation,例如横屏时显示双页
    func pageViewController(_ pageViewController: UIPageViewController, spineLocationFor orientation: UIInterfaceOrientation) -> UIPageViewController.SpineLocation {
        // 手机竖屏通常是单页 (.min)
        return .min
    }
}

如上不到百行的代码即可实现仿真翻页效果,手势在 UIPageViewController 中会自动处理,外部不用感知。

和 UITableView 使用类似,需要通过 UIPageViewControllerDataSource 来提供上一页和下一页的数据源,通过 UIPageViewControllerDelegate 来感知翻页时机。

UIPageViewControllerDelegate 主要提供三个时机:

1)willTransitionTo:当用户开始滑动翻页的时候触发,系统已经准备好目标页,通过该回调来告诉你将要显示哪个页面(pendingViewControllers)

/// pendingViewControllers: 将要显示的页面
func pageViewController(_ pageViewController: UIPageViewController,
                        willTransitionTo pendingViewControllers: [UIViewController])

2)didFinishAnimating:当用户的翻页动画结束时回调

/// finished: 动画是否完成
/// previousViewControllers: 原来显示的ViewController
/// completed: 最终是否翻页成功;比如滑一半又拖回去,不会真正翻页
func pageViewController(_ pageViewController: UIPageViewController,
                        didFinishAnimating finished: Bool,
                        previousViewControllers: [UIViewController],
                        transitionCompleted completed: Bool)

3)spineLocationFor:控制仿真书本翻页时 “书脊” 的位置,只在 UIPageViewControllerTransitionStyle 为 pageCurl 时有效

/// SpineLocation:
/// - none: 没书脊
/// - min: 书脊在左边(单页模式)
/// - mid: 书脊在中间(双页模式)
/// - max: 书脊在右边(单页模式)
func pageViewController(_ pageViewController: UIPageViewController,
                        spineLocationFor orientation: UIInterfaceOrientation)
-> UIPageViewController.SpineLocation

如果要配置普通翻页效果,只需要修改 UIPageViewController 的配置即可:

// Options: 设置页面之间的间距 (微信读书一般有 10-20pt 的间距)
let options: [UIPageViewController.OptionsKey: Any] = [
    .interPageSpacing: 20
]

// 核心修改 1: transitionStyle 改为 .scroll
pageViewController = UIPageViewController(transitionStyle: .scroll,
                                          navigationOrientation: .horizontal,
                                          options: options)

另外翻页手势通常和系统的侧滑返回手势有冲突,可以手动禁用手势来解决;类似微信读书一样,在导航栏出现时才开启侧滑返回手势,否则禁用侧滑返回:

private func updateGesture() {
    if isNaviBarHidden { // 导航栏隐藏:禁用侧滑,开启翻页手势
        navigationController?.interactivePopGestureRecognizer?.isEnabled = false
        for gesture in pageViewController.gestureRecognizers {
            gesture.isEnabled = true
        }
    } else { // 导航栏显示:开启侧滑,禁用翻页手势
        navigationController?.interactivePopGestureRecognizer?.isEnabled = true
        for gesture in pageViewController.gestureRecognizers {
            gesture.isEnabled = false
        }
    }
}

WKWebView的重定向(objective_c)

背景

第三方支付回调时需要重定向到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 语音房(拍卖房)开发实践

本文基于一个真实的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

示例程序

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

示例

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

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

欧陆风云5的游玩笔记

最近一个月共玩了 270 小时的欧陆风云5 ,这两天打算停下来。最近在游戏后期打大战役时,交互已经卡得不行。我已经是 i9-14900K 的 CPU ,估计升级硬件已经无法解决这个问题,只能等版本更新优化了。

ps. 其实只要把游戏暂停下来立刻就不卡了。虽然我直到这个游戏需要的计算量非常大,但是卡交互操作肯定是实现的不对。因为这并不是因为渲染负荷造成的卡顿,可以让游戏时间流逝更慢一些,也不应该让鼠标点击后的界面弹出时间变长。

在暂置游戏前,我先把一些关于游戏设计上的理解先记录下来。也是对上一篇的补充

在最初几十小时的游戏时间里,我一直想确认游戏经济系统的基础逻辑。和很多类似策略游戏不同,欧陆风云5 在游戏一开始,展现给玩家的是一个发展过(或者说是设定出来)的经济系统版图。玩家更需要了解的是他选择扮演的国家在当下到底面临什么问题,该怎样解决。这不只是经济,也包括政治、文化和军事。而很多游戏则是设定好规则,让玩家从零开始建设,找到合适的发展路径。

大多数情况下,EU5 玩家一开始考虑的并不是从头发展,所以在游戏新手期也没有强烈的理解游戏底层设计细节的动机。不过游戏也有开荒玩法,在游戏中后期势必会在远方殖民、开拓新大陆;甚至游戏还设计了让玩家直接转换视角以新殖民地为核心来继续游戏。但即使的重新殖民,在四周鸟无人烟的地方开荒,和在已有部分发展的区域附近拓展也完全不同。

我十分好奇这样一个复杂的经济系统是怎样启动起来的,所以仔细做了一点归纳笔记。不一定全对,但很多信息在游戏内的说明和目前的官方 wiki 都不完整,只能自己探索。


游戏中的一切来源于“原产”,官方称为 ROG ,比较类似异星工厂里的矿石。上层的一切都是从原产直接或间接获得。版图上的任何一个最小单位的地块,只要上面有人口,就会不断生产出唯一品种的原材料进入这个世界。它和国家控制力、市场接入度都无关系。比原材料更高级的产品都是由原产直接或间接转换而来。

货币本身在世界中不以资源形式存在,货币本身也没有价值。货币的存在在于推动包括原产在内的原材料和产品等在世界中的流动。所以,世界中即使不存在经济活动、没有货币,亦或是货币急剧膨胀,这些因为国家破产而债务消失等让货币总值急剧变化的行为也不会直接影响这个世界中的物资变化。即没有很多游戏中直接用钱凭空兑换成物资的途径。

换句话说,如果整个世界缺铁,那么只能通过生产手段慢慢的产出,再多的钱也无法变出铁来。但分配更多的人力去生产、更高的科技水平可以获得铁产量的提升、使用更高效的配方、各种提升生产率的增益等等都可以加快铁的产出速度。

从一个世界的局部看(这是一般玩家的视野),获得原材料的方式有三种:

  1. 养活更多的劳工或奴隶开发对应原产。
  2. 在合适的地理位置上用劳动或奴隶生产。
  3. 从附近的市场进口。

第一种方式,玩家拥有对应原产地,然后在地皮上增加人口。但新增人口是农民,还需要从农民升级成劳工。国家 buf 中,默认只有原住民满意度对产量有轻微的增益。

第二种方式,玩家有更大的自主性。以铁为例,只要是湿地地形或者地皮邻接湖泊,就可以主动产铁。这种生产除同样需要劳工外,还有原料开销:把炭转换为铁。这种生产方式直接被市场接入率打折,即离市场越远的地方单位人口的生产效率越低,但同时有更多增加生产率的增益途径:最基本的就有当地劳工识字率和市民满意度。和虽然和第一种方式一样需要劳工,但游戏似乎会先满足原产需求的劳工,多出部分才进行建筑生产。所以在劳工不足时,若需进行建筑生产,需要主动减少原产等级。因为生产建筑可以由玩家主动关闭,但原产似乎不行。

第三种方式,通常需要在本地市场拥有一定的市场容量。在不考虑成本时,甚至可以亏本进货。对开荒来说,进口原料比进口成品的优势就在于占用更少的贸易容量。

为什么上面以铁举例,因为铁是开荒时最重要的资源。虽然木头和石头也很重要,但游戏把木材、粘土、沙、石头设定为一般物资,所有地块都有一个很低的默认产能,从市场角度看,根据市场规模,每个市场总有一定量的供给。但铁不属于这种物资。

建造建筑需要的基本材料是砖头,砖头可以通过基础建筑,从粘土或石头转换。

开发原产需要的基本材料是木头或工具。大多数基础建筑的生产配方里都需要工具。而工具在非城市生产建筑中,只有乡村市场可以把铁转换为工具。所以、如果开荒时的市场中缺铁,就只能通过进口。进口制造工具的铁比进口工具更能利用上贸易容量(铁和工具的单位贸易容量相同,但铁到工具以 4:5 转换)。

铁矿在版图上相对其它资源更少,所以一般开荒需更关注市场覆盖下有无湿地地形或有无湖泊,同时需要用充足的木材供应,可以把木材转换为炭再转换为铁。

一旦单一地块上的人口超过 5000 ,就可以升级为城镇,这种生产就有更多选择。以工具制造来说,城镇里就多出了用石头或铜转换为工具的配方。尤其是石制工具的途径,虽然效率很低,通常利润也很低,但贵在石头有保底产出。城镇的升级需要砖头和玻璃,玻璃可通过沙子转换,而沙子有保底产出或通过工具加木材转换。

开荒期间,解决了木头、砖头、玻璃、工具这四种基本货物(前三种是各种基础建筑建设需求,最后是大部分生产转换配方的必须)后,就要考虑提高产能的问题,这里的核心之一是纸。因为劳工识字率影响着生产率。纸是印书的原料、书是图书馆的维持品,而图书馆以及更多提高识字率相关的建筑都需要书。

造纸术需要纤维作物或皮革或布匹。纤维作物的基本生产方法是在对应农场通过牲畜木材工具制造;而牲畜则在耕种村落通过工具加粘土转换;皮革则可以在森林村落通过沙加焦油和野味转换,其中焦油在一般木材产区都可以通过木材转换得到。

另一个重要的资源是人口。它在游戏中和钱一样重要、甚至更重要。因为一切的生产行为都需要人。对开荒而言,升级到城市对效率影响最大,这里的硬性要求就是 5000 基础人口。除了主动殖民,就是本地土著转换、周边迁移(集中)以及自然生长率。这些都可以通过内阁行为略微加速,同样关键是修定居点(同时加移民吸引度和自然生长率)。定居点除了 250 农民外的维持成本是石头、木头、羊毛、野味。前两个一定有保底产出,后两个不是必须,缺少只会让效率打折,但供应充足可以发挥全部性能。定居点和乡村市场都占用农民,在最初阶段,我感觉乡村市场更重要一些,毕竟可以制造工具,还能提供宝贵的贸易容量。

不同阶层人口除了产生固定需求(吃掉本地市场的部分产出)外,更基本的需求是食物。EU5 中的食物系统设计,我觉得也是很巧妙的。

食物并不是货物的一种,而是和钱一样,表示为一个单一值。最主要的食物来源是生产带食物属性的货物(被称为食物原料)的副产品。属于食物原料的货物,被设定了不同的食物倍率,这就让有些食物原料产生食物的效率更高。不工作的劳工默认会以一个较低的产能生产食物,所以不必担心多出来的劳动力被浪费。另外,农民在森林村落里,虽然产品皮革并非食物原料,但生产行为本身被设定了食物产能。另外,农村相对城市有额外的食物产能的乘数增益。

食物的仓储按省份为单位计算,省份归属的若干地块共享一个食物仓库。在每个省份会优先填满仓亏,多出的部分卖给了所属市场,这里是不计市场接入度的。而一旦仓库有空间,就会从所属市场购买。战争是影响它的一个变数。因为军队也会从这个仓库中获取食物,围攻会阻止市场上的食物交易。

食物在本地市场上的交易行为会影响到本地食物价格,购买食物的开销和销售食物的收入是分开计算的,全部通过国库完成。市场间并不能单独交易食物,但通过对有食物原料属性的商品的交易,会产生附带的食物流通(但并不会产生额外的货币流动)。我觉得理论上会出现市场仓库中的食物储量为 0 ,但依然出口食物原料的情况,但实际玩的时候并没有发现,所以不清楚游戏怎样处理。但我猜测,食物流通是在单独层面计算的。既然超出市场食物容量的食物似乎就消失了,那么也可以接受万一食物储量为 0 却继续出口的情况,把储量设为 0 即可。


最后,写写我对税收的看法。

简单说,游戏里的经济活动产生了税基。税基中按王室影响力直接把钱进入国库,另外的钱按阶层影响力分到了各阶层。但玩家可以对阶层分得的钱征税,让这些钱进入国库。

看起来,在不造成阶层不满的前提下,税率越高,国库收入就越高。但实际我玩的感觉是,其实税基才是整个国家的收入,国库仅仅是玩家可以主动调配的部分。阶层保留更多的收入,也会投入到国家发展中去,只不过有时不是玩家想要的方向,甚至是负方向。例如当玩家想削弱某个阶层的影响力时,阶层把钱投入都修建扩大本阶层影响力的建筑上。但总的来说,如果国库钱够用,更低的税收更好。因为税基相同时,税收影响的是分配。低税收必定增加阶层满意度,带来的正面增益是额外的。正所谓藏富于民。

而影响税基最重要的是地区控制度。当然地区控制度不仅仅影响税基,还影响了更多建筑的效率。从这个意义上来说,地方分权比中央集权更有利于经济发展。分封属国,尤其是朝贡国,比大一统国家会获得更好的经济局面。

但权力分配在游戏中也相当重要,因为它直接影响调配价值观的能力。价值观在一盘游戏进程中必须配合时代发展而演变才能更好的发展经济。而集权以及王室影响力是权利分配能力的来源。

所以说,最终玩整个游戏的体验还是在和面,只是多出了一份历史感。有了真实历史这种后验知识,才更为有趣。

flutter 集成flutter_Boost

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如何真正成为便民好帮手?

你是否曾在微信、支付宝、各个政务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 图纸标注功能的实现:踩坑与架构设计

写在前面

最近在做一个工程验收的项目,有个需求是要在 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:生命周期、动画优化与性能调优

在日常开发中,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 开发指南

目录

  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

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

最新新上架一个产品,但是由于有些三方库没有隐私清单的问题导致提交到苹果后台之后总是会提示二进制无效,这里特别说明一下,如果你的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这个库没有隐私清单的警告了。

iOS Audio后台模式下能否执行非Audio逻辑

测试设备:iPhone 13mini / iOS 26

验证方法

  1. 开启Audio, AirPlay, and Picture in Picture模式,播放声音
  2. 执行与声音无关的代码和网络请求逻辑,并写入日志文件,代码如下所示
  3. 让App进入后台,App持续播放声音,过一段时间(5分钟)回到App前台,检查日志文件中是否预期内容
private func test() {
    var sum = 0
    timer?.invalidate()
    timer = YYTimer(timeInterval: 1, repeats: true, block: { t in
        sum += 1
                
        // 验证代码执行
        DDLogInfo("[BackgroundTest] Timer fired. Sum: \(sum)")
                
        // 验证网络请求
        let url = URL(string: "https://www.baidu.com")!
        URLSession.shared.dataTask(with: url) { _, response, error in
            if let error = error {
                DDLogInfo("[BackgroundTest] Network failed: \(error.localizedDescription)")
            } else if let httpResponse = response as? HTTPURLResponse {
                DDLogInfo("[BackgroundTest] Network success. Status: \(httpResponse.statusCode)")
            }
        }.resume()
    })
}

日志结果:

2025-11-27 10:23:13.575 [INFO] [BackgroundTest] Timer fired. Sum: 5
2025-11-27 10:23:13.612 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:23:14.008 [INFO] Report OnlineStatus background, badge number 1 // 进入后台
2025-11-27 10:23:14.582 [INFO] [BackgroundTest] Timer fired. Sum: 6
2025-11-27 10:23:14.612 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:23:15.586 [INFO] [BackgroundTest] Timer fired. Sum: 7
2025-11-27 10:23:15.622 [INFO] [BackgroundTest] Network success. Status: 200
.....//省略
2025-11-27 10:28:19.578 [INFO] [BackgroundTest] Timer fired. Sum: 311
2025-11-27 10:28:19.610 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:28:20.579 [INFO] [BackgroundTest] Timer fired. Sum: 312
2025-11-27 10:28:20.611 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:28:21.574 [INFO] [BackgroundTest] Timer fired. Sum: 313
2025-11-27 10:28:21.602 [INFO] [BackgroundTest] Network success. Status: 200
2025-11-27 10:28:22.449 [INFO] Report OnlineStatus foreground                 // 回到前台
2025-11-27 10:28:22.573 [INFO] [BackgroundTest] Timer fired. Sum: 314
2025-11-27 10:28:22.600 [INFO] [BackgroundTest] Network success. Status: 200

结论

  • 开启Audio, AirPlay, and Picture in Picture模式,开启声音,进入后台,可以正常执行与声音无关的代码逻辑和网络请求

注:仅为测试结果,不排除系统会做任何形式的拦截或中断

Swift的Extension简单说明

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开发的扛把子~

打破 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审核人员进行了一场视频会议特此记录。

❌