普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月13日掘金 iOS

UIScene in iOS

作者 songgeb
2025年8月13日 15:45

UIScene是iOS 13引入的,核心要解决的是,原来的基于1个Window管理App UI的策略,不能很好的适配像iPad中出现的一个App对应多个Window的场景

iOS 13之前

在iOS 13以前,App启动后执行的方法以及AppDelegate所负责的工作如下图所示

wechat_2025-08-13_152124_019.png

  • App Delegate一方面要负责App进程生命周期方法执行、事件回调
  • 另一方面还要处理UI的生命周期

iOS 13开始

从iOS 13开始,变化如下:

wechat_2025-08-13_152245_306.png

  • 关于App进程生命周期,还是App Delegate在负责,保持不变
  • 但UI生命周期的管理则是交给了Scene Delegate

这也就引出了Scene、Scene Delegate、UIWindowScene等概念。最核心的理念在于:

原来是一个App进程对应一个UI场景;现在开始,一个App进程将对应多个UI场景,其中的每一个场景都叫做Scene

既然引入了场景Scene,那场景所对应的实例(UISceneUIWindowScene)、代理(Scene Delegate)、场景管理的window等概念也就非常合理了

对于上图,还有几点需要关注:

  1. App Delegate中负责创建、切换Scene
  2. 当实现Scene Delegate中的各种UI回调后,原来App Delegate的UI回调变不再执行
  3. 由于一个App进程可能有多个Scene,所以App Delegate中也会有Scene创建、连接、断开连接、销毁等逻辑,这一点在后面的图中更能看出来

引入Scene后的App启动流程

wechat_2025-08-13_152333_410.png

wechat_2025-08-13_152345_160.png

Q&A

1. iPhone是否支持多个Scene

不支持

尽管官方文档中说iOS支持,但通过UIApplication.shared.supportsMultipleScenes结果来看,iOS应用并不支持

2. 一个Scene是只对应一个Window吗

  • 不是的,可以对应多个Window,可以通过APIUIWindowScene.windows了解到
  • 尽管如此,大部分情况下一个Scene只有1个Window

3. 如何初始化Scene

开发者不要直接创建UIWindowScene实例,而是交给系统创建,创建方式有如下几种:

  • 通过在Info.plist中scene配置中指定scene的类名
  • 或者在app delegate的 application(_:configurationForConnecting:options:)方法中,创建UISceneConfiguration时指定scene的类名
  • 也可以通过嗲用UIApplication.requestSceneSessionActivation(_:userActivity:options:errorHandler:)方法来获取/创建scene实例

参考

苹果审核被拒要听劝,能沟通回复解决真的不用改!

作者 iOS研究院
2025年8月13日 09:50

背景

由于苹果审核最后一步是人工介入,所以这也大大增加了影响审核结果的不确定。

比如最近有一个同行,本来是iPhone设备的产品,应是被苹果因为不适配iPad被打回。

Guideline 4.0 - Design  
  
Several screens of the app were crowded, laid out, or displayed in a way that made it difficult to use the app when reviewed on iPad Air 11-inch (M2) running iPadOS 18.6.  
  
Next Steps  
  
To resolve this issue, revise the app to ensure that the content and controls on the screen are easy to read and interact with.  
  
Note that users expect apps they download to function on all the devices where they are available. Since your app may be downloaded onto iPad devices, it is important that it also function as expected for iPad users.

简单来说:

苹果认为该应用会被安装在iPad类型的设备中,期望UI可适配iPad上的所有情况。

但,离谱的是苹果只是提出页面不适配,感到了拥挤并没有明确说明,具体是哪些页面。

正常来讲附件会有对应的应用截图,实际上并没有任何参考。

自查阶段

  1. 构建勾选情况,如果勾选了iPad构建的版本,除了本身要兼容iPhone还必须要兼容iPad,而且在市场截图还必须对应配置。

构建版本.png

  1. 使用iPad自查页面,确保产品本身确实没有审核员所说的适配问题

快速解决方案

由于该同行仅仅只是勾选了iPhone尺寸,并不会做iPad的兼容。同时,该产品已经顺利迭代过3~4次,并非首次提交。所以,用最简单直接的方式向苹果主动说明并且做一个保证

回复内容:
苹果审核:
   你好,感谢你的提醒。我们的产品并没有iPad建构的规划,并且我们保证在后续的迭代中,都并不会为
iPad的用户提供服务。我们在之前的迭代中都没有遇到此类问题,并且我们确保在项目构建未勾选iPad相关
配置,所以我们不清楚为什么一定适配iPad所有场景?
最好的问候

对话截图.jpeg

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

相关推荐

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

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

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

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

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

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

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

昨天以前掘金 iOS

腾讯元器的优点是什么

元器本身的优点有三个:

1、团队功能,一个团队可以有50人,支持创建5个。方便小企业和团队,在元器上以团队方式创建智能体、工作流、插件、知识库,并共享这些资产。也方便大家共同协助,相互学习,我创立了一个”浪洋洋和朋友们”的团队,欢迎大家加入。

2、有简单的官方说明文档,能在元器平台内直接加入官方群,有负责人解答。

3、免费TOKEN额度高,估计是用的人少,现在单个账号免费额度有1个亿,轻松实现人生小目标,用不完真的用不完。扣子的免费TOKEN额度很少了,初学者练手可以试试元器。

drawRect方法的理解

2025年8月12日 14:06

drawRect: 是 UIView 中用于自定义绘制内容的核心方法,对iOS开发者来说,想要高效绘图,我们需要深入理解这个方法。

一、基础调用时机

1. 首次显示视图时

  • 当视图被添加到视图层级时
  • 当视图的 hidden 属性从 YES 变为 NO 时
  • 当视图从父视图的 nil 变为非 nil 时

2. 视图尺寸变化时

  • 当视图的 frame 或 bounds 属性改变时
  • 设备旋转导致视图尺寸变化时
  • 父视图布局改变导致子视图尺寸变化时

3. 显式请求重绘时

  • 调用 setNeedsDisplay 方法
  • 调用 setNeedsDisplayInRect: 方法(部分重绘)

二、详细调用场景分析

1. 自动调用场景

class CustomView: UIView {
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        // 绘制代码
        print("drawRect called with rect: (rect)")
    }
}

// 以下操作会自动触发 drawRect:
let view = CustomView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
window.addSubview(view) // 首次显示触发

view.frame = CGRect(x: 0, y: 0, width: 200, height: 200) // 尺寸变化触发

view.isHidden = true
view.isHidden = false // 显示状态变化触发

2. 手动触发场景

// 标记整个视图需要重绘
view.setNeedsDisplay()

// 标记视图的特定区域需要重绘
view.setNeedsDisplay(CGRect(x: 10, y: 10, width: 50, height: 50))

三、调用机制原理

1. 系统绘制周期

  1. RunLoop 的 BeforeWaiting 阶段:系统检查所有标记为需要重绘的视图
  2. 合并绘制请求:将多个 setNeedsDisplay 调用合并为一次绘制
  3. 调用顺序:按照视图层级从父视图到子视图依次调用

2. 性能优化机制

  • 延迟合并:系统不会立即响应每次属性变化,而是在下一个绘制周期统一处理
  • 脏矩形技术:只重绘发生变化的部分区域(通过 rect 参数传递)

四、重要注意事项

1. 不要直接调用 drawRect:

// 错误做法 ❌
view.draw(CGRect.zero)

// 正确做法 ✅
view.setNeedsDisplay()

2. 绘制性能影响

  • 频繁调用 drawRect: 会严重影响性能
  • 复杂绘制应考虑使用 CAShapeLayer 或 Core Graphics 离屏渲染

3. 背景色与绘制

  • 设置 backgroundColor 不会触发 drawRect:
  • 如果自定义了 drawRect:,背景色需要在方法内手动绘制

五、高级调用场景

1. 内容模式影响

view.contentMode = .redraw // 尺寸变化时自动调用 drawRect:
view.contentMode = .scaleToFill // 默认模式,不自动触发重绘

2. 动画中的调用

UIView.animate(withDuration: 1.0) {
    view.frame = CGRect(x: 0, y: 0, width: 300, height: 300)
    // 动画过程中会多次调用 drawRect:
}

3. 滚动视图中的调用

scrollView.didScroll {
    // 滚动时频繁调用 setNeedsDisplay
    visibleCells.forEach { $0.setNeedsDisplay() }
}

六、实践建议

1. 减少不必要的重绘

// 使用局部重绘
func updatePartialContent() {
    let dirtyRect = CGRect(x: 10, y: 10, width: 50, height: 50)
    setNeedsDisplay(dirtyRect)
}

2. 复杂绘制优化

// 使用 display link 控制绘制频率
let displayLink = CADisplayLink(target: self, selector: #selector(updateDrawing))
displayLink.preferredFramesPerSecond = 30 // 限制为30FPS
displayLink.add(to: .current, forMode: .common)

3. 离屏渲染技术

// 在后台线程创建绘制上下文
DispatchQueue.global(qos: .userInitiated).async {
    UIGraphicsBeginImageContextWithOptions(size, false, 0)
    defer { UIGraphicsEndImageContext() }
    
    // 绘制操作...
    let image = UIGraphicsGetImageFromCurrentImageContext()
    
    DispatchQueue.main.async {
        imageView.image = image
    }
}

七、 性能分析

  • 使用 Instruments 的 Core Animation 模板
  • 检查 drawRect: 的执行时间和频率
  • 监控 CPU 使用率和帧率

Swift 结构体属性:let 与 var 的选择艺术

作者 unravel2025
2025年8月12日 12:47

在 Swift 开发中,结构体(struct)的属性声明常面临 let 与 var 的抉择。本文将从多个维度解析两者的差异,并结合实际场景提供决策建议。

一、基础差异:不可变性与初始化行为

1. 不可变性的连锁反应

struct User {
    let id: UUID
    let imageURL: URL?
}

// 必须显式传递 nil
let user = User(id: UUID(), imageURL: nil)
  • 强制显式性let 属性要求初始化时必须赋值(包括 nil
  • 解码限制:若遵循 Decodablelet 属性会忽略 JSON 中的同名字段

2. 默认值的陷阱

struct User {
    let id = UUID() // 编译错误!无法覆盖默认值
}
  • 编译期锁定let 的默认值无法被外部赋值覆盖
  • 初始化器必要性:需手动实现初始化器才能保留默认值灵活性

二、进阶方案:平衡不可变性与便利性

1. 手动初始化器的优雅退场

struct User {
    let id: UUID
    let imageURL: URL?
    
    init(id: UUID = UUID(), imageURL: URL? = nil) {
        self.id = id
        self.imageURL = imageURL
    }
}
  • 双重优势:保持属性不可变的同时支持默认值
  • 维护成本:需手动编写和维护初始化逻辑

2. 属性包装器的魔法

@propertyWrapper struct Readonly<Value: Codable> {
    let wrappedValue: Value
}

struct User {
    @Readonly var id = UUID()
    @Readonly var imageURL: URL?
}
  • 复用性:通过包装器实现 var 声明的只读特性
  • 协议兼容:需额外实现 Encodable/Decodable 协议扩展

三、争议焦点:可变性的取舍

1. 极简主义路线

struct User {
    var id = UUID()
    var name: String
    // 其他属性均为 var
}
  • 测试友好:便于模拟状态变化(如 normalizeName() 测试)
  • 潜在风险:暴露不必要的可变性(需依赖调用者自律)

2. 结构体的本质思考

protocol UserTransformer {
    mutating func transform(_ user: inout User)
}

// 可能的滥用场景
struct UserIDTransformer: UserTransformer {
    func transform(_ user: inout User) {
        user = User(id: UUID(), name: user.name) // 完全替换实例
    }
}
  • 值类型的陷阱inout 参数允许完全替换底层实例
  • 防御性编程:重要属性应通过业务逻辑层保护

四、决策框架与最佳实践

1. 属性分类指南

属性类型 推荐修饰符 典型场景
核心标识符 let idprimaryKey
可选配置项 let? imageURL
计算衍生属性 var fullName
需要默认值 let+初始化器 createdAt = Date()

2. 实战建议

  1. **优先使用 let**:除非明确需要可变性
  2. 初始化器先行:通过自定义初始化保持 API 清晰
  3. 防御性包装:关键属性可通过访问控制限制修改权限
  4. **审慎使用 inout**:在需要改变实例时优先返回新实例

五、未来趋势展望

随着 Swift 演进,以下方向值得关注:

  • 不可变集合:Swift 5.7+ 引入的 @resultBuilder 可能催生新型不可变模式
  • 值类型增强:SE-0353 提案探索更高效的值类型复制机制
  • 协程集成:Async/Await 与结构体的结合可能改变状态管理范式

结语let 与 var 的选择本质上是数据模型设计的哲学问题。建议采用「最小权限原则」——仅在必要时引入可变性,并通过清晰的接口契约约束变更行为。记住,Swift 的强大之处在于其表达能力,合理利用语言特性能让代码既安全又优雅。

使用 Swift 的 defer 管理状态清理(译文)

作者 unravel2025
2025年8月12日 12:09

在异步函数中处理清理逻辑时,defer语句能确保代码在当前作用域退出前执行,无论函数是正常结束、抛出错误还是被提前返回。本文将通过一个常见场景——显示/隐藏加载指示器——演示 defer的用法。

典型用例

func fetch() async {
    isLoading = true
    defer { isLoading = false } // 清理逻辑集中在此
    
    do {
        articles = try await service.fetchArticles()
    } catch {
        self.error = error.localizedDescription
    }
}

传统实现方式对比

  1. 多位置设置(易遗漏):
isLoading = true
do {
    ...
    isLoading = false
} catch {
    ...
    isLoading = false
}
  1. 单次底部设置(无法应对提前退出):
isLoading = true
do {
    ...
} catch {
    ...
}
isLoading = false // 若中途 return 或 await 取消,此行不会执行

defer 的优势

  • 统一清理入口:将清理逻辑集中在一处,避免分散在各分支。

  • 容错性:自动处理以下场景:

    • 函数正常结束
    • 抛出错误
    • 提前返回(return
    • await过程中被取消

适用场景扩展

  • 资源释放:文件句柄、网络连接等
  • UI 状态管理:进度条、禁用按钮等
  • 并发安全:配合 actor防止竞态条件

总结

defer是 Swift 中管理临时状态和资源的利器,尤其适合异步编程场景。通过将清理逻辑延迟到作用域结束时执行,它能显著提升代码的健壮性和可维护性,降低因流程分支复杂导致的遗漏风险。对于需要确保执行清理操作的场景(如加载状态、锁机制等),建议优先考虑使用 defer

成年人的沟通,不谈钱谈什么?谈感情?

作者 iOS研究院
2025年8月12日 11:56

背景

正所谓,男人不好色好什么?How are you 么?

表情包.png

本来今天准备了另外的一个素材,准备发稿了。突然有个xxv加我,看着头像就知道不一般,结果果然不一般。

对话截图

对话截图.png

为啥三个不?

首先,我要表明自身立场确实在不方便接语音。其次,遇到的白嫖党太多了习惯了。所以,我习惯丑话说前边。

本质上成年人的社交,本来就应该是高效的。如果愿意付费就聊聊,不愿意付费就算了,没必要浪费彼此的时间

毕竟大家的时间都挺值钱,没必要互相耽误

对于这样就别做生意了那就更可笑了,你在教我做事?

我用了最简单的三个不,就是为了过滤这种低价低质量的交流。

同时,再次声明一下。我从16年就开始从事iOS开发的工作,从20年iOS审核过审严格之后截至目前,已经累计折损了60个账号。这里边有部分是公司,有部分是个人试错。

公众号咨询只不过是把之前账号成本回回血,顺便真正帮助一些想上架却上不去的同行。

有独立开发者身份傍身,即使不做咨询也依旧可以很潇洒。因为自身暂时没有什么好的产品,而且工具类产品暂时也不需要维护。

所以找了个公司混混社保,让自己时刻保持着学习的状态,在结交一些新的志同道合的朋友,拓展一下自己的人脉圈子。

写在最后

大家都是成年人别总想着像小孩子一样占便宜了。成人的免费往往都是最贵的,比如免费领鸡蛋,高价卖保jian品。再比如免费陪聊,引入杀🐷盘。

最近还看到一个见闻,易水寒一套时装10w+,而大多数游戏还停留在6元首冲的套路。

所以,与其做低价低质量的用户,不如分流提高客单价。以少胜多,以质取胜。

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

相关推荐

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

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

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

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

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

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

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

把 GPT 塞进 iPhone:iOS 26 的 Foundation Models 框架全解析

作者 unravel2025
2025年8月12日 11:23

十分钟学会调用苹果原生大模型,不联网、不走流量、不丢隐私

一、为什么开发者要开始关心「苹果自己的大模型」?

WWDC 24 的 Platforms State of the Union 上,苹果第一次把「Apple Intelligence」背后的技术栈公开:Foundation Models 框架。

一句话总结:它让 3B 规模的大语言模型 直接在 A17/M3 系列芯片上跑——CPU + GPU + Neural Engine 混合计算,速度≈本地 Core ML;不联网、不上云、不泄露隐私。

本文基于 iOS 26 Beta API 写成,正式版可能微调,但思路 100 % 可用。

二、一分钟速览:Foundation Models 框架核心概念

概念 作用 对应代码
SystemLanguageModel 获取系统内置模型(3B 通用、内容打标签、摘要等适配器) SystemLanguageModel.default
LanguageModelSession 对话会话,保存上下文 LanguageModelSession(...)
@Generable 把 JSON 结构映射成 Swift 原生类型 @Generable struct Weather {...}
@Guide 对字段加约束(枚举、正则、描述) @Guide(.anyOf(["PG-13","R"]))
Tool 协议 让模型调用你的函数 FindRestaurantsTool()

三、实战:用 20 行代码做一个离线聊天机器人

  1. 检查模型可用性
import FoundationModels

let model = SystemLanguageModel.default
switch model.availability {
    case .available:
        break // OK
    case .unavailable(let reason):
        print("❌ \(reason)") // 设备不支持 or 正在下载
}

实测:A17 Pro + iOS 26 Beta 冷启动下载 1.8 GB 权重,首次后秒开。

  1. 创建会话
let session = LanguageModelSession {
    """
    你是 WWDC 小助手,回答简洁,用中文。
    """
}
  1. 发送/接收
let reply = try await session.respond(to: "Swift 的 async let 怎么用?")
print(reply.content) // 带代码示例的中文解释
  1. 流式输出(打字机效果)
let stream = try await session.streamResponse(to: "写一首五言绝句")
for try await partial in stream {
    updateUI(partial) // SwiftUI Text 逐字刷新
}
  1. 结构化输出(JSON → Swift 类型)
@Generable struct Weather {
    let temperature: Double
    let condition: String
}

let res = try await session.respond(
    to: "给我今日北京的天气",
    generating: Weather.self
)
print("气温 \(res.content.temperature)°C")

四、进阶:让模型调用你的 App 函数

场景:用户说「找三里屯附近的日料」→ 模型自动调用定位 + 网络搜索。

  1. 定义工具
final class FindRestaurantsTool: Tool {
    struct Args: Generable {
        let query: String
        @Guide(.count(3)) let maxResults: Int
    }
    
    func call(arguments: Args) async throws -> ToolOutput {
        let list = await LocationService.search(query: arguments.query,
                                                limit: arguments.maxResults)
        return ToolOutput(list.joined(separator: ", "))
    }
}
  1. 注入会话
let session = LanguageModelSession(
    tools: [FindRestaurantsTool()]
)
  1. 用户一句话触发
let result = try await session.respond(to: "三里屯附近好吃的日料")
print(result.content) // "寿司郎, 筑底海鲜市场, 将太无二"

五、性能 & 隐私 FAQ

六、开发 checklist(上线前必看)

• ✅ 在 Info.plist 增加 NSAppleIntelligenceUsageDescription

• ✅ 处理 model.availability == .downloading 的 UI 状态

• ✅ 使用 session.prewarm() 提前预热模型

• ✅ 对 VoiceOver 加 .accessibilityLabel("AI 回复")

• ✅ 高并发场景复用同一 LanguageModelSession,避免重复加载权重

七、一句话总结

Foundation Models 把「大模型」变成了和 UserDefaults 一样的基础设施:

声明式调用、类型安全、零网络、零隐私顾虑。

如果你今天开始做 iOS 16/17 适配,不妨把「本地大模型能力」写进 PRD —— 明年用户换机后就能直接体验。

八、延伸阅读 & 官方链接

• Foundation Models | Apple Developer Documentation developer.apple.com/documentati…

• Platforms State of the Union (WWDC 24) developer.apple.com/wwdc24/102

• Sample Code(官方 Demo):CreateWithSwift/FoundationModels-Demo

用 SwiftUI 打造“会长大”的组件 —— 从一次性 Alert 到可扩展设计系统

作者 unravel2025
2025年8月12日 10:43

原文链接

为什么旧写法撑不过三次迭代?

先来看一个“经典”写法

Alert(
    title: "Title",
    message: "Description",
    type: .info,
    showBorder: true,
    isDisabled: false,
    primaryButtonTitle: "OK",
    secondaryButtonTitle: "Cancel",
    primaryAction: { /* ... */ },
    secondaryAction: { /* ... */ }
)

痛点一句话总结:初始化即地狱。

• 参数爆炸,阅读困难

• 布局/样式/行为耦合,一改全改

• 无法注入自定义内容,复用性 ≈ 0

目标:像原生一样的 SwiftUI 组件

我们想要的最终形态:

AlertView(title: "...", message: "...") {
    AnyViewBuilder Content
}
.showBorder(true)
.disabled(isLoading)

为此,需要遵循 4 个关键词:

  1. Familiar APIs – 看起来像 SwiftUI 自带的
  2. Composability – 任意组合内容
  3. Scalability – 业务扩张不炸窝
  4. Accessibility – 无障碍不打补丁

三步重构法

Step 1:只保留「必须参数」

public struct AlertView: View {
    private let title: String
    private let message: String
    
    public init(title: String, message: String) {
        self.title = title
        self.message = message
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
        }
        .padding()
    }
}

经验:先把最常用、不可省略的参数放进 init,其余全部踢出去。这一步就能干掉 70% 的参数。

Step 2:用 @ViewBuilder 把“内容”交出去

public struct AlertView<Footer: View>: View {
    private let title: String
    private let message: String
    private let footer: Footer
    
    public init(
        title: String,
        message: String,
        @ViewBuilder footer: () -> Footer
    ) {
        self.title = title
        self.message = message
        self.footer = footer()
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
            footer.padding(.top, 25)
        }
        .padding()
    }
}

使用:

AlertView(title: "提示", message: "确定删除吗?") {
    HStack {
        Button("取消", role: .cancel) {}
        Button("删除", role: .destructive) {}
    }
}

Step 3:样式/行为用 环境值 + 自定义修饰符

我们想让边框可开关,但又不想回到“参数爆炸”。

struct ShowBorderKey: EnvironmentKey {
    static let defaultValue = false
}

extension EnvironmentValues {
    var showBorder: Bool {
        get { self[ShowBorderKey.self] }
        set { self[ShowBorderKey.self] = newValue }
    }
}

extension View {
    public func showBorder(_ value: Bool) -> some View {
        environment(\.showBorder, value)
    }
}

在 AlertView 内部读取

@Environment(\.showBorder) private var showBorder

// …
.overlay(
    RoundedRectangle(cornerRadius: 12)
        .stroke(Color.accentColor, lineWidth: showBorder ? 1 : 0)
)

至此,API 回归简洁:

AlertView(...) { ... }
    .showBorder(true)

进阶:用 @resultBuilder 做「有约束的自由」

当设计规范新增“免责声明 + 倒计时”组合时,与其疯狂加 init,不如定义一个 InfoSectionBuilder:

@resultBuilder
public struct InfoSectionBuilder {
    public static func buildBlock(_ disclaimer: Text) -> some View {
        disclaimer.disclaimerStyle()
    }
    public static func buildBlock(_ timer: TimerView) -> some View {
        timer
    }
    public static func buildBlock(
        _ disclaimer: Text,
        _ timer: TimerView
    ) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            disclaimer.disclaimerStyle()
            timer
        }
    }
}

把 AlertView 再升级一次:

public struct AlertView<Info: View, Footer: View>: View {
    private let title, message: String
    private let infoSection: Info
    private let footer: Footer
    
    public init(
        title: String,
        message: String,
        @InfoSectionBuilder infoSection: () -> Info,
        @ViewBuilder footer: () -> Footer
    ) {
        self.title = title
        self.message = message
        self.infoSection = infoSection()
        self.footer = footer()
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
            infoSection.padding(.top, 16)
            footer.padding(.top, 25)
        }
        .padding()
    }
}

用法:

AlertView(
    title: "删除账户",
    message: "此操作不可撤销",
    infoSection: {
        Text("余额将在 24 小时内退回")
        TimerView(targetDate: .now + 100)
    },
    footer: {
        Button("确认删除", role: .destructive) {}
    }
)

无障碍:组件方 + 使用方 共同责任

组件内部负责结构级:

.accessibilityElement(children: .combine)
.accessibilityLabel("\(type.rawValue) alert: \(title). \(message)")
.accessibilityAddTraits(.isModal)

使用方负责内容级:

Button("延长会话") {}
    .accessibilityHint("延长 30 分钟")
    .accessibilityAction(named: "延长会话") { // 实际逻辑 }

写在最后的 checklist

维度 ✅ 自检问题
初始化 是否只有“最少必要参数”?
可组合 是否使用 @ViewBuilder / @resultBuilder
样式扩展 是否通过 EnvironmentKey + 自定义修饰符?
无障碍 结构 + 内容 是否都提供了 label / hint / action?
向后兼容 新增需求是否只“加 Builder 方法”而不是“改 init”?

源码仓库

所有示例已整理到 GitHub(非官方镜像,可直接跑 playground): github.com/muhammadosa…

当你用 .disabled(true) 把一整块区域关掉,子组件自动变灰、按钮自动失效 —— 这种「像原生」的体验,正是可扩展设计系统给人的最大安全感。

Swift Concurrency从入门到精通

2025年8月12日 08:58

如果在GPT上问如何精通swift并发你得到的答案会是这样

GPT回答如何精通swift并发

精通 Swift Concurrency(Swift 并发编程)需要你从概念 → API → 实战 → 性能优化四个阶段系统学习,并且在不同类型的项目中反复练习。下面我帮你分成一个循序渐进的路线图,并附上关键知识点与练习建议。


阶段 1:打牢基础(理解并发模型)

Swift Concurrency 在 Swift 5.5(iOS 15/macOS 12)引入,基于 async/awaitTaskActor 以及结构化并发(Structured Concurrency)

你要掌握的核心概念:

  1. 同步 vs 异步:理解阻塞与非阻塞

  2. 并发 vs 并行:并发是任务交错执行,并行是多核同时执行

  3. 线程安全数据竞争

  4. Swift Concurrency 的三大基石:

    • async/await
    • Task / TaskGroup
    • Actor

建议练习:

  • 写一个使用 async 函数下载多张图片并显示的简单 Demo
  • Task.sleep 模拟异步延迟
  • 使用 TaskGroup 并行处理多个网络请求

阶段 2:熟练掌握 Swift Concurrency API

必学 API 与关键点:

  1. async / await

    • 如何将回调式 API 改写成 async 函数(使用 withCheckedContinuation
    • 处理错误:try await
  2. Task 与取消任务

    • Task {} 创建任务
    • Task.detached 与作用域
    • Task.isCancelled 检查取消
  3. TaskGroup(并发任务组)

    • withTaskGroup
    • 并行批量任务 + 收集结果
  4. Actor(状态隔离)

    • 避免数据竞争
    • nonisolated 修饰符
  5. MainActor

    • 保证 UI 更新在主线程执行
  6. AsyncSequence 与异步流

    • for await 循环
    • AsyncStream 创建异步数据流

阶段 3:结合真实项目场景

  1. 网络层改造

    • async/await 改写 URLSession 请求
    • 结合 TaskGroup 批量请求数据
  2. UI 交互优化

    • 异步加载大图并在 MainActor 更新
  3. 后台任务

    • Task.detached 处理非 UI 逻辑
  4. 数据同步

    • Actor 封装共享数据状态
  5. 流式数据

    • 使用 AsyncStream 实现 WebSocket 消息流

阶段 4:进阶与性能优化

  1. 取消与资源管理

    • 学会在耗时任务中正确响应取消请求
  2. 并发限制

    • 使用 Semaphore 或自定义 TaskGroup 限制并发数
  3. 调试

    • 使用 Instruments 的 Concurrency Template 分析任务执行情况
  4. 与 GCD/Operation 混用

    • 明确什么时候保留 GCD(如与旧库集成)

学习资源推荐

  • 官方文档Swift Concurrency

  • Apple WWDC 视频

    • Meet async/await in Swift
    • Explore structured concurrency in Swift
    • Protect mutable state with Swift actors
  • 书籍

    • Using Async/Await in Swift by Marin Todorov
    • Swift Concurrency by Tutorials(raywenderlich)

打开swift官网,关于swift并发的介绍是这样的

async/await

这俩关键字的作用是用写同步代码的方式来写异步逻辑,避免传统基于多线程+回调的异步编程模式所具有的回调地狱的问题

Task

可异步执行的代码块,支持控制并发任务的生命周期、结构化并发与取消传播

actor

用编译器检查的方式保证可变状态在并发中安全访问(数据隔离模型),避免手工加锁

苹果首次在中国永久关闭了一家 Apple Store | 肘子的 Swift 周报 #097

作者 东坡肘子
2025年8月12日 08:07

issue97.webp

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

苹果首次在中国永久关闭了一家 Apple Store

上周六,苹果正式永久性关闭了位于中国大连的一家 Apple Store 零售店,这是苹果首次在中国关闭直营店。该店于 2015 年开业,距今正好十年。消息传出后,网络上出现了不少相关报道,其中一些将此事解读为苹果在中国经营状况的某种信号。由于该店正好位于我所在的城市,我对这个事件有一些实地了解,想分享一些不同的观察角度。

2015 年该店开业时,其所在位置是大连最核心的商圈。开业初期确实吸引了大量苹果爱好者,加之地理位置的重要性,许多来大连的游客也将其作为打卡地,对苹果的品牌宣传和服务都起到了积极作用。然而,随着城市规模扩大以及功能区域的调整,这个曾经最核心的商圈在过去几年已被多个新兴商圈所取代。无论是人流量还是商业综合体的硬件设施都出现了明显滑坡,目前的人气不及高峰期的十分之一。同一商圈内,不少国际品牌也相继调整或撤出。

事实上,即便我现在需要去 Apple Store,也会选择苹果 2016 年在大连开设的第二家店(位于一个更加现代化的商业综合体)。过去几年间,我和身边的亲友去这个老商圈的次数屈指可数。因此,苹果直到现在才关闭该店反而超出了我的预期。恰逢该店所在商业体因股权变更导致经营业态调整,苹果选择此时关店,从经营角度看是合理的商业决策。

有传闻称,苹果可能仍会在大连保持两店配置,考虑在正在建设的新地标商业综合体(预计 2028 年开业)中开设新店。我倒是真心希望该计划能够实现,因为新址距离我住的地方不远,几乎每天散步都会路过。

苹果目前在中国拥有超过 40 家官方零售店。尽管线上购物已足够便利,但零售店在品牌宣传、文化传播、售后服务等方面的功能仍不可替代,尤其是它能为苹果爱好者提供独特的情感价值。对于苹果这样的全球性企业来说,根据市场变化调整门店布局——开设新店、关闭旧店,本就是常态化的经营行为。这次大连店的关闭虽然是“中国首次”,但更多是巧合而非转折,是城市发展变迁中的一个自然结果。

又到了每年休暑假 ⛱️ 的时候,接下来博客文章将会停更 4-5 周,期间《肘子的 Swift 周报》仍正常更新。

前一期内容全部周报列表

原创

Swift 6: Sendable、@unchecked Sendable、@Sendable、sending and nonsending

Swift 的并发模型引入了众多关键字,其中一些在命名和用途上颇为相似,容易让开发者感到困惑。本文将对 Swift 并发中与跨隔离域传递相关的几个关键字:Sendable@unchecked Sendable@Sendablesendingnonsending 进行梳理,帮助大家理解它们各自的作用和使用场景。

近期推荐

@isolated(any)

在 Swift 为并发编程引入的众多关键字中,很多对于 API 使用者来说几乎不需要深入掌握,但在某些特定场景下却至关重要。在本文中,Matt Massicotte 详细介绍了这样一个关键字——@isolated(any)

它主要解决了使用 async 函数时面临的类型擦除问题:我们无法静态地获知一个函数的隔离状态信息。@isolated(any) 通过为函数添加一个特殊的 isolation 属性来表示该函数的隔离情况(any Actor 实例或非隔离状态)。对于需要设计能够接受隔离函数并进行智能调度的 API 的开发者来说,这个功能提供了更精细的控制能力,特别是在任务调度和执行顺序保证方面。


使用异步流持续观察属性变化 (Swift Observations AsyncSequence for State Changes)

Observation 框架为 Swift 带来了原生的属性级别观察能力,但从诞生之初就缺少了一个关键功能——对属性的持续观察。虽然可以通过递归调用 withObservationTracking 来部分实现这一需求,但随着 Swift 进入严格并发时代,编写不包含并发警告的持续观察代码变得越来越困难。Keith Harrison 在本文中介绍了 Swift 6.2 新引入的功能:通过 Observations 函数,开发者可以更优雅地使用异步流的方式来对可观察对象的属性进行持续且安全的观察。这个新 API 不仅解决了并发安全问题,还提供了更符合现代 Swift 异步编程范式的解决方案。

Observations 对操作系统版本的严格限制(iOS 26/macOS 26)确实令人遗憾,这样一个实用的功能理应提供向后兼容支持。


Valkey Swift 客户端发布 (Introducing valkey-swift, the Swift Client for Valkey)

Valkey 是一个高性能键值存储系统,源自 Redis 7.2.4 的开源分支,旨在保留 BSD 许可证、规避 SSPL 与商业授权限制。不久前,Valkey 官方发布了 Swift 客户端 valkey-swift:基于 Swift Concurrency 构建,覆盖全部 Valkey 命令集,并内置连接池、管道化、集群与 Pub/Sub 支持。在Adam Fowler 的这篇文章中,你可以看到完整的使用示例与最佳实践,帮助 Swift 开发者在应用中无缝接入 Valkey 的强大能力。


在 Zed 中开发调试 iOS 应用 (Build, Run and Debug iOS and Mac Apps in Zed instead of Xcode)

凭借高效与协作友好的特性,Zed 受到不少开发者青睐,但其对 Swift 的原生支持仍相当有限,难以满足 Apple 平台开发的全流程需求。Adrian Ross 在本文中介绍了如何结合 xcode-build-server 以及自研工具 xcede,在 Zed 中实现 iOS 与 macOS 应用的构建、运行与调试,从而在很大程度上替代 Xcode 作为日常开发环境。


探索苹果端侧 AI 框架 (Exploring the Foundation Models Framework)

在 WWDC 2026 上,苹果推出 Foundation Models,进一步巩固其在端侧 AI 领域的布局。Luca Palmese 在本文中详解了如何获取系统语言模型(含特定用例适配器)、创建会话并进行完整或流式响应、借助 @Generable@Guide 实现类型安全的结构化输出,以及通过 Tool 协议让模型调用应用功能,为在本地构建聊天、摘要、分类等 AI 体验提供了完整示例。

尽管 Foundation Models 展示了端侧 AI 的潜力,但其相对较小的参数规模也让不少开发者对其能力与适用场景存疑。reddit 上正好有一则相关讨论,你也可以参与交流。


构建可扩展的 SwiftUI 组件 (Crafting SwiftUI Components for a Scalable Design System)

随着项目复杂度提升,开发者往往会在 SwiftUI 原生组件之外进行更多自定义。然而,功能堆叠过多的组件很容易陷入“构造器过载 + 紧耦合布局”的困境。muhammad osama 从这一痛点切入,通过逐步迭代示例,演示了如何构建既可维护、可扩展,又兼顾可访问性的组件,让自定义 SwiftUI 组件既具备原生视图的易用性,又能在设计系统中优雅演进。


macOS Sequoia 版本周期总结 (macOS Sequoia End of Cycle Report)

距离 macOS 26(Tahoe)正式发布还有一个月,Howard Oakley 回顾了 macOS Sequoia(15.x)一年的生命周期,总结了版本更新节奏、安全修复数量、系统体积变化以及框架增减趋势。在文章的评论区,Howard 还透露了一组耐人寻味的数据:从 Sonoma 到 Sequoia,新增 50 个公有框架,却增加了 708 个私有框架,显示苹果在不断扩展内部功能和自家应用的同时,对第三方开发者的开放支持增长有限。


大模型成本困境 (Tokens are Getting More Expensive)

AI 大模型公司获得巨额融资的一个核心假设,是随着技术进步,训练与推理成本会显著下降,从而实现理想利润。但 Ethan Ding 指出一个残酷现实:用户始终追求性能最强的模型(而低成本旧模型的使用率极低),同时上下文容量的提升让单次任务的 token 消耗呈指数级增长,使固定价格的“无限使用”模式在经济上不可持续。文章结合 Claude Code 案例,分析了行业面临的“囚徒困境”,并提出三条可能的突围路径。

往期内容

THANK YOU

如果你觉得这份周报或者我的文章对你有所帮助,欢迎 点赞 并将其 转发 给更多的朋友。

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

纯粹的广告变现,已经来到了山穷水尽的地步

作者 iOS研究院
2025年8月11日 11:23

背景

各大广告平台在7月17号,开启了SDK版本的重大升级。以穿山甲为例:站内信也在多次提醒,请将SDK版本升级到最新版本。

其实从7月15号开始,广告点击率已经有了明显的风控,从15号~17号,点击率从两位数变个位数。足以预见未来广告收益的波动将会迎来,多么严峻的暴风雨。

ecpm.png

整治重点

针对恶意的广告App,随意跳转第三方应用的问题,进行了针对性打击。

同时,整改关于误触堆出来的曝光和点击率

带来的影响?

以开屏广告为例,ecpm从40元左右下降到了20元左右,基本上可以说是腰斩。

作为变现作为核心的激励视频和插屏广告,也大相径庭。单用户产生收益从人均0.18元,锐减到0.055元。

是的,没有看错。靠单一广告变现,且恶意堆砌曝光广告的路子终将落幕。

对于产品商业化的发展和规划,需要有更多的反思。每一次洗牌的背后,都是对于竞品的肃清。

如何转型?

单一的广告依然鸡肋,建议更多的开发者应该把心思放在让用户,玩的简单,用的舒服,体验较好。同时,确实帮助用户解决了生活所需的痛点。

就当下的环境而言,其实大多数用户是很愿意为自己的情绪价值买单,越来越多的人开始尊重知识付费。

所以,弱化广告变现,提升会员权益,将是未来产品发展不可或缺的生命力。

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

相关推荐

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

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

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

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

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

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

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

accessibilityIdentifier 的唯一性要求

作者 littleplayer
2025年8月11日 10:00

accessibilityIdentifier 的唯一性要求

关于 accessibilityIdentifier 是否需要唯一值,以及在同一个页面中是否可以重复使用,以下是详细解答:

核心结论

accessibilityIdentifier 不需要全局唯一,但在以下情况下有特定要求:

  1. 在UI测试中:如果你需要通过标识符查找特定元素,那么这些元素的标识符在查询范围内需要唯一
  2. 在同一个视图层次中:对于需要单独访问的同类元素,应该使用不同标识符
  3. 对于自动化测试:XCUITest等框架要求查询时标识符能唯一确定目标元素

详细说明

1. 技术层面没有强制唯一性

  • 系统不会强制要求 accessibilityIdentifier 必须唯一
  • 你可以为多个视图设置相同的标识符而不会导致崩溃或错误
  • 框架本身没有内置的重复检查机制

2. 实际使用中的唯一性需求

可以重复的情况

  • 当你不打算通过这个标识符单独访问某个特定元素时
  • 对于功能相同且不需要单独测试的重复元素(如列表中的相似项)

应该唯一的情况

  • 当你需要在UI测试中精确查找特定元素时
  • 对于需要单独交互或验证的关键元素

3. UI测试中的行为

在XCUITest中:

// 如果有多个元素具有相同的accessibilityIdentifier
let elements = app.buttons["sameIdentifier"] 
// elements.count 会返回匹配的元素数量

// 如果你只需要其中一个,测试会变得困难
let firstMatch = app.buttons["sameIdentifier"].firstMatch // 不稳定的选择方式

4. 最佳实践

推荐做法

// 对于需要单独访问的元素使用唯一ID
Button("Save") { ... }
  .accessibilityIdentifier("settingsSaveButton") // 具体场景+功能

Button("Save") { ... }
  .accessibilityIdentifier("editorSaveButton") // 不同场景使用不同ID

// 对于列表中的相似项可以使用模式化ID
ForEach(items) { item in
  ItemView(item: item)
    .accessibilityIdentifier("item_\(item.id)") // 添加唯一后缀
}

可以接受的做法

// 如果你只需要知道某类元素存在而不需要单独操作
ForEach(items) { _ in
  ItemView()
    .accessibilityIdentifier("listItem") // 所有项相同ID
}
// 测试中可以用 app.buttons["listItem"].count 验证数量

为什么有时需要重复

  1. 批量操作:测试中可能需要验证某类元素的数量或统一操作
  2. 语义分组:标识一组功能相似的元素
  3. 动态内容:暂时无法确定唯一标识的场景

实际示例

可以重复的情况

// 产品列表中每个产品的"购买"按钮不需要唯一ID
// 因为测试只需要验证"存在购买按钮"
ForEach(products) { product in
  ProductView(product: product)
    .accessibilityIdentifier("productBuyButton")
}

应该唯一的情况

// 设置页面中每个开关需要独立控制
Toggle("通知", isOn: $notify)
  .accessibilityIdentifier("settingsNotifyToggle")

Toggle("暗黑模式", isOn: $darkMode)
  .accessibilityIdentifier("settingsDarkModeToggle")

总结

  • 不是必须唯一:技术上允许多个元素共享相同 accessibilityIdentifier
  • 测试需要唯一:如果你需要通过ID查找特定元素,则该ID在查找范围内应该唯一
  • 平衡设计:根据实际测试需求决定是否使用唯一ID
  • 推荐模式:对于重要元素使用完整上下文ID(如"screen_element_purpose"格式),对于批量元素可以使用共享ID+额外查询条件

最终是否要求唯一性取决于你如何使用这些标识符进行测试和验证。

accessibilityIdentifier 与 @AccessibilityFocusState 的区别与使用场景

作者 littleplayer
2025年8月11日 09:47

accessibilityIdentifier@AccessibilityFocusState 的区别与使用场景

这两个属性虽然都与无障碍功能相关,但用途和实现机制完全不同。下面我将详细比较它们的区别并说明各自的使用场景。

核心区别

特性 accessibilityIdentifier @AccessibilityFocusState
主要用途 用于UI测试定位元素 管理辅助技术(如VoiceOver)的焦点状态
影响对象 测试框架 辅助技术用户
可见性 对用户不可见 直接影响用户体验
数据类型 字符串标识符 布尔值或枚举
系统版本 iOS 5.0+ iOS 15.0+/macOS 12.0+
底层技术 设置accessibilityIdentifier属性 使用SwiftUI的无障碍焦点管理系统

accessibilityIdentifier 详解

原理

  • 为视图设置一个唯一标识字符串
  • 不会影响实际的无障碍特性
  • 主要用于自动化测试中定位元素

使用场景

  1. UI测试定位

    Button("Submit") { ... }
      .accessibilityIdentifier("submitButton")
    
    // 测试中可以通过XCUIElement查询
    let submitButton = app.buttons["submitButton"]
    
  2. 元素标记

    • 标记复杂界面中的特定元素
    • 动态生成的视图标识
  3. 与VoiceOver无关

    • 不会影响VoiceOver的阅读顺序或内容
    • 普通用户和辅助技术用户都感知不到

示例

TextField("Username", text: $username)
  .accessibilityIdentifier("loginUsernameField")

@AccessibilityFocusState 详解

原理

  • 管理辅助技术(如VoiceOver)的焦点状态
  • 响应VoiceOver手势或编程焦点变更
  • 与SwiftUI的焦点系统深度集成

使用场景

  1. 引导VoiceOver焦点

    @AccessibilityFocusState private var isFirstNameFocused: Bool
    
    TextField("First Name", text: $firstName)
      .accessibilityFocused($isFirstNameFocused)
    
  2. 复杂导航流程

    • 表单验证后自动聚焦错误字段
    • 模态对话框打开时聚焦第一个可操作元素
  3. 自定义焦点顺序

    • 覆盖系统默认的阅读顺序
    • 创建非线性的焦点路径

示例

struct SignUpView: View {
  enum Field { case email, password, submit }
  @AccessibilityFocusState private var focusedField: Field?
  
  var body: some View {
    VStack {
      TextField("Email", text: $email)
        .accessibilityFocused($focusedField, equals: .email)
      
      SecureField("Password", text: $password)
        .accessibilityFocused($focusedField, equals: .password)
      
      Button("Submit") { ... }
        .accessibilityFocused($focusedField, equals: .submit)
    }
    .onAppear { focusedField = .email } // 初始聚焦邮箱字段
  }
}

何时使用哪个

使用 accessibilityIdentifier 当:

  • 需要为UI测试标记元素
  • 想要在自动化脚本中可靠地定位视图
  • 需要标识动态生成的内容
  • 不希望影响实际的无障碍体验

使用 @AccessibilityFocusState 当:

  • 需要改善VoiceOver/Switch Control体验
  • 要编程控制辅助技术焦点
  • 需要自定义焦点顺序
  • 要响应辅助技术的焦点变化

组合使用案例

实际上,两者可以一起使用,各司其职:

struct PaymentView: View {
  enum Field { case cardNumber, expiry, cvv }
  @AccessibilityFocusState private var focusedField: Field?
  
  var body: some View {
    Form {
      TextField("Card Number", text: $cardNumber)
        .accessibilityIdentifier("cardNumberField")
        .accessibilityFocused($focusedField, equals: .cardNumber)
      
      TextField("Expiry Date", text: $expiry)
        .accessibilityIdentifier("expiryField")
        .accessibilityFocused($focusedField, equals: .expiry)
      
      TextField("CVV", text: $cvv)
        .accessibilityIdentifier("cvvField")
        .accessibilityFocused($focusedField, equals: .cvv)
    }
    .onAppear { focusedField = .cardNumber }
  }
}

在这个例子中:

  • accessibilityIdentifier 帮助测试框架定位元素
  • @AccessibilityFocusState 确保VoiceOver用户有合理的焦点流程

总结

理解这两者的区别关键在于:

  • accessibilityIdentifier面向开发者/测试人员的工具,用于测试和调试
  • @AccessibilityFocusState面向最终用户(特别是辅助技术用户)的功能,用于改善无障碍体验

正确使用这两个特性可以同时提高应用的可测试性和可访问性。

iOS26适配指南之UIVisualEffectView

作者 YungFan
2025年8月11日 09:35

介绍

增加了符合 Liquid Glass 风格的效果UIGlassEffectUIGlassContainerEffect

UIGlassEffect

代码

import UIKit

class ViewController: UIViewController {
    lazy var wwdcLabel: UILabel = {
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 50))
        label.text = "WWDC25"
        label.textAlignment = .center
        label.textColor = .white
        return label
    }()
    let visualEffectView = UIVisualEffectView()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemTeal

        glassEffect()
    }

    // MARK: UIGlassEffect
    func glassEffect() {
        // iOS26新增
        let glassEffect = UIGlassEffect()
        glassEffect.isInteractive = true
        visualEffectView.effect = glassEffect
        visualEffectView.frame = CGRect(x: view.frame.midX - 50, y: view.frame.midY - 25, width: 100, height: 50)
        visualEffectView.layer.cornerRadius = 20
        visualEffectView.clipsToBounds = true
        visualEffectView.contentView.addSubview(wwdcLabel)
        view.addSubview(visualEffectView)
    }
}

效果

UIGlassEffect.png

UIGlassContainerEffect

代码

import UIKit

class ViewController: UIViewController {
    lazy var wwdcLabel: UILabel = {
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 50))
        label.text = "WWDC25"
        label.textAlignment = .center
        label.textColor = .white
        return label
    }()
    lazy var iOSLabel: UILabel = {
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 50))
        label.text = "iOS26"
        label.textAlignment = .center
        label.textColor = .white
        return label
    }()
    let visualEffectView = UIVisualEffectView()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemTeal

        glassContainerEffect()
    }

    // MARK: UIGlassContainerEffect
    func glassContainerEffect() {
        // iOS26新增
        let glassContainerEffect = UIGlassContainerEffect()
        visualEffectView.effect = glassContainerEffect
        visualEffectView.frame = CGRect(x: 0, y: 0, width: 210, height: 50)
        visualEffectView.center = view.center
        let glassEffect = UIGlassEffect()
        let view1 = UIVisualEffectView(effect: glassEffect)
        view1.frame = CGRect(x: 10, y: 10, width: 100, height: 50)
        view1.contentView.addSubview(wwdcLabel)
        let view2 = UIVisualEffectView(effect: glassEffect)
        view2.frame = CGRect(x: 110, y: 10, width: 100, height: 50)
        view2.contentView.addSubview(iOSLabel)
        visualEffectView.contentView.addSubview(view1)
        visualEffectView.contentView.addSubview(view2)
        view.addSubview(visualEffectView)
    }
}

效果

UIGlassContainerEffect.png

NSThread

作者 Magic_ht
2025年8月10日 23:27

在 iOS/macOS 开发中,NSThread 是用于处理多线程操作的基础类,startcancel 和 main 是其核心方法,各自承担不同职责:

1. start() 方法

  • 作用:启动线程,将线程从 “新建” 状态切换到 “就绪” 状态,等待系统调度执行。

  • 执行流程:调用 start() 后,系统会在合适时机调用线程的 main() 方法(线程的入口点)。

  • 注意

    • 一个线程对象只能调用一次 start(),重复调用会导致异常。
    • 调用后线程并非立即执行,而是进入系统的线程调度队列,等待 CPU 分配时间片。

2. main() 方法

  • 作用:线程的主执行函数,线程启动后实际运行的代码逻辑都在这里。

  • 使用方式

    • 通常需要子类化 NSThread 并重写 main() 方法,在其中实现具体任务。
    • 也可以通过 NSThread 的初始化方法(如 initWithTarget:selector:object:)指定要执行的方法,替代重写 main()
  • 注意

    • main() 方法执行完毕后,线程会进入 “终止” 状态。
    • 若在 main() 中处理耗时任务,需定期检查线程是否被取消(见 cancel 方法),以便及时退出。

3. cancel() 方法

  • 作用:标记线程为 “取消” 状态,但不会立即终止线程。

  • 工作原理

    • 仅设置线程的 isCancelled 属性为 YES,不会主动中断线程的执行。
    • 需要在 main() 方法中主动检查 isCancelled 状态,若为 YES 则手动退出任务,实现线程终止。
  • 示例

    objective-c

    // 子类化 NSThread 并重写 main
    @interface MyThread : NSThread
    @end
    
    @implementation MyThread
    - (void)main {
        // 模拟耗时任务,定期检查是否被取消
        for (int i = 0; i < 1000; i++) {
            // 检查取消状态,若已取消则退出
            if (self.isCancelled) {
                NSLog(@"线程被取消,退出执行");
                return;
            }
            NSLog(@"执行第 %d 步", i);
            [NSThread sleepForTimeInterval:0.1]; // 模拟耗时
        }
    }
    @end
    
    // 使用线程
    MyThread *thread = [[MyThread alloc] init];
    [thread start]; // 启动线程
    
    // 一段时间后取消线程
    [thread cancel];
    

总结

  • start():启动线程,触发 main() 执行。
  • main():线程的核心逻辑执行处,需包含具体任务代码。
  • cancel():标记线程为取消状态,需配合 isCancelled 检查实现线程退出。

UIApplicationDelegate执行说明

作者 Magic_ht
2025年8月10日 23:23

在 iOS 应用的生命周期中,UIApplicationDelegate 方法的执行顺序与应用的状态变化密切相关。以下是典型场景下的方法调用顺序:

一、应用启动过程(冷启动)

  1. application:willFinishLaunchingWithOptions:
    最早被调用,此时应用刚完成初始化,还未加载 UIWindow 和根视图控制器。可在此做一些早期初始化工作。
  2. application:didFinishLaunchingWithOptions:
    应用完成基本启动,UIWindow 已初始化,但尚未显示。通常在此设置根视图控制器、初始化第三方框架等,返回 YES 表示允许应用启动。
  3. applicationDidBecomeActive:
    应用从 “非活跃” 状态切换到 “活跃” 状态,此时用户可以交互。启动完成后会最终进入此状态。

二、应用进入后台

  1. applicationWillResignActive:
    应用即将失去焦点(如来电、下拉通知中心),进入 “非活跃” 状态。需暂停正在进行的任务(如游戏逻辑、视频播放)。
  2. applicationDidEnterBackground:
    应用完全进入后台。需保存数据、释放内存,若需后台运行(如下载),需在此申请后台任务标识符。

三、应用从后台返回前台

  1. applicationWillEnterForeground:
    应用即将从后台回到前台,仍处于 “非活跃” 状态。可恢复数据、重新初始化界面。
  2. applicationDidBecomeActive:
    应用完全激活,用户可再次交互。需恢复之前暂停的任务。

四、应用终止

  1. applicationWillTerminate:
    应用即将被终止(如用户强制关闭、系统内存不足)。仅在此做最后的数据保存,无法执行耗时操作。

简化流程图

plaintext

冷启动 → willFinishLaunching → didFinishLaunching → didBecomeActive(活跃状态)

活跃 → willResignActive → didEnterBackground(后台状态)

后台 → willEnterForeground → didBecomeActive(回到活跃)

终止 → willTerminate

注意事项

  • 不同场景(如通过推送启动、从后台被系统杀死后重启)可能触发部分方法的差异。

  • didFinishLaunchingWithOptions: 的 launchOptions 参数可获取启动原因(如点击推送、URL Scheme 唤醒)。

  • iOS 13+ 后,基于 UISceneDelegate 的场景生命周期逐渐替代传统 UIApplicationDelegate,但核心状态切换逻辑类似。

理解这些顺序有助于合理安排初始化、资源管理和状态恢复的代码逻辑。

推送启动的怎么执行

当应用通过推送通知(远程推送或本地推送)启动时,UIApplicationDelegate 方法的执行顺序与普通冷启动基本一致,但会在启动参数中携带推送相关信息。具体流程如下:

一、推送启动的完整执行顺序

  1. application:willFinishLaunchingWithOptions:
    最早被调用,此时可通过 launchOptions 参数获取推送信息(键为 UIApplicationLaunchOptionsRemoteNotificationKey 或 UIApplicationLaunchOptionsLocalNotificationKey)。

  2. application:didFinishLaunchingWithOptions:
    核心启动方法,launchOptions 参数会包含推送数据,需在此处理推送相关逻辑(如跳转指定页面、展示推送内容)。
    示例:

    objective-c

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        // 检查是否通过远程推送启动
        NSDictionary *remoteNotification = launchOptions[UIApplicationLaunchOptionsRemoteNotificationKey];
        if (remoteNotification) {
            // 处理推送逻辑,如跳转页面
            [self handlePushNotification:remoteNotification];
        }
        return YES;
    }
    
  3. applicationDidBecomeActive:
    应用完全激活后调用,若有需要在前台展示的推送相关 UI(如弹窗),可在此处理。

二、特殊情况:应用已在后台运行时收到推送

若应用处于后台而非完全关闭状态,点击推送启动时:

  1. 先调用 application:didReceiveRemoteNotification:fetchCompletionHandler: (远程推送)或 application:didReceiveLocalNotification:(本地推送),获取推送数据。
  2. 接着执行:
    applicationWillEnterForeground: → applicationDidBecomeActive:
    可在这两个方法中处理前台展示逻辑(如从后台切换到前台时的页面跳转)。

三、关键区别

  • 冷启动(应用完全关闭) :推送信息仅在 launchOptions 中传递,需在 didFinishLaunchingWithOptions: 中处理。

  • 后台运行时:推送信息通过 didReceiveRemoteNotification: 等方法直接传递,无需依赖 launchOptions

通过判断启动参数和应用状态,可区分 “点击推送启动” 和 “普通启动”,从而执行对应的业务逻辑(如根据推送内容跳转到特定页面)。

URL Scheme 唤醒的怎么执行

当应用通过 URL Scheme 唤醒时(例如从其他应用或浏览器点击自定义链接打开),UIApplicationDelegate 方法的执行顺序会根据应用当前状态(未启动 / 已在后台)有所不同,核心是通过特定方法接收 URL 信息。

一、应用完全关闭时(冷启动通过 URL Scheme 唤醒)

此时执行顺序与普通冷启动类似,但会在启动参数中携带 URL 信息:

  1. application:willFinishLaunchingWithOptions:
    最早被调用,可通过 launchOptions 获取唤醒的 URL:

    objective-c

    NSURL *url = launchOptions[UIApplicationLaunchOptionsURLKey];
    if (url) {
        // 提前处理 URL 信息(可选)
    }
    
  2. application:didFinishLaunchingWithOptions:
    核心启动方法,必须在此处理 URL 逻辑(如解析 URL 参数、跳转对应页面)。
    示例:

    objective-c

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        // 初始化窗口和根控制器(必需步骤)
        self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];
        self.window.rootViewController = [[UINavigationController alloc] initWithRootViewController:[[HomeViewController alloc] init]];
        [self.window makeKeyAndVisible];
        
        // 检查是否通过 URL Scheme 启动
        NSURL *url = launchOptions[UIApplicationLaunchOptionsURLKey];
        if (url) {
            [self handleURLScheme:url]; // 解析 URL 并处理(如跳转页面)
        }
        return YES;
    }
    
  3. application:openURL:options:
    在 didFinishLaunchingWithOptions: 之后调用,再次传递 URL 信息(建议在此统一处理,更规范)。
    示例:

    objective-c

    - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
        // 统一处理 URL Scheme
        return [self handleURLScheme:url];
    }
    
  4. applicationDidBecomeActive:
    应用激活后调用,可执行后续操作(如刷新页面数据)。

二、应用已在后台运行时(通过 URL Scheme 唤醒)

此时应用无需重新初始化,直接从后台切换到前台,执行顺序:

  1. applicationWillEnterForeground:
    应用即将从后台进入前台。
  2. application:openURL:options:
    核心方法,接收并处理唤醒的 URL(解析参数、跳转页面等)。
  3. applicationDidBecomeActive:
    应用完全激活,完成后续交互逻辑。

三、关键说明

  1. URL 处理的最佳实践
    无论应用是冷启动还是后台唤醒,application:openURL:options: 都会被调用,建议在此方法中统一处理 URL 解析逻辑,避免代码冗余。
  2. URL 格式示例
    假设自定义 Scheme 为 myapp,唤醒链接可能为 myapp://page/detail?id=123,需解析 page 和 id 参数来决定跳转页面。
  3. iOS 13+ 适配
    若使用 UISceneDelegate,URL Scheme 唤醒逻辑会转移到 scene:openURLContexts: 方法中,流程类似但需适配场景生命周期。

flutter_flavorizr 多渠道打包、多环境打包利器,不需要再一个个手动配置了

2025年8月10日 11:25

在 app 开发中,测试工程师经常会说,我要在一个手机上同时安装测试环境、预发布环境、正式环境的包,又或者一套代码上架多个 app,怎么搞,手动配置多个环境过程繁琐。无意间看到 flutter_flavorizr 插件 传送门,完美解决了我的问题

1.安装

dev_dependencies:
  flutter_flavorizr: ^2.4.1

2.在项目根目录手动创建 flavorizr.yaml文件,app icon 用 1024*1024

image.png

3.在flavorizr.yaml文件中配置环境信息

flavors:
  develop:
    app:
      name: "meituan"
      icon: "assets/icons/icon_develop.png"

    android:
      applicationId: "com.meituan.develop"  
    ios:
      bundleId: "com.meituan.develop"
  staging:
    app:
      name: "meituan"
      icon: "assets/icons/icon_staging.png"
  
    android:
      applicationId: "com.meituan.staging"
    ios:
      bundleId: "com.meituan.staging"

  prod:
    app:
      name: "meituan"
      icon: "assets/icons/icon_prod.png"
  
    android:
      applicationId: "com.meituan"
    ios:
      bundleId: "com.meituan"

ide: vscode

4.配置完毕后,运行下面脚本

flutter pub run flutter_flavorizr

如遇到下面提示 Do you want to proceed? (Y/n),直接回复 y

The following instructions will be executed:
 - assets:download
 - assets:extract
 - android:androidManifest
 - android:flavorizrGradle
 - android:buildGradle
 - android:dummyAssets
 - android:icons
 - flutter:flavors
 - flutter:app
 - flutter:pages
 - flutter:main
 - ios:podfile
 - ios:xcconfig
 - ios:buildTargets
 - ios:schema
 - ios:dummyAssets
 - ios:icons
 - ios:plist
 - ios:launchScreen
 - google:firebase
 - huawei:agconnect
 - assets:clean
 - ide:config
Do you want to proceed? (Y/n) y

如遇到 Failed to update packages, 需要科学上网

⠏ [assets:download] Executing... (90.2s)Unhandled exception:
HttpException: Connection closed before full header was received, uri = https://github.com/AngeloAvv/flutter_flavorizr/releases/download/v2.4.1/assets.zip
Failed to update packages.

脚本执行完毕

image.png

在 android/app/src中生成了 develop、staging 和 prod等目录,里面是 icon 等配置信息

image.png

同样在 iOS文件中自动生成环境和 icon 等配置信息

image.png

如果使用的是vscode编辑器,还会生成 launch.json配置,如下图,默认没有格式化,可使用 vscode 的格式工具,cmd+s保存完成格式化

image.png

格式化后,launch.json配置如下

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "develop Debug",
            "request": "launch",
            "type": "dart",
            "flutterMode": "debug",
            "args": [
                "--flavor",
                "develop"
            ],
            "program": "lib/main.dart"
        },
        {
            "name": "develop Profile",
            "request": "launch",
            "type": "dart",
            "flutterMode": "profile",
            "args": [
                "--flavor",
                "develop"
            ],
            "program": "lib/main.dart"
        },
        {
            "name": "develop Release",
            "request": "launch",
            "type": "dart",
            "flutterMode": "release",
            "args": [
                "--flavor",
                "develop"
            ],
            "program": "lib/main.dart"
        },
        {
            "name": "staging Debug",
            "request": "launch",
            "type": "dart",
            "flutterMode": "debug",
            "args": [
                "--flavor",
                "staging"
            ],
            "program": "lib/main.dart"
        },
        {
            "name": "staging Profile",
            "request": "launch",
            "type": "dart",
            "flutterMode": "profile",
            "args": [
                "--flavor",
                "staging"
            ],
            "program": "lib/main.dart"
        },
        {
            "name": "staging Release",
            "request": "launch",
            "type": "dart",
            "flutterMode": "release",
            "args": [
                "--flavor",
                "staging"
            ],
            "program": "lib/main.dart"
        },
        {
            "name": "prod Debug",
            "request": "launch",
            "type": "dart",
            "flutterMode": "debug",
            "args": [
                "--flavor",
                "prod"
            ],
            "program": "lib/main.dart"
        },
        {
            "name": "prod Profile",
            "request": "launch",
            "type": "dart",
            "flutterMode": "profile",
            "args": [
                "--flavor",
                "prod"
            ],
            "program": "lib/main.dart"
        },
        {
            "name": "prod Release",
            "request": "launch",
            "type": "dart",
            "flutterMode": "release",
            "args": [
                "--flavor",
                "prod"
            ],
            "program": "lib/main.dart"
        }
    ]
}

vscode 中选择一种模式运行代码

image.png

在运行 iOS 模拟器时,如果遇到 xcode 报下面的错误,

# The Xcode project does not define custom schemes. You cannot use the --flavor option

那是因为没有安装 xcodeproj,执行下面代码安装

sudo gem install xcodeproj

然后再执行

flutter pub run flutter_flavorizr

5.后续服务器、三方等配置信息,可根据 flavor 进行配置

image.png

import 'flavors.dart';

class EnvConfig {
  static String get baseUrl {
    switch (F.appFlavor) {
      case Flavor.develop:
        return 'https://dev.api.example.com';
      case Flavor.staging:
        return 'https://staging.api.example.com';
      case Flavor.prod:
        return 'https://api.example.com';
    }
  }
}

IMG_4643.PNG

❌
❌