安康记1.1.x版本发布
安康记新版本已经上线App Store。目前已经更新了独立用药功能,方便一些简单的小毛病可能会自己用药,可以跳过就诊直接进行记录。
原本这个功能已经上线了有几天了,但因为一直测试发现有一些新的问题,这两周也提交了多次修复版本,目前算是相对稳定了,希望各位能更新体验♪(・ω・)ノ
安康记新版本已经上线App Store。目前已经更新了独立用药功能,方便一些简单的小毛病可能会自己用药,可以跳过就诊直接进行记录。
原本这个功能已经上线了有几天了,但因为一直测试发现有一些新的问题,这两周也提交了多次修复版本,目前算是相对稳定了,希望各位能更新体验♪(・ω・)ノ
基础用法、底层原理、高级特性和注意事项四个方面详细讲解。
在 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
这是面试或深入理解时最重要的部分。虽然 inout 看起来像“引用传递”,但 Swift 官方将其描述为 Copy-In Copy-Out(输入复制,输出复制) ,也就是“值结果模式(Call by Value Result)”。
完整过程如下:
底层优化:
代码证明(计算属性也能用 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)
inout 的常见应用场景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
当结构体嵌套很深时,使用 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
这是 Swift 相比 C++ 指针更先进的地方。Swift 编译器会强制执行独占访问权限(Law of Exclusivity) ,防止内存冲突。
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)
let 常量或字面量作为 inout 参数因为它们本质上不可写。
Swift
func change(val: inout Int) {}
// change(val: &5) // ❌ 错误:字面量不可变
let num = 10
// change(val: &num) // ❌ 错误:常量不可变
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
}
*/
}
解决办法: 使用非逃逸闭包,或者显式地捕获变量的副本(如果逻辑允许)。
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
inout,调用用 &。仿真翻页效果:
普通翻页效果:
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
}
}
}
第三方支付回调时需要重定向到app的某个页面,比如支付完成后回到原生订单详情页,这个时间会有两种情况:
1、直接在web页面重定向到app的订单详情页,这个时候只需要实现 WKNavigationDelegate 中的一个核心方法webView:decidePolicyForNavigationAction:decisionHandler: 方法。
2、在支付中心跳转到第三方app然后支付完成后需要跳转回自己的app的订单详情页,这个时候可以采用Scheme方式或者是通用链接的方式解决
实现这一目标,您需要让您的 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(), ^{
/// 做跳转
});
}
}
}
第三方支付平台完成支付后,是通过你App的 URL Scheme 来唤醒你的App并携带支付结果的。
操作: 在 Xcode 项目的 Info.plist 或项目设置的 Info 选项卡下的 URL Types 中添加你的 App 的 Scheme。
myscheme。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 链接时:
AppDelegate 中的这个方法。这个机制的主要优点是安全(基于 HTTPS)和用户体验更好(避免了 URL Scheme 引起的跳转确认和安全问题)。
这是通用链接能够工作的基础。您需要在您的 Web 服务器上创建一个特殊的 JSON 文件,告诉 iOS 系统哪些路径应该由您的 App 处理。
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 路径。部署位置: 将此文件上传到您的域名根目录或 .well-known/ 目录下。
https://yourdomain.com/apple-app-site-association
https://yourdomain.com/.well-known/apple-app-site-association
内容类型: 确保服务器以正确的 MIME 类型提供此文件:application/json 或 text/plain。
HTTPS: 您的整个网站必须使用 HTTPS。
在 Xcode 中为您的 App 开启 Associated Domains 功能。
路径: Xcode -> 项目设置 -> 目标 (Target) -> Signing & Capabilities 选项卡
操作: 点击 + Capability,添加 Associated Domains。
添加域名: 在列表中添加您的域名,格式为:
applinks:yourdomain.com
注意: 不带
https://或http://。
当用户点击一个通用链接并唤醒 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语音房项目案例,详细讲解如何使用状态模式来管理复杂的业务流程,以及如何与权限中心协同工作,因为在拍卖房间中不只有不同的房间阶段变化(状态)还有不同角色拥有不同的权限(权限中心)。
拍拍房是一个实时拍卖房间系统,类似于语音房间+拍卖的结合体。用户可以在房间内:
一个完整的拍卖流程需要经历4个明确的阶段:
准备阶段 → 上拍 → 拍卖中 → 定拍 → (循环)准备阶段
每个阶段都有:
如果使用传统的 if-else 或 switch-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
}
}
问题显而易见:
// 状态模式:每个状态独立处理
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
}
}
优势明显:
┌─────────────────────────────────────────┐
│ 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 │
└──────┘ └────────┘ └────────┘ └────────┘
enum RoomState: String {
case preparing // 准备阶段
case listing // 上拍
case auctioning // 拍卖中
case closed // 定拍
}
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 │
└─────────────┘
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 "准备阶段:拍卖人可以上传物品并设置规则"
}
}
关键点:
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 "上拍中:展示拍卖物品,倒计时后自动开始"
}
}
关键点:
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 "拍卖中:竞拍者可以出价,倒计时结束后定拍"
}
}
关键点:
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. 创建房间(自动进入准备阶段)
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)") // 准备中
// 尝试在准备阶段出价
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
let room = Room(...)
// ✅ 正确的转换
room.state // .preparing
room.stateObject.startAuction(room: room)
room.state // .listing → .auctioning
// ❌ 不允许跳过状态
room.state // .preparing
room.stateObject.endAuction(room: room)
// 输出:"拍卖还未开始"
// 状态不变,仍然是 .preparing
对比:
传统方式(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行
修改"拍卖中"的逻辑:
AuctioningState.swift,放心修改新增"暂停"状态:
PausedState.swift,不修改现有代码// 可以单独测试某个状态
func testAuctioningState() {
let state = AuctioningState()
let room = MockRoom()
let result = state.placeBid(room: room, user: mockUser, amount: 100)
XCTAssertTrue(result)
}
多人开发时:
PreparingState
AuctioningState
ClosedState
互不干扰,Git冲突少。
应对:合理的文件组织和命名规范
需要仔细设计状态转换图,避免:
应对:
状态对象是无状态的,数据存储在Room对象中:
class Room {
var stateObject: RoomStateProtocol // 当前状态对象
var currentItem: AuctionItem? // 状态间共享的数据
var currentBid: Bid? // 状态间共享的数据
}
应对:
调用链变长:
ViewController → RoomManager → PermissionCenter → StateObject
应对:
// ❌ 错误:状态对象持有数据
class AuctioningState {
var currentPrice: Decimal = 0 // 不应该在这里
var bidHistory: [Bid] = [] // 不应该在这里
}
// ✅ 正确:数据存储在上下文中
class Room {
var currentPrice: Decimal
var bidHistory: [Bid]
var stateObject: RoomStateProtocol
}
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)")
}
}
func changeState(to newState: RoomState) {
let oldState = self.state
self.state = newState
// 记录状态转换
print("🔄 状态转换:(oldState.displayName) → (newState.displayName)")
// 可以添加到数据库或分析系统
Analytics.trackStateChange(from: oldState, to: newState)
}
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
}
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(...)
}
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)
}
}
✅ 适合使用的场景:
❌ 不适合使用的场景:
在拍拍房项目中,状态模式:
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)) |
.drawingGroup() 以 Metal 合成,降低 CPU 负担。.accessibilityHidden(true),避免 VoiceOver 读出「图像」。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) 配合可实现「先缩再放」效果 |
在 Xcode 中,Bundle Identifier(包标识符) 是一个唯一标识你 App 的字符串,它在整个 Apple 生态系统中用于区分你的应用
注意事项
@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 就是程序显示给用户的名称
最近一个月共玩了 270 小时的欧陆风云5 ,这两天打算停下来。最近在游戏后期打大战役时,交互已经卡得不行。我已经是 i9-14900K 的 CPU ,估计升级硬件已经无法解决这个问题,只能等版本更新优化了。
ps. 其实只要把游戏暂停下来立刻就不卡了。虽然我直到这个游戏需要的计算量非常大,但是卡交互操作肯定是实现的不对。因为这并不是因为渲染负荷造成的卡顿,可以让游戏时间流逝更慢一些,也不应该让鼠标点击后的界面弹出时间变长。
在暂置游戏前,我先把一些关于游戏设计上的理解先记录下来。也是对上一篇的补充。
在最初几十小时的游戏时间里,我一直想确认游戏经济系统的基础逻辑。和很多类似策略游戏不同,欧陆风云5 在游戏一开始,展现给玩家的是一个发展过(或者说是设定出来)的经济系统版图。玩家更需要了解的是他选择扮演的国家在当下到底面临什么问题,该怎样解决。这不只是经济,也包括政治、文化和军事。而很多游戏则是设定好规则,让玩家从零开始建设,找到合适的发展路径。
大多数情况下,EU5 玩家一开始考虑的并不是从头发展,所以在游戏新手期也没有强烈的理解游戏底层设计细节的动机。不过游戏也有开荒玩法,在游戏中后期势必会在远方殖民、开拓新大陆;甚至游戏还设计了让玩家直接转换视角以新殖民地为核心来继续游戏。但即使的重新殖民,在四周鸟无人烟的地方开荒,和在已有部分发展的区域附近拓展也完全不同。
我十分好奇这样一个复杂的经济系统是怎样启动起来的,所以仔细做了一点归纳笔记。不一定全对,但很多信息在游戏内的说明和目前的官方 wiki 都不完整,只能自己探索。
游戏中的一切来源于“原产”,官方称为 ROG ,比较类似异星工厂里的矿石。上层的一切都是从原产直接或间接获得。版图上的任何一个最小单位的地块,只要上面有人口,就会不断生产出唯一品种的原材料进入这个世界。它和国家控制力、市场接入度都无关系。比原材料更高级的产品都是由原产直接或间接转换而来。
货币本身在世界中不以资源形式存在,货币本身也没有价值。货币的存在在于推动包括原产在内的原材料和产品等在世界中的流动。所以,世界中即使不存在经济活动、没有货币,亦或是货币急剧膨胀,这些因为国家破产而债务消失等让货币总值急剧变化的行为也不会直接影响这个世界中的物资变化。即没有很多游戏中直接用钱凭空兑换成物资的途径。
换句话说,如果整个世界缺铁,那么只能通过生产手段慢慢的产出,再多的钱也无法变出铁来。但分配更多的人力去生产、更高的科技水平可以获得铁产量的提升、使用更高效的配方、各种提升生产率的增益等等都可以加快铁的产出速度。
从一个世界的局部看(这是一般玩家的视野),获得原材料的方式有三种:
第一种方式,玩家拥有对应原产地,然后在地皮上增加人口。但新增人口是农民,还需要从农民升级成劳工。国家 buf 中,默认只有原住民满意度对产量有轻微的增益。
第二种方式,玩家有更大的自主性。以铁为例,只要是湿地地形或者地皮邻接湖泊,就可以主动产铁。这种生产除同样需要劳工外,还有原料开销:把炭转换为铁。这种生产方式直接被市场接入率打折,即离市场越远的地方单位人口的生产效率越低,但同时有更多增加生产率的增益途径:最基本的就有当地劳工识字率和市民满意度。和虽然和第一种方式一样需要劳工,但游戏似乎会先满足原产需求的劳工,多出部分才进行建筑生产。所以在劳工不足时,若需进行建筑生产,需要主动减少原产等级。因为生产建筑可以由玩家主动关闭,但原产似乎不行。
第三种方式,通常需要在本地市场拥有一定的市场容量。在不考虑成本时,甚至可以亏本进货。对开荒来说,进口原料比进口成品的优势就在于占用更少的贸易容量。
为什么上面以铁举例,因为铁是开荒时最重要的资源。虽然木头和石头也很重要,但游戏把木材、粘土、沙、石头设定为一般物资,所有地块都有一个很低的默认产能,从市场角度看,根据市场规模,每个市场总有一定量的供给。但铁不属于这种物资。
建造建筑需要的基本材料是砖头,砖头可以通过基础建筑,从粘土或石头转换。
开发原产需要的基本材料是木头或工具。大多数基础建筑的生产配方里都需要工具。而工具在非城市生产建筑中,只有乡村市场可以把铁转换为工具。所以、如果开荒时的市场中缺铁,就只能通过进口。进口制造工具的铁比进口工具更能利用上贸易容量(铁和工具的单位贸易容量相同,但铁到工具以 4:5 转换)。
铁矿在版图上相对其它资源更少,所以一般开荒需更关注市场覆盖下有无湿地地形或有无湖泊,同时需要用充足的木材供应,可以把木材转换为炭再转换为铁。
一旦单一地块上的人口超过 5000 ,就可以升级为城镇,这种生产就有更多选择。以工具制造来说,城镇里就多出了用石头或铜转换为工具的配方。尤其是石制工具的途径,虽然效率很低,通常利润也很低,但贵在石头有保底产出。城镇的升级需要砖头和玻璃,玻璃可通过沙子转换,而沙子有保底产出或通过工具加木材转换。
开荒期间,解决了木头、砖头、玻璃、工具这四种基本货物(前三种是各种基础建筑建设需求,最后是大部分生产转换配方的必须)后,就要考虑提高产能的问题,这里的核心之一是纸。因为劳工识字率影响着生产率。纸是印书的原料、书是图书馆的维持品,而图书馆以及更多提高识字率相关的建筑都需要书。
造纸术需要纤维作物或皮革或布匹。纤维作物的基本生产方法是在对应农场通过牲畜木材工具制造;而牲畜则在耕种村落通过工具加粘土转换;皮革则可以在森林村落通过沙加焦油和野味转换,其中焦油在一般木材产区都可以通过木材转换得到。
另一个重要的资源是人口。它在游戏中和钱一样重要、甚至更重要。因为一切的生产行为都需要人。对开荒而言,升级到城市对效率影响最大,这里的硬性要求就是 5000 基础人口。除了主动殖民,就是本地土著转换、周边迁移(集中)以及自然生长率。这些都可以通过内阁行为略微加速,同样关键是修定居点(同时加移民吸引度和自然生长率)。定居点除了 250 农民外的维持成本是石头、木头、羊毛、野味。前两个一定有保底产出,后两个不是必须,缺少只会让效率打折,但供应充足可以发挥全部性能。定居点和乡村市场都占用农民,在最初阶段,我感觉乡村市场更重要一些,毕竟可以制造工具,还能提供宝贵的贸易容量。
不同阶层人口除了产生固定需求(吃掉本地市场的部分产出)外,更基本的需求是食物。EU5 中的食物系统设计,我觉得也是很巧妙的。
食物并不是货物的一种,而是和钱一样,表示为一个单一值。最主要的食物来源是生产带食物属性的货物(被称为食物原料)的副产品。属于食物原料的货物,被设定了不同的食物倍率,这就让有些食物原料产生食物的效率更高。不工作的劳工默认会以一个较低的产能生产食物,所以不必担心多出来的劳动力被浪费。另外,农民在森林村落里,虽然产品皮革并非食物原料,但生产行为本身被设定了食物产能。另外,农村相对城市有额外的食物产能的乘数增益。
食物的仓储按省份为单位计算,省份归属的若干地块共享一个食物仓库。在每个省份会优先填满仓亏,多出的部分卖给了所属市场,这里是不计市场接入度的。而一旦仓库有空间,就会从所属市场购买。战争是影响它的一个变数。因为军队也会从这个仓库中获取食物,围攻会阻止市场上的食物交易。
食物在本地市场上的交易行为会影响到本地食物价格,购买食物的开销和销售食物的收入是分开计算的,全部通过国库完成。市场间并不能单独交易食物,但通过对有食物原料属性的商品的交易,会产生附带的食物流通(但并不会产生额外的货币流动)。我觉得理论上会出现市场仓库中的食物储量为 0 ,但依然出口食物原料的情况,但实际玩的时候并没有发现,所以不清楚游戏怎样处理。但我猜测,食物流通是在单独层面计算的。既然超出市场食物容量的食物似乎就消失了,那么也可以接受万一食物储量为 0 却继续出口的情况,把储量设为 0 即可。
最后,写写我对税收的看法。
简单说,游戏里的经济活动产生了税基。税基中按王室影响力直接把钱进入国库,另外的钱按阶层影响力分到了各阶层。但玩家可以对阶层分得的钱征税,让这些钱进入国库。
看起来,在不造成阶层不满的前提下,税率越高,国库收入就越高。但实际我玩的感觉是,其实税基才是整个国家的收入,国库仅仅是玩家可以主动调配的部分。阶层保留更多的收入,也会投入到国家发展中去,只不过有时不是玩家想要的方向,甚至是负方向。例如当玩家想削弱某个阶层的影响力时,阶层把钱投入都修建扩大本阶层影响力的建筑上。但总的来说,如果国库钱够用,更低的税收更好。因为税基相同时,税收影响的是分配。低税收必定增加阶层满意度,带来的正面增益是额外的。正所谓藏富于民。
而影响税基最重要的是地区控制度。当然地区控制度不仅仅影响税基,还影响了更多建筑的效率。从这个意义上来说,地方分权比中央集权更有利于经济发展。分封属国,尤其是朝贡国,比大一统国家会获得更好的经济局面。
但权力分配在游戏中也相当重要,因为它直接影响调配价值观的能力。价值观在一盘游戏进程中必须配合时代发展而演变才能更好的发展经济。而集权以及王室影响力是权利分配能力的来源。
所以说,最终玩整个游戏的体验还是在和面,只是多出了一份历史感。有了真实历史这种后验知识,才更为有趣。
flutter_Boots 是咸鱼开源的三方框架,主要是为原生和flutter之间混合跳转提供的解决方案,下面说一下集成flutter_Boots的步骤和如何在项目中使用flutter_Boots。
创建原生工程和flutter module
flutter安装flutter_Boots依赖
ios 配置pod
# 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
flutter 编写flutter_boost集成代码
导入flutter_boost
import 'package:flutter_boost/flutter_boost.dart';
创建CustomFlutterBinding
class CustomFlutterBinding extends WidgetsFlutterBinding
with BoostFlutterBinding {}
测试页面
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']}'),
);
}
}
编写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端代码集成完毕。
iOS端代码集成
先创建一个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)
}
}
}
修改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
}
添加跳转交互
跳转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)
跳转原生
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重复开发,标准不一,难以统一管理;
入口分散:服务应用散落各处,缺乏统一入口,导致用户体验碎片化;
更新迟缓:应用开发发布流程繁琐,无法快速响应政策和用户需求;
集成困难:内部系统标准各异,对接难度大,且数据敏感,安全要求高;
运维复杂:应用缺乏统一治理,各部门各自为政,运维效率和难度增加;
解决方案:携手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官网免费注册体验或者咨询。
最近在做一个工程验收的项目,有个需求是要在 CAD 图纸上标注问题点。一开始觉得挺简单,不就是显示个图片,点一下加个 Marker 吗?真动手做了才发现,这里面的坑多到怀疑人生。
比如说:
折腾了两周,终于把这个东西搞定了。整个过程中踩了不少坑,也积累了一些经验,所以写篇文章记录一下,顺便分享给有类似需求的朋友。
搞这个东西之前,我先理了理需求,发现核心就是:在一张离线图纸上,支持用户点击标注,还得支持区域限制(不能乱点)。
听起来简单,但要做好,必须解决几个问题:
想来想去,决定按这个思路来:
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 = [];
}
这里有几个关键点:
控制器主要负责协调各个部分,处理用户交互。
初始化流程
_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);
}
这个函数看起来简单,但每一步都是踩坑踩出来的:
视图层就是负责显示,用 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) // 图纸右下角
);
这里有几个坑:
产品要求用户只能在房间内标注,不能标到墙外面去。这就需要判断点是否在多边形内。
我用的是射线法(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();
}
这套架构最大的优点是解耦。核心框架不关心你的业务,只负责地图展示和交互。所有业务逻辑都通过 DataSource 接口注入,换个场景只需要写一个新的 DataSource 实现就行。
当然也有一些不足:
不过对于大部分场景来说,已经够用了。
如果你也有类似的需求,希望这篇文章能帮到你。有问题欢迎交流!
2024年实战项目总结,代码已脱敏。
在日常开发中,UINavigationController 是我们最常用的容器控制器之一。但你是否真正理解:
本文将从 基础生命周期 → 动画优化 → 性能检测 三个层次,带你系统掌握 UINavigationController 的核心机制,并提供可落地的 Objective-C 实践方案。
假设当前栈顶是 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仅在视图首次加载时调用一次。
当用户点击返回或手势滑动 pop 回 A:
B: viewWillDisappear
A: viewWillAppear
B: viewDidDisappear
A: viewDidAppear
❗ 关键点:A 的
viewDidLoad不会再次调用!
所以,若需每次进入都刷新数据,请放在viewWillAppear:中。
在 viewDidLoad 或 viewWillAppear: 中执行耗时操作
首次 push 时构建整个视图层级
离屏渲染(Offscreen Rendering)
- (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 更新必须回到主线程!
// 在父页面中预创建
- (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];
}
💡 适用于高频跳转页面(如商品详情、用户主页)。
实现 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];
}];
}
🎨 自定义动画可用于品牌化设计,但务必保证流畅性。
viewDidLoad / viewWillAppear 中的 CPU 热点@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帧),就可能影响动画流畅度。
Xcode 默认开启。若在子线程更新 UI,会立即 crash 并提示:
“Main Thread Checker: UI API called on a background thread”
确保所有 UI 操作都在主线程:
dispatch_async(dispatch_get_main_queue(), ^{
self.titleLabel.text = newText;
});
| 项目 | 是否做到 |
|---|---|
✅ viewDidLoad 只做 UI 初始化 |
☐ |
| ✅ 数据加载异步化 | ☐ |
| ✅ 高频页面预加载 | ☐ |
| ✅ 避免离屏渲染(用贝塞尔路径切圆角) | ☐ |
| ✅ 使用 Instruments 定期检测 FPS | ☐ |
| ✅ 返回手势未被遮挡 | ☐ |
UINavigationController 看似简单,但其背后的生命周期与渲染机制直接影响用户体验。流畅的页面切换不是偶然,而是对细节的极致把控。
希望本文能帮你:
真正的高手,不仅写得出功能,更调得稳帧率。
如果你有具体的卡顿案例,欢迎留言交流!
延伸阅读
在 StoreKit 2 之前,我们进行内购开发充满了痛苦:复杂的收据验证、晦涩的 API、漏单等... StoreKit 2 利用 Swift 的现代特性(Concurrency)重构了整个框架。
StoreKit 2 是 Apple 在 iOS 15+ / macOS 12.0+ 引入的全新内购框架,相比于旧版 StoreKit 具有以下优势:
async/await 替代回调地狱。| 概念 | 说明 |
|---|---|
| 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;
在编写代码前,我们首先需要了解 Apple 定义的四种商品类型:
| 类型 | 英文名 | 特点 | 典型场景 |
|---|---|---|---|
| 消耗型 | Consumable | 可重复购买,购买后即消耗 | 游戏金币、道具 |
| 非消耗型 | Non-Consumable | 一次购买,永久拥有,支持恢复购买 | 解锁完整版、移除广告、终身会员 |
| 自动续期订阅 | Auto-Renewing Subscription | 按周期扣费,自动续订 | 视频会员、SaaS 服务 |
| 非续期订阅 | Non-Renewing Subscription | 有效期固定,不自动续费 | 赛季通行证 |
你可能以为必须先去 App Store Connect 创建商品才能写代码,其实无需这么麻烦,Xcode 提供了一个本地配置文件 (.storekit),让你在没有开发者账号、没联网的情况下也能开发。 操作步骤:
Command + N).+ 按钮,配置你的商品信息。Product -> Scheme -> Edit Scheme -> Run -> Options -> StoreKit Configuration,选择你刚才创建的文件。💡 老鸟经验:建议使用这个本地配置!它不仅能模拟购买成功,还能模拟扣费失败、退款、订阅过期等真实环境很难复现的场景。
我们将创建一个 StoreKitManager 类来管理所有逻辑。
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)")
}
}
}
此方法主要方便调试,打印商品信息。
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("━━━━━━━━━━━━━━━━━━━━━━")
}
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()
}
}
}
StoreKit 2 有两个关键的数据源:
最佳实践:必须在 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()
}
}
}
}
如何判断用户是不是会员呢?
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)")
}
}
订阅比一次性购买复杂,因为需要处理过期、宽限期等状态。
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
}
}
}
当 Transaction.updates 收到更新,或遍历 currentEntitlements 时:
transaction.revocationDate 是否不为 nil。transaction.revocationReason。if let date = transaction.revocationDate {
print("该交易已于 \(date) 被撤销/退款")
// 移除对应的权益
removeEntitlement(for: transaction.productID)
}
恢复购买旨在帮助用户在换新手机或重装 App 后,找回之前购买过的非消耗型商品和订阅。 而且苹果审核要求必须有“恢复购买”按钮。
restoreCompletedTransactions 触发系统弹窗输入密码。Transaction.currentEntitlements 已经包含了用户所有的有效权益。通常情况下,应用启动时刷新这个属性,就等同于“静默恢复”。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)")
}
}
}
updateCustomerProductStatus()(遍历 currentEntitlements),不要弹窗,静默让老用户获取权益。restorePurchases()。currentEntitlements 会包含所有设备上的购买。想给新用户“首月免费”?或者给老用户“回归半价”? StoreKit 2 支持显示推介促销(Introductory Offers)和促销代码(Offer Codes)。
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)")
}
}
}
对于 首次优惠(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)
}
}
}
沙盒环境的时间过得很快:
Transaction.updates 是否收到续订通知。.userCancelled。在 Xcode 中使用 .storekit 配置文件时:
VerificationResult.unverified 怎么办?
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
}
发布前请对照这张清单:
listenForTransactions 在最早的时机运行。Finish 所有的交易了吗? 不管成功还是失败(验证不过),都要调用 .finish(),否则队列会堵死。.pending 状态(家长控制)?Transaction.currentEntitlements 动态计算用户是不是 VIP。本地存个 isPro = true 很容易因为卸载重装或跨设备导致数据不一致。StoreKit 2 大大降低了内购开发的门槛。核心记住三点:
最后,附上一个较为完整的 Demo,地址:StoreKitDemo
最新新上架一个产品,但是由于有些三方库没有隐私清单的问题导致提交到苹果后台之后总是会提示
二进制无效,这里特别说明一下,如果你的app已经是线上的话,貌似没啥问题。(只是问了几个朋友),但是如果你要是新的产品,1.0上线的话那么就会因为这个导致二进制无效无法提交。
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"
Frameworks这个文件夹,然后在它的下边可以找到AFNetworking.framework的文件夹然后你会看到如下图所示的文件,那么证明你添加成功了。AFNetworking这个库没有隐私清单的警告了。测试设备:iPhone 13mini / iOS 26
Audio, AirPlay, and Picture in Picture模式,播放声音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 中用于给已有类型(类、结构体、枚举、协议)添加功能的核心特性,无需继承、无需修改原类型源码,在 AppDelegate.swift 中可以看到大量 extension AppDelegate { ... } 的核心原因(用于分类管理代码、遵守协议、扩展方法)。
作用:给任意类型(系统类型如 NSMenu、自定义类型如 AppDelegate)添加方法、计算属性、协议实现、初始化器等,实现「模块化编程」和「代码解耦」。
优势:
1. 避免类体积过大(把不同功能拆分到扩展中);
2. 无需继承即可扩展功能(比如给 String、Int 加自定义方法);
3. 集中实现协议方法(代码更清晰);
4. 系统类型扩展(比如给 NSMenuItem 加通用方法)。
// 基础语法:扩展已有类型
extension 类型名 {
// 要添加的功能(方法、计算属性、协议实现等)
}
// 带约束的扩展(比如给遵循某协议的类型扩展)
extension 类型名: 协议1, 协议2 where 泛型约束 {
// 协议方法实现 + 自定义功能
}
// 示例(代码中)
extension AppDelegate: ClashProcessDelegate {
// 实现 ClashProcessDelegate 协议方法
func startProxyCore() { ... }
}
AppDelegate 代码实例)AppDelegate.swift 中大量使用 Extension,是 Swift 模块化编程的典型实践,以下逐一拆解核心用法:
给已有类扩展并遵守协议,实现协议方法,避免把所有协议方法写在类的主定义中,代码更清晰。
代码实例:
// 扩展 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 主定义只保留属性,协议方法集中在扩展中,便于维护。
把类的不同功能(如「主菜单项点击事件」「配置操作」「崩溃处理」)拆分到不同扩展中,用 // 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 扩展,无需翻找整个类。
给任意类型添加自定义方法(系统类型/自定义类型均可),比如给 String 加「验证URL」方法,给 AppDelegate 加「重置代理」方法。
示例(通用场景):
// 扩展系统类型:给 String 加 URL 验证方法
extension String {
func isValidURL() -> Bool {
return URL(string: self) != nil
}
}
// 代码中:给 AppDelegate 加实例方法(重置代理)
extension AppDelegate {
@objc func resetProxySettingOnWakeupFromSleep() { ... } // 睡眠唤醒后重置代理
@objc func healthCheckOnNetworkChange() { ... } // 网络变化时健康检查
}
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"
}
}
}
给值类型(结构体、枚举)或类添加自定义初始化器,补充原类型的初始化逻辑。
示例:
// 扩展自定义结构体:添加便捷初始化器
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
给泛型类型(如 Array)添加约束扩展,仅对满足条件的泛型生效。
示例:
// 仅对元素为 Int 的 Array 扩展求和方法
extension Array where Element == Int {
func sum() -> Int {
return reduce(0, +)
}
}
let numbers = [1,2,3]
print(numbers.sum()) // 6
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
}
}
Extension 只能添加新方法,不能重写类原有方法(重写需用继承)。
如果类和扩展都实现了协议方法,类的主定义方法优先级更高;如果多个扩展实现同一方法,编译报错(歧义)。
给 Objective-C 兼容类型(如 NSObject 子类)扩展的方法,若需被 OC 调用(如 @IBAction、代理方法),需加 @objc:
extension AppDelegate {
@objc func handleURL(event: NSAppleEventDescriptor, reply: NSAppleEventDescriptor) { ... }
}
可给类型添加静态方法/计算属性:
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 扩展 |
很多新手会混淆扩展和继承,两者核心区别:
| 特性 | Extension(扩展) | 继承(Inheritance) |
|---|---|---|
| 核心目的 | 给已有类型添加功能 | 基于父类创建子类,重写/扩展功能 |
| 内存布局 | 不修改原类型内存 | 子类有独立内存布局 |
| 方法重写 | 不支持 | 支持重写父类方法 |
| 存储属性 | 不支持 | 支持添加存储属性 |
| 耦合度 | 低(无需关联原类型源码) | 高(子类依赖父类) |
Swift Extension 是「模块化编程」的核心,AppDelegate 代码通过扩展可以实现:
协议方法与核心逻辑分离;
按业务功能拆分代码(配置、菜单、崩溃、网络等);
兼容 OC 运行时(@objc 方法);
扩展自定义方法(如 startProxyCore、resetStreamApi)。
掌握 Extension 的核心是「拆分功能、解耦代码、不入侵原类型」,这也是 Swift 推崇的「组合优于继承」设计思想的体现。
我们拒绝「35 岁职场干电池」标签,坚信经验是最宝贵的财富 —— 诚邀深耕 iOS 领域的技术大佬,与我们并肩开拓 AI 出海新赛道,在碰撞中创新,在实战中共同成长!
我们不设年龄焦虑,只看能力与潜力;这里没有层级束缚,只有并肩作战的伙伴。期待你加入,成为我们不可或缺的核心力量,一起在 AI 出海赛道共创下一个爆款!
遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!
SwiftUI管理声明为state的存储属性。当值发生变化时,SwiftUI会更新视图层次结构中依赖于该值的部分。对这个属性进行赋值的操作将会触发 View 的刷新,它的 body 会被再次调用,底层渲染引擎会找出界面上被改变的部分,根据新的属性值计算出新的 View,并进行刷新。
struct JLStateView: View {
@State var count = 0
var body: some View {
VStack {
Text("\(count)")
Button("按钮点击加1") {
count += 1
}
.background(.orange)
}
}
}
通过@State定义变量count,点击按钮会触发Text中数字的显示
不要在视图层次结构中实例化视图的位置初始化视图的状态属性,因为这可能与SwiftUI提供的存储管理冲突。
为了避免这种情况,总是将state声明为private,并将其放在视图层次结构中需要访问该值的最高视图中。
@State private var count = 0
@State修饰的属性是值传递,因此在父视图和子视图之间传递属性时。子视图针对属性的修改无法传递到父视图上。
Binding修饰后会将属性会变为一个引用类型,视图之间的传递从值传递变为了引用传递,将父视图和子视图的属性关联起来。这样子视图针对属性的修改,会传递到父视图上。
需要在属性名称前加上一个美元符号$来获得这个值。
被声明为 @Binding 的属性进行赋值,改变的将不是属性本身,而是它的引用,这个改变将被向外传递.
import SwiftUI
struct JLBtnView: View {
@Binding var isShowText: Bool
var body: some View {
Button("按钮点击") {
isShowText.toggle()
}
}
}
struct JLContentView: View {
@State private var isShowText: Bool = true
var body: some View {
VStack {
if isShowText{
Text("点击后会被隐藏")
}else{
Text("点击后会被显示")
}
/// $isShowText 双向绑定
JLBtnView(isShowText: $isShowText)
}
}
}
按钮在JLBtnView视图中,并且通过点击,修改isShowText的值。
将jLBtnView视图添加到JLContentView上作为它的子视图。并且传入isShowText。
此时的传值是指针传递,会将点击后的属性值传递到父视图上。
父视图拿到后也作用在自己的属性,因此他的文本视图会依据该属性而隐藏或显示
如果将@Binding改为@State,会发现点击后不起作用。这是因为值传递子视图的更改不会反映到父视图上
struct JLContentView: View {
@State private var name: String = ""
var body: some View {
VStack {
TextField("请输入您的名字",text: $name)
Text(name)
}
}
}
在文本输入框中输入的数据,就会传入到name中
同时name又绑定在文本视图上,所以会将文本输入框输入的文本显示到文本视图上
这就是数据绑定的快捷实现。
如果说 @State 是全自动驾驶的话,ObservableObject 就是半自动,它需要一些额外的声明。ObservableObject 协议要求实现类型是 class,它只有一个需要实现的属性:objectWillChange。在数据将要发生改变时,这个属性用来向外进行“广播”,它的订阅者 (一般是 View 相关的逻辑) 在收到通知后,对 View 进行刷新。
创建 ObservableObject 后,实际在 View 里使用时,我们需要将它声明为 @ObservedObject。这也是一个属性包装,它负责通过订阅 objectWillChange 这个“广播”,将具体管理数据的 ObservableObject 和当前的 View 关联起来。
绑定的数据是一个对象。
被修饰的对象,其类必须遵守ObservableObject协议
此时这个类中被@Published修饰的属性都会被绑定
使用@ObservedObject修饰这个对象,绑定这个对象。
被@Published修饰的属性发生改变时,SwiftUI就会进行更新。
import SwiftUI
internal import Combine
class Persion: ObservableObject{
/// 属性只有被@Published修饰时,属性的值修改时,才能被监听到
@Published var name = ""
}
struct JLContentView: View {
@ObservedObject var p = Persion()
var body: some View {
VStack {
Text(p.name)
.padding()
Button("修改") {
p.name = "哈哈"
}
}
}
}
@ObservedObject修饰的必须是遵守ObservableObject 协议的class对象class对象的属性只有被@Published修饰时,属性的值修改时,才能被监听到
在多视图中,为了避免数据的无效传递,可以直接将数据放到环境中,供多个视图进行使用
在 SwiftUI 中,View 提供了 environmentObject( 方法,来把某个 ObservableObject 的值注入到当前 View 层级及其子层级中去。在这个 View 的子层级中,可以使用 @EnvironmentObject 来直接获取这个绑定的环境值。
extension View {
@inlinable nonisolated public func environmentObject<T>(_ object: T) -> some View where T : ObservableObject
}
final class Persion: ObservableObject{
@Published var name = "哈哈"
}
struct MapView: View {
@EnvironmentObject var p : Persion
var body: some View {
VStack {
Text(p.name)
Button("点击") {
p.name = "呵呵"
}
}
}
}
struct JLContentView: View {
var body: some View {
VStack {
let p = Persion()
MapView().environmentObject(p)
}
}
}
@EnvironmentObject 修饰器是针对全局环境的。通过它,我们可以避免在初始 View 时创建 ObservableObject, 而是从环境中获取 ObservableObject
可以看出我们获取 p这个 ObservableObject 是通过 @EnvironmentObject 修饰器,但是在入口需要传入 .environmentObject(p) 。@EnvironmentObject 的工作方式是在 Environment 查找 Person 实例。
import SwiftUI
internal import Combine
final class Persion: ObservableObject{
@Published var name = "哈哈"
}
struct EnvView: View {
@EnvironmentObject var p : Persion
var body: some View {
Text(p.name)
}
}
struct BtnView: View {
@EnvironmentObject var p: Persion
var body: some View {
Text(p.name)
Button("修改") {
p.name = "1123"
}
}
}
struct JLContentView: View {
let p = Persion()
var body: some View {
VStack {
EnvView().environmentObject(p)
BtnView().environmentObject(p)
}
}
}
给属性添加@EnvironmentObject修改,就将其放到了环境中。
其他视图中想要获取该属性,可以通过.environmentObject从环境中获取。
可以看到分别将EnvView和BtnvView的属性分别放到了环境中
之后我们ContentView视图中获取数据时,可以直接通过环境获取。
不需要将数据传递到ContentView,而是直接通过环境获取,这样避免了无效的数据传递,更加高效
如果是在多层级视图之间进行传递,会有更明显的效果。
import SwiftUI
internal import Combine
final class Persion: ObservableObject{
@Published var name = 1
deinit{
print("被销毁了")
}
}
struct MapView: View {
@ObservedObject var p = Persion()
var body: some View {
VStack{
Text("\(p.name)")
Button("+1") { //添加一个按钮,指定标题文字为 First button
p.name += 1
}
}
}
}
struct JLContentView: View {
@State var count = 0
var body: some View {
VStack {
Text("刷新:\(count)")
Button("刷新"){
count += 1
}
MapView()
}
}
}
点击刷新时,Person 的deinit方法被调用,说明p对象被销毁;
先连续点击+1,Text上的数字在一直递增,当点击刷新时Text上的数字恢复为1,这个现象也说明p对象被销毁
import SwiftUI
internal import Combine
final class Persion: ObservableObject{
@Published var name = 1
deinit{
print("被销毁了")
}
}
struct MapView: View {
@StateObject var p = Persion()
var body: some View {
VStack{
Text("\(p.name)")
Button("+1") { //添加一个按钮,指定标题文字为 First button
p.name += 1
}
}
}
}
struct JLContentView: View {
@State var count = 0
var body: some View {
VStack {
Text("刷新:\(count)")
Button("刷新"){
count += 1
}
MapView()
}
}
}
和例1不同的是怎么操作,p都不会销毁
@StateObject的声明周期与当前所在View生命周期保持一致,即当View被销毁后,StateObject的数据销毁,当View被刷新时,StateObject的数据会保持;而ObservedObject不被View持有,生命周期不一定与View一致,即数据可能被保持或者销毁;