去 Apple Store 修手机 - 肘子的 Swift 周报 #107
父亲的 iPhone 16 突然无法充电。预约后,我前往 Apple Store 送修。工作人员确认问题后,为我提供了一部 iPhone 14 作为备用机,并协助完成数据转移。十二天后(期间正好赶上一个长假),设备维修完成——更换了 Type-C 接口,同时还免费更换了一块新电池。体验一如既往地令人满意。
父亲的 iPhone 16 突然无法充电。预约后,我前往 Apple Store 送修。工作人员确认问题后,为我提供了一部 iPhone 14 作为备用机,并协助完成数据转移。十二天后(期间正好赶上一个长假),设备维修完成——更换了 Type-C 接口,同时还免费更换了一块新电池。体验一如既往地令人满意。
这篇文章是从0到1自定义富文本渲染的原理篇之一,此外你还可能感兴趣:
更多内容可订阅公众号「非专业程序员Ping」,文中所有代码可在公众号后台回复 “CoreText” 获取。
CoreText是iOS/macOS中的文字排版引擎,提供了一系列对文本精确操作的API;UIKit中UILabel、UITextView等文本组件底层都是基于CoreText的,可以看官方提供的层级图:
本文的目的是结合实际使用例子,来介绍和总结CoreText中的重要概念和API。
CoreText中有几个重要概念:CTTypesetter、CTFramesetter、CTFrame、CTLine、CTRun;它们之间的关系可以看官方提供的层级图:
一篇文档可以分为:文档 -> 段落 -> 段落中的行 -> 行中的文字,类似的,CoreText也是按这个结构来组织和管理API的,我们也可以根据诉求来选择不同层级的API。
CTFramesetter类似于文档的概念,它负责将多段文本进行排版,管理多个段落(CTFrame)。
CTFramesetter的输入是属性字符串(NSAttributedString)和路径(CGPath),负责将文本在指定路径上进行排版。
CTFrame类似于段落的概念,其中包含了若干行(CTLine)以及对应行的位置、方向、行间距等信息。
CTLine类似于行的概念,其中包含了若干个字形(CTRun)以及对应字形的位置等信息。
需要注意CTRun不是单个的字符,而是一段连续的且具有相同属性(字体、颜色等)的字形(Glyph)。
如下,每个虚线框都代表一个CTRun:
CTTypesetter支持对属性字符串进行换行,可以通过CTTypesetter来自定义换行(比如按word换行、按char换行等)或控制每行的内容,可以理解成更精细化的控制。
1)CTFramesetterCreateWithAttributedString
func CTFramesetterCreateWithAttributedString(_ attrString: CFAttributedString) -> CTFramesetter
通过属性字符串来创建CTFramesetter。
我们可以构造不同字体、颜色、大小的属性字符串,然后从属性字符串构造CTFramesetter,之后可以继续往下拆分得到段落、行、字形等信息,这样可以实现自定义排版、图文混排等复杂富文本样式。
2)CTFramesetterCreateWithTypesetter
func CTFramesetterCreateWithTypesetter(_ typesetter: CTTypesetter) -> CTFramesetter
通过CTTypesetter来创建CTFramesetter,当我们需要对文本实现更精细控制,比如自定义换行时,可以自己构造CTTypesetter。
3)CTFramesetterCreateFrame
func CTFramesetterCreateFrame(
_ framesetter: CTFramesetter,
_ stringRange: CFRange,
_ path: CGPath,
_ frameAttributes: CFDictionary?
) -> CTFrame
生成CTFrame:在指定路径(path)为属性字符串的指定范围(stringRange)生成CTFrame。
framesetter
stringRange
:字符范围,注意需要以UTF-16编码格式计算;当 stringRange.length = 0 时,表示从起点(stringRange.location)到字符结束为止;比如当 CFRangeMake(0, 0) 表示全字符范围path
:排版路径,可以是不规则矩形,这意味着可以传入不规则图形来实现文字环绕等高级效果frameAttributes
:一个可选的字典,可以用于控制段落级别的布局行为,比如行间距等,一般用不到,可传 nil4)CTFramesetterSuggestFrameSizeWithConstraints
func CTFramesetterSuggestFrameSizeWithConstraints(
_ framesetter: CTFramesetter,
_ stringRange: CFRange,
_ frameAttributes: CFDictionary?,
_ constraints: CGSize,
_ fitRange: UnsafeMutablePointer<CFRange>?
) -> CGSize
计算文本宽高:在给定约束尺寸(constraints)下计算文本范围(stringRange)的实际宽高。
如下,我们可以计算出在宽高 100 x 100 的范围内排版,实际能放下的文本范围(fitRange)以及实际的文本尺寸:
let attr = NSAttributedString(string: "这是一段测试文本,通过调用CTFramesetterSuggestFrameSizeWithConstraints来计算文本的宽高信息,并返回实际的range", attributes: [
.font: UIFont.systemFont(ofSize: 16),
.foregroundColor: UIColor.black
])
let framesetter = CTFramesetterCreateWithAttributedString(attr)
var fitRange = CFRange(location: 0, length: 0)
let size = CTFramesetterSuggestFrameSizeWithConstraints(
framesetter,
CFRangeMake(0, 0),
nil,
CGSize(width: 100, height: 100),
&fitRange
)
print(size, fitRange, attr.length)
这个API在分页时非常有用,比如微信读书的翻页效果,需要知道在哪个地方截断,PDF的分页排版等。
1)实现一个支持AutoLayout且高度靠内容撑开的富文本View
2)在圆形路径中绘制文本
3)文本分页:模拟微信读书的分页逻辑
1)CTFramesetterCreateFrame
func CTFramesetterCreateFrame(
_ framesetter: CTFramesetter,
_ stringRange: CFRange,
_ path: CGPath,
_ frameAttributes: CFDictionary?
) -> CTFrame
创建CTFrame,在CTFramesetter一节中有介绍过,这是创建CTFrame的唯一方式。
2)CTFrameGetStringRange
func CTFrameGetStringRange(_ frame: CTFrame) -> CFRange
获取CTFrame包含的字符范围。
我们在调用CTFramesetterCreateFrame创建CTFrame时,会传入一个 stringRange 的参数,CTFrameGetStringRange也可以理解成获取这个 stringRange,区别是处理了当 stringRange.length 为0的情况。
3)CTFrameGetVisibleStringRange
func CTFrameGetVisibleStringRange(_ frame: CTFrame) -> CFRange
获取CTFrame实际可见的字符范围。
我们在调用CTFramesetterCreateFrame创建CTFrame时,会传入path,可能会把字符截断,CTFrameGetVisibleStringRange返回的就是可见的字符范围。
需要注意和CTFrameGetStringRange进行区分,可以用如下Demo验证:
let longText = String(repeating: "这是一个分栏布局的例子。Core Text 允许我们将一个长的属性字符串(CFAttributedString)流动到多个不同的路径(CGPath)中。我们只需要创建一个 CTFramesetter,然后循环调用 CTFramesetterCreateFrame。每次调用后,我们使用 CTFrameGetStringRange 来找出有多少文本被排入了当前的框架,然后将下一个框架的起始索引设置为这个范围的末尾。 ", count: 10)
let attributedText = NSAttributedString(string: longText, attributes: [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.darkText
])
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let path = CGPath(rect: .init(x: 10, y: 100, width: 400, height: 200), transform: nil)
let frame = CTFramesetterCreateFrame(
framesetter,
CFRange(location: 100, length: 0),
path,
nil
)
// 输出:CFRange(location: 100, length: 1980)
print(CTFrameGetStringRange(frame))
// 输出:CFRange(location: 100, length: 584)
print(CTFrameGetVisibleStringRange(frame))
4)CTFrameGetPath
func CTFrameGetPath(_ frame: CTFrame) -> CGPath
获取创建CTFrame时传入的path。
5)CTFrameGetLines
func CTFrameGetLines(_ frame: CTFrame) -> CFArray
获取CTFrame中所有的行(CTLine)。
6)CTFrameGetLineOrigins
func CTFrameGetLineOrigins(
_ frame: CTFrame,
_ range: CFRange,
_ origins: UnsafeMutablePointer<CGPoint>
)
获取每一行的起点坐标。
用法示例:
let lines = CTFrameGetLines(frame) as! [CTLine]
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
7)CTFrameDraw
func CTFrameDraw(
_ frame: CTFrame,
_ context: CGContext
)
绘制CTFrame。
1)绘制CTFrame
2)高亮某一行
3)检测点击字符
1)CTLineCreateWithAttributedString
func CTLineCreateWithAttributedString(_ attrString: CFAttributedString) -> CTLine
从属性字符串创建单行CTLine,如果字符串中有换行符(\n)的话,换行符会被转换成空格,如下:
let line = CTLineCreateWithAttributedString(
NSAttributedString(string: "Hello CoreText\nWorld", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)
2)CTLineCreateTruncatedLine
func CTLineCreateTruncatedLine(
_ line: CTLine,
_ width: Double,
_ truncationType: CTLineTruncationType,
_ truncationToken: CTLine?
) -> CTLine?
创建一个被截断的新行。
line
:待截断的行width
:在多少宽度截断truncationType
:start/end/middle,截断类型let truncationToken = CTLineCreateWithAttributedString(
NSAttributedString(string: "…", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)
let truncated = CTLineCreateTruncatedLine(line, 100, .end, truncationToken)
3)CTLineCreateJustifiedLine
func CTLineCreateJustifiedLine(
_ line: CTLine,
_ justificationFactor: CGFloat,
_ justificationWidth: Double
) -> CTLine?
创建一个两端对齐的新行,类似书籍或报纸中两端对齐的排版效果。
line
:原始行justificationFactor
:justificationFactor <= 0
表示不缩放,即与原始行相同;justificationFactor >= 1
表示完全缩放到指定宽度;0 < justificationFactor < 1
表示部分缩放到指定宽度,可以看示例代码justificationWidth
:缩放指定宽度示例:
4)CTLineDraw
func CTLineDraw(
_ line: CTLine,
_ context: CGContext
)
绘制行。
5)CTLineGetGlyphCount
func CTLineGetGlyphCount(_ line: CTLine) -> CFIndex
获取行内字形总数。
6)CTLineGetGlyphRuns
func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray
获取行内所有的CTRun。
7)CTLineGetStringRange
func CTLineGetStringRange(_ line: CTLine) -> CFRange
获取该行对应的字符范围。
8)CTLineGetPenOffsetForFlush
func CTLineGetPenOffsetForFlush(
_ line: CTLine,
_ flushFactor: CGFloat,
_ flushWidth: Double
) -> Double
获取在指定宽度绘制时的水平偏移,一般配合 CGContext.textPosition 使用,可用于实现在固定宽度下文本的左对齐、右对齐、居中对齐及自定义水平偏移等。
示例:
9)CTLineGetImageBounds
func CTLineGetImageBounds(
_ line: CTLine,
_ context: CGContext?
) -> CGRect
获取行的视觉边界;注意 CTLineGetImageBounds 获取的是相对于CTLine局部坐标系的矩形,即以textPosition为原点的矩形。
视觉边界可以看下面的例子,与之相对的是布局边界;这个API在实际应用中不常见,除非有特殊诉求,比如要检测精确的内容点击范围,给行绘制紧贴背景等。
10)CTLineGetTypographicBounds
func CTLineGetTypographicBounds(
_ line: CTLine,
_ ascent: UnsafeMutablePointer<CGFloat>?,
_ descent: UnsafeMutablePointer<CGFloat>?,
_ leading: UnsafeMutablePointer<CGFloat>?
) -> Double
获取上行(ascent)、下行(descent)、行距(leading)。
这几个概念不熟悉的可以参考:一文读懂字符、字形、字体
想了解这几个数值最终是从哪个地方读取的可以参考:一文读懂字体文件
通过这个API我们可以手动构造布局边界(见上面的例子),一般用于点击检测、绘制行背景等。
11)CTLineGetTrailingWhitespaceWidth
func CTLineGetTrailingWhitespaceWidth(_ line: CTLine) -> Double
获取行尾空白字符的宽度(比如空格、制表符 (\t) 等),一般用于实现对齐时基于可见文本对齐等。
示例:
let line = CTLineCreateWithAttributedString(
NSAttributedString(string: "Hello ", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)
let totalWidth = CTLineGetTypographicBounds(line, nil, nil, nil)
let trailingWidth = CTLineGetTrailingWhitespaceWidth(line)
print("总宽度: \(totalWidth)")
print("尾部空白宽度: \(trailingWidth)")
print("可见文字宽度: \(totalWidth - trailingWidth)")
12)CTLineGetStringIndexForPosition
func CTLineGetStringIndexForPosition(
_ line: CTLine,
_ position: CGPoint
) -> CFIndex
获取给定位置处的字符串索引。
注意:虽然官方文档说这个API一般用于点击检测,但实际测试下来这个API返回的点击索引不准确,比如虽然点击的是当前字符,但实际返回的索引是后一个字符的,如下:
查了下,发现这个API一般是用于计算光标位置的,比如点击「行」的左半部分,希望光标出现在「行」左侧,如果点击「行」的右半部分,希望光标出现在「行」的右侧。
如果我们想精确做字符的点击检测,推荐使用字符/行的bounds来计算,参考「CTFrame使用示例-3」例子。
13)CTLineGetOffsetForStringIndex
func CTLineGetOffsetForStringIndex(
_ line: CTLine,
_ charIndex: CFIndex,
_ secondaryOffset: UnsafeMutablePointer<CGFloat>?
) -> CGFloat
获取指定字符索引相对于行的 x 轴偏移量。
line
:待查询的行charIndex
:要查询的字符在原始属性字符串中的索引secondaryOffset
:次要偏移值,在简单的LTR文本中,可以忽略(传nil即可),但在复杂的双向文本(BiDi)中会用到使用场景:
14)CTLineEnumerateCaretOffsets
func CTLineEnumerateCaretOffsets(
_ line: CTLine,
_ block: @escaping (Double, CFIndex, Bool, UnsafeMutablePointer<Bool>) -> Void
)
遍历一行中光标所有的有效位置。
line
block
使用场景:
除了上面例子,再举一个:
1)高亮特定字符
CTRun相关API比较基础,这里主要介绍常用的。
1)CTLineGetGlyphRuns
func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray
获取CTRun的唯一方式。
2)CTRunGetAttributes
func CTRunGetAttributes(_ run: CTRun) -> CFDictionary
获取CTRun的属性;比如想知道这个CTRun是不是粗体,是不是链接,是不是目标Run等,都可以通过这个API。
示例:
guard let attributes = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any] else { continue }
// 现在你可以检查属性
if let color = attributes[.foregroundColor] as? UIColor {
// ...
}
if let font = attributes[.font] as? UIFont {
// ...
}
if let link = attributes[NSAttributedString.Key("my_custom_link_key")] {
// 这就是那个可点击的 run!
}
3)CTRunGetStringRange
func CTRunGetStringRange(_ run: CTRun) -> CFRange
获取CTRun对应于原始属性字符串的哪个范围。
4)CTRunGetTypographicBounds
func CTRunGetTypographicBounds(
_ run: CTRun,
_ range: CFRange,
_ ascent: UnsafeMutablePointer<CGFloat>?,
_ descent: UnsafeMutablePointer<CGFloat>?,
_ leading: UnsafeMutablePointer<CGFloat>?
) -> Double
获取CTRun的度量信息,同上面许多API一样,当 range.length 为0时表示直到CTRun文本末尾。
5)CTRunGetPositions
func CTRunGetPositions(
_ run: CTRun,
_ range: CFRange,
_ buffer: UnsafeMutablePointer<CGPoint>
)
获取CTRun中每一个字形的位置,注意这里的位置是相对于CTLine原点的。
6)CTRunDelegate
CTRunDelegate允许为属性字符串中的一段文本提供自定义布局测量信息,一般用于在文本中插入图片、自定义View等非文本元素。
比如在文本中间插入图片:
1)基础绘制
2)链接点击识别
CTFramesetter会自动处理换行,当我们想手动控制换行时,可以用CTTypesetter。
1)CTTypesetterSuggestLineBreak
func CTTypesetterSuggestLineBreak(
_ typesetter: CTTypesetter,
_ startIndex: CFIndex,
_ width: Double
) -> CFIndex
按单词(word)换行。
如下示例,输出:Try word
和wrapping
let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let totalLength = attributedString.length // UTF-16 长度
var startIndex = 0
var lineCount = 1
while startIndex < totalLength {
let charCount = CTTypesetterSuggestLineBreak(typesetter, startIndex, 100)
// 如果返回 0,意味着一个字符都放不下(或已结束)
if charCount == 0 {
if startIndex < totalLength {
print("Line \(lineCount): (Error) 无法放下剩余字符。")
}
break
}
// 获取这一行的子字符串
let range = NSRange(location: startIndex, length: charCount)
let lineString = (attributedString.string as NSString).substring(with: range)
print("Line \(lineCount): '\(lineString)' (UTF-16 字符数: \(charCount))")
// 更新下一次循环的起始索引
startIndex += charCount
lineCount += 1
}
2)CTTypesetterSuggestClusterBreak
func CTTypesetterSuggestClusterBreak(
_ typesetter: CTTypesetter,
_ startIndex: CFIndex,
_ width: Double
) -> CFIndex
按字符(char)换行。
如下示例,输出:Try word wr
和apping
let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let totalLength = attributedString.length // UTF-16 长度
var startIndex = 0
var lineCount = 1
while startIndex < totalLength {
let charCount = CTTypesetterSuggestClusterBreak(typesetter, startIndex, 100)
// 如果返回 0,意味着一个字符都放不下(或已结束)
if charCount == 0 {
if startIndex < totalLength {
print("Line \(lineCount): (Error) 无法放下剩余字符。")
}
break
}
// 获取这一行的子字符串
let range = NSRange(location: startIndex, length: charCount)
let lineString = (attributedString.string as NSString).substring(with: range)
print("Line \(lineCount): '\(lineString)' (UTF-16 字符数: \(charCount))")
// 更新下一次循环的起始索引
startIndex += charCount
lineCount += 1
}
以上是CoreText中常用的API及其场景代码举例,完整示例代码可在公众号「非专业程序员Ping」回复 “CoreText” 获取。
你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。
@阿权:私有成员一直是单元测试的难题,以往的做法要么公开其成员,要么封装供测试的接口,本文提出可以用 @_private(sourceFile:)
测试私有成员。该标识是非公开 API,是编译器专用的接口,可绕过访问控制。不过前提需要为目标模块添加编译标志 -enable-private-imports
,允许其私有成员被外部测试访问,并需用条件编译宏包裹,以防后续编译器更新导致 API 不可用。
通过这种方式,可以在完全不修改原有接口的前提下自由地编写单元测试代码。
@EyreFree:本文聚焦大规模 AI 系统构建,提出 17 种智能体架构及应用场景,含多智能体系统、集成决策、思维树(ToT)等。以 LangChain、LangGraph、LangSmith 为工具栈,详细解析各架构实现流程,如反思架构通过生成 - 批判 - 优化提升输出质量,ReAct 架构借 “思考 - 行动 - 观察” 循环解决多步骤问题,PEV 架构增验证环节应对工具失效。还介绍组合架构思路,强调不同架构协同可实现复杂任务,且附 GitHub 代码库供实践,为大规模 AI 系统开发提供全面参考,在做 AI 相关开发的同学可以看看。
@Kyle-Ye: iOS 26 Runtime 在 objc_storeStrong
实现中引入了哨兵值机制(0x400000000000bad0),主动暴露 nonatomic 属性的并发访问问题。新实现在写入新值之前,会先向属性地址写入哨兵值,创建一个"危险窗口"。任何在此窗口期间的并发读写操作都会必然触发 EXC_BAD_ACCESS 崩溃,而不再依赖于对象引用计数等不确定因素。文章详细剖析了写写并发(objc_release 崩溃)和读写并发(objc_retain 崩溃)两种典型场景,并介绍了快手自研的 Ekko 安全气垫框架如何通过兜底 Mach 异常来进行崩溃止损。对于维护线上 App 稳定性的开发者来说,这个系统级变更影响全量版本,建议重点关注 nonatomic 属性的线程安全问题,必要时改用 atomic 或添加额外的安全气垫机制进行兜底。
@Cooper Chen:如果你想了解“ AI Agent 是否能真正实现持续自主执行任务”,WeZZard 的文章与配套项目是极具价值的入门参考。
该文章提出了一个极简且可工程化复用的方法论:
Contract(合同)+ Loop(循环)+ Runtime(运行时)。借助高阶模型(如 Claude 4 或未来的 GPT-5),通过结构化 JSON 协议与工具调用,系统即可在评估者(Evaluator)与执行者(Executor)之间持续推进任务,实现 24/7 agentic 工作流。
配套开源仓库 《agentic-loop-playground》实现了一个完整示例:
TODO/FIXME
这是一种无需复杂框架即可落地的智能代理构建方式,非常适合开发者快速启动个人或团队级 Agent 流程。
@AidenRao:这篇文章是关于 iOS 16 引入的 Live Activities(实时活动)功能的设计指南。
Live Activities 的核心是帮助用户快速获取关键动态信息,减少频繁打开应用的需要。设计时应以用户需求为中心,避免滥用。
@DylanYang:iOS 17 引入的 AutoFillUI 框架使得在使用 UITextField 时会发生不预期的内存泄漏,当 UITextField 成为第一响应者且用户离开视图时就会发生。由于在 SwfitUI 中 TextField 可以通过 .environmentObject() 保持对大型对象图的引用,因此在 SwiftUI 中内存泄漏的问题会更严重一点。对问题起因和绕过方式感兴趣的同学可以阅读本文作进一步了解。
重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考
具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)
我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。
关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参
同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom 。
🚧 表示需某工具,🌟 表示编辑推荐
预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)
基于Vision.framework从图片中提取文字
苹果在iOS 11中引入的Vision框架为OCR提供了基础能力,其核心组件VNRecognizeTextRequest
可实现高效文字检测与识别。结合VisionKit中的DocumentCameraViewController
,可快速构建扫描界面,支持自动裁剪、透视校正等预处理功能。
技术优势:
#import <Foundation/Foundation.h>
#import <Vision/Vision.h>
NS_ASSUME_NONNULL_BEGIN
API_AVAILABLE(ios(11.0))
typedef void(^SBVisionTextCallBack)(NSError *error, NSArray<__kindof VNObservation*>* results);
API_AVAILABLE(ios(11.0))
@interface SBVisionText : NSObject
@property (nonatomic,copy)SBVisionTextCallBack resultBlock;
+ (void)sb_vision_text_image:(UIImage *)img result:(SBVisionTextCallBack) resultBlock;
@end
#import "SBVisionText.h"
@implementation SBVisionText
+ (void)sb_vision_text_image:(UIImage *)img result:(SBVisionTextCallBack) resultBlock{
if (@available(iOS 13.0, *)) {
VNRecognizeTextRequest *textRequest = [[VNRecognizeTextRequest alloc] initWithCompletionHandler:^(VNRequest * _Nonnull request, NSError * _Nullable error){
NSArray *observations = request.results;
// [self textRectangles:observations image:image complete:complete];
NSLog(@"sb_vision_text_image:%@",observations);
if (resultBlock) {
resultBlock(error,request.results);
}
}];
textRequest.recognitionLevel = VNRequestTextRecognitionLevelAccurate;
textRequest.usesLanguageCorrection = NO;
textRequest.recognitionLanguages = @[@"zh-Hans", @"en-US"];
// 转换CIImage
CIImage *convertImage = [[CIImage alloc]initWithImage:img];
// 创建处理requestHandler
VNImageRequestHandler *detectRequestHandler = [[VNImageRequestHandler alloc]initWithCIImage:convertImage options:@{}];
// 发送识别请求
[detectRequestHandler performRequests:@[textRequest] error:nil];
} else {
// Fallback on earlier versions
NSLog(@"Fallback on earlier versions");
}
}
@end
方法调用
#import "SBVisionTextViewController.h"
#import "SBVisionText.h"
@implementation SBVisionTextViewController
- (void)viewDidLoad {
[super viewDidLoad];
}
- (IBAction)getText:(UIButton *)sender {
[self getTextFormImage:[UIImage imageNamed:@"1681888102373.jpg"]];
}
-(void)getTextFormImage:(UIImage *)img{
if (@available(iOS 11.0, *)) {
[SBVisionText sb_vision_text_image:img result:^(NSError * _Nonnull error, NSArray<__kindof VNObservation *> * _Nonnull results) {
if (@available(iOS 13.0, *)) {
for (VNRecognizedTextObservation *observation in results) {
NSLog(@"%@", [observation topCandidates:1].firstObject.string);
}
} else {
NSLog(@"Fallback on earlier versions");
}
}];
} else {
NSLog(@"Fallback on earlier versions");
}
return;
}
@end
SwiftUI 的 keyboardShortcut
修饰符让为应用添加快捷键变得简单直观。然而,这些快捷键的作用域(Scope) 和生命周期可能会带来一些意想不到的行为,例如即使关联的视图不在屏幕可见区域,其快捷键仍可能被激活。本文将深入探讨 SwiftUI 键盘快捷键的作用域机制,并提供一系列解决方案和最佳实践。
在 SwiftUI 中,你可以使用 .keyboardShortcut
修饰符为任何可交互的视图(如 Button
)附加键盘快捷键。
以下代码为一个按钮添加了快捷键 Command + Shift + P
:
import SwiftUI
struct ContentView: View {
var body: some View {
Button("打印信息") {
print("Hello World!")
}
.keyboardShortcut("p", modifiers: [.command, .shift]) //
}
}
KeyEquivalent
:表示快捷键的主键,可以是单个字符(如 "p")或特殊键(如 .return
, .escape
, .downArrow
)。它遵循 ExpressibleByExtendedGraphemeClusterLiteral
协议,允许我们用字符串字面量创建实例。EventModifiers
:表示修饰键(如 .command
, .shift
, .control
, .option
),它是一个遵循 OptionSet
协议的结构体,允许组合多个修饰键。modifiers
参数,SwiftUI 默认使用 .command
修饰符。keyboardShortcut
修饰符可以应用于任何视图,不仅是 Button
。例如,可以将其应用于 Toggle
:
struct ContentView: View {
@State private var isEnabled = false
var body: some View {
Toggle(isOn: $isEnabled) {
Text(String(isEnabled))
}
.keyboardShortcut("t") // 按下快捷键将切换 Toggle 的状态
}
}
它也可以应用于容器视图(如 VStack
, HStack
)。在这种情况下,快捷键会作用于该容器层次结构中第一个可交互的子视图。
struct ContentView: View {
var body: some View {
VStack {
Button("打印信息") {
print("Hello World!")
}
Button("删除信息") {
print("信息已删除。")
}
}
.keyboardShortcut("p") // 此快捷键将激活第一个按钮(打印信息)
}
}
理解快捷键的作用域(Scope) 和生命周期(Lifetime) 是有效管理它们的关键。
SwiftUI 的键盘快捷键在视图层次结构中进行管理。其解析过程遵循深度优先、从前向后的遍历规则。当多个控件关联到同一快捷键时,系统会使用最先找到的那个。
一个非常重要的特性是:只要附加了快捷键的视图仍然存在于视图层次结构中(即使该视图当前不在屏幕可见范围内,例如在 TabView
的非活动标签页、NavigationStack
的深层页面,或者简单的 if
条件渲染但视图未销毁),其快捷键就保持有效并可激活。
这种行为可能导致非预期的操作:
struct ContentView: View {
@State private var selection = 1
var body: some View {
TabView(selection: $selection) {
Tab("标签 1", systemImage: "1.circle") {
Button("标签1的按钮") {
print("标签1动作")
}
.keyboardShortcut("a") // ⌘A 在标签1
}
.tag(1)
Tab("标签 2", systemImage: "2.circle") {
Button("标签2的按钮") {
print("标签2动作")
}
.keyboardShortcut("b") // ⌘B 在标签2
}
.tag(2)
}
}
}
在此例中,即使你在标签页 2(⌘B 活跃),按下 ⌘A 仍然会触发标签页 1 中的按钮动作,因为标签页 1 的视图仍然在视图层次结构中(只是未被显示)。
为了解决快捷键意外激活的问题,我们需要有意识地控制其作用域。以下是几种有效的方法。
最直接的方法是通过条件语句(如 if
、.disabled
)控制视图的存在与否或可交互性,从而间接控制快捷键。
if
条件语句通过 @State
驱动视图的条件渲染,当视图被移除时,其快捷键自然失效。
struct ContentView: View {
@State private var isFeatureEnabled = false
var body: some View {
VStack {
Toggle("启用功能", isOn: $isFeatureEnabled)
if isFeatureEnabled {
Button("执行功能") {
// 执行操作
}
.keyboardShortcut("e") // 仅在 isFeatureEnabled 为 true 时存在且有效
}
}
}
}
.disabled
修饰符.disabled
修饰符会禁用视图的交互能力,同时也会使其关联的快捷键失效。
struct ContentView: View {
@State private var isButtonDisabled = true
var body: some View {
Button("点击我") {
// 执行操作
}
.keyboardShortcut("k")
.disabled(isButtonDisabled) // 为 true 时,按钮无法点击且快捷键无效
}
}
isPresented
的状态控制对于通过 sheet
、alert
、popover
等呈现的视图,其快捷键的生命周期通常与模态视图的呈现状态绑定。
struct ContentView: View {
@State private var isSheetPresented = false
var body: some View {
Button("显示表单") {
isSheetPresented.toggle()
}
.sheet(isPresented: $isSheetPresented) {
SheetView()
}
}
}
struct SheetView: View {
var body: some View {
Button("提交表单") {
// 提交操作
}
.keyboardShortcut(.return, modifiers: [.command]) // ⌘Return
// 此快捷键仅在 Sheet 呈现时有效
}
}
在这个例子中,⌘Return 快捷键只在 SheetView
显示时有效。当 sheet 被关闭后,该快捷键也随之失效,完美避免了与主界面快捷键的冲突。
AppDelegate
和 UIKeyCommand
进行全局管理对于更复杂的应用,尤其是在 macOS 或需要非常精确控制快捷键的 iPad 应用上,你可以选择绕过 SwiftUI 的修饰符,直接在 AppDelegate
中使用 UIKit 的 UIKeyCommand
。
这种方法让你可以完全自主地决定在不同场景下哪些快捷键应该被激活。
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
// 跟踪当前视图状态
var currentView: CurrentViewType = .main
override var keyCommands: [UIKeyCommand]? {
switch currentView {
case .main:
return [
UIKeyCommand(title: "搜索", action: #selector(handleKeyCommand(_:)), input: "f", modifierFlags: .command, propertyList: "search"),
UIKeyCommand(title: "新建", action: #selector(handleKeyCommand(_:)), input: "n", modifierFlags: .command, propertyList: "new")
]
case .sheet:
return [
UIKeyCommand(title: "保存", action: #selector(handleKeyCommand(_:)), input: "s", modifierFlags: .command, propertyList: "saveSheet")
]
case .settings:
return [] // 在设置页面禁用所有自定义快捷键
}
}
@objc func handleKeyCommand(_ sender: UIKeyCommand) {
guard let action = sender.propertyList as? String else { return }
switch action {
case "search": // 处理搜索逻辑
case "new": // 处理新建逻辑
case "saveSheet": // 处理Sheet保存逻辑
default: break
}
}
// ... 其他 AppDelegate 方法
}
enum CurrentViewType {
case main, sheet, settings
}
通过在 AppDelegate
中维护一个状态机(如 currentView
),你可以根据应用当前所处的不同界面或模式,动态返回不同的快捷键数组,实现精准的全局快捷键管理。
如前所述,SwiftUI 会选择在深度优先遍历中最先找到的快捷键。 因此,在设计快捷键时,需要注意其唯一性,避免无意中的覆盖。如果确实需要覆盖,可以利用视图的层次结构,将高优先级的快捷键定义放在更靠近视图树根部的位置或确保其被先定义。
你可以创建“隐藏”的快捷键(不显示在菜单中),用于一些通用操作,如关闭模态框。
// 在 AppDelegate 的 keyCommands 中
UIKeyCommand(title: "", action: #selector(handleKeyCommand(_:)), input: UIKeyCommand.inputEscape, propertyList: "closeModal"),
UIKeyCommand(title: "", action: #selector(handleKeyCommand(_:)), input: "w", modifierFlags: .command, propertyList: "closeModal")
这些没有标题的 UIKeyCommand
不会出现在菜单中,但用户按下 Esc
或 ⌘W
时仍然会触发关闭操作,这符合许多桌面应用的用户习惯。
在模拟器中测试快捷键时,记得点击模拟器窗口底部的 “Capture Keyboard” 按钮(看起来像一个小键盘图标),以确保模拟器捕获你的键盘输入。
FocusState
结合管理文本输入焦点在处理文本输入时,快捷键常与焦点管理配合使用。SwiftUI 的 @FocusState
可以用来程序控制第一个响应者(焦点)。
struct ContentView: View {
@State private var username = ""
@State private var password = ""
@FocusState private var focusedField: Field? // 焦点状态
enum Field: Hashable {
case username, password
}
var body: some View {
Form {
TextField("用户名", text: $username)
.focused($focusedField, equals: .username)
.keyboardShortcut("1", modifiers: [.control, .command]) // 切换焦点快捷键
SecureField("密码", text: $password)
.focused($focusedField, equals: .password)
.keyboardShortcut("2", modifiers: [.control, .command]) // 切换焦点快捷键
}
.onSubmit { // 处理回车键提交
if focusedField == .username {
focusedField = .password
} else {
login()
}
}
}
private func login() { ... }
}
在 macOS 应用中,SwiftUI 的 .commands
修饰符允许你向菜单栏添加项目,并为其指定快捷键。这些快捷键通常具有全局性,但系统会自动处理其与当前焦点视图的优先级关系。
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandMenu("编辑") {
Button("复制") {
// 执行复制操作
}
.keyboardShortcut("c") // 定义在菜单栏中
Button("粘贴") {
// 执行粘贴操作
}
.keyboardShortcut("v")
}
}
}
}
假设我们有一个文档编辑器,它包含:
MainEditorView
)。SettingsView
),通过导航链接推送。ExportView
),通过 sheet 呈现。struct MainEditorView: View {
@State private var documentText: String = ""
@State private var showSettings = false
@State private var showExportSheet = false
@State private var isExportDisabled = true
var body: some View {
NavigationStack {
TextEditor(text: $documentText)
.toolbar {
ToolbarItemGroup {
Button("设置") { showSettings.toggle() }
Button("导出") { showExportSheet.toggle() }
.disabled(isExportDisabled) // 初始状态下导出禁用
}
}
.navigationDestination(isPresented: $showSettings) {
SettingsView()
}
.sheet(isPresented: $showExportSheet) {
ExportView()
}
// 主编辑器的快捷键
.keyboardShortcut("s", modifiers: [.command]) // 保存,始终有效
}
.onChange(of: documentText) {
isExportDisabled = documentText.isEmpty // 有内容时才允许导出
}
}
}
struct SettingsView: View {
var body: some View {
Form {
// 各种设置选项...
}
// 设置页面可能有自己的快捷键,但只在当前视图活跃
.keyboardShortcut("r", modifiers: [.command]) // 重置设置
}
}
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
// 导出选项...
Button("确认导出") {
// 导出逻辑
dismiss()
}
.keyboardShortcut(.return, modifiers: [.command]) // ⌘Return 在Sheet中有效
}
.frame(minWidth: 300, minHeight: 200)
.padding()
}
}
在这个案例中:
MainEditorView
上,只要该视图在层次结构中就有效(即使在设置页面或导出Sheet背后)。SettingsView
上,仅在设置页面可见时有效。ExportView
上,仅在导出 Sheet 显示时有效。isExportDisabled
状态控制,同时也禁用了其快捷键,避免了无效操作。SwiftUI 的键盘快捷键功能强大且易于使用,但其“离屏”激活的特性要求开发者仔细考虑其作用域管理。
if
和 .disabled(_:)
动态控制视图及其快捷键的可用性。isPresented
等状态,将模态视图的快捷键生命周期限制在模态显示期间。AppDelegate
中使用 UIKeyCommand
实现精细的、基于状态的全局快捷键控制。.commands
为 macOS 应用添加快捷键,并结合 @FocusState
管理文本输入焦点。Xcode 26,这次更新不仅仅是版本号的迭代,而是对iOS开发生态的一次彻底重构。从设备端AI模型集成到革命性的界面设计语言,从编码智能辅助到跨平台开发优化,Xcode 26为开发者提供了前所未有的工具集,标志着Apple开发工具进入智能化、一体化的新纪元。本文将深入解析十大核心特性,揭示它们如何重塑开发工作流。
Xcode 26最引人注目的特性是深度集成了大语言模型(LLM),使开发者能够使用自然语言与代码交互。这一功能不仅支持云端模型如ChatGPT,还允许在配备Apple芯片的Mac上运行本地模型,为代码编写、测试生成、文档创作和错误修复提供智能辅助。
传统的代码自动完成基于静态语法分析,而Xcode 26的AI助手采用动态上下文收集技术。系统会自动分析整个项目结构、代码风格和开发者习惯,使模型生成的代码不仅语法正确,更符合项目特定需求。例如,当开发者输入“为地标集合添加评分功能”时,模型会参考项目中现有的数据模型和UI模式,生成类型安全且风格一致的Swift代码。
考虑一个常见场景:开发者在ForEach
视图中遇到类型不符合Identifiable
协议的错误。在Xcode 26中,只需选择错误代码并调用Coding Tools,AI助手会分析相关类型声明和错误位置,自动建议添加协议一致性代码。更令人印象深刻的是,系统能理解代码语义——如果修复涉及多个文件,它会跨文件协调修改,保持代码库的一致性。
// 修复前:Landmark结构体缺少Identifiable一致性
struct Landmark {
var name: String
var coordinate: Coordinate
}
// 使用AI助手后,自动添加Identifiable一致性
extension Landmark: Identifiable {
var id: String { name }
}
代码注释:以上代码演示了AI助手如何自动添加协议一致性。id
属性使用name
作为标识符,这是基于上下文分析得出的合理实现。
开发者可以灵活选择AI模型提供商:默认集成ChatGPT(无需账户即可有限使用),也支持通过API密钥连接Claude等第三方模型。对于注重隐私的团队,可以在本地部署模型,确保代码完全不离开开发环境。这种灵活性使Xcode 26能适应不同组织的安全和合规需求。
Apple在Xcode 26中引入了全新的软件设计语言Liquid Glass,这是一种基于软件的材料设计系统,将玻璃的光学特性与流体力学的动态感相结合,为应用程序带来前所未有的视觉深度和触觉响应。
Liquid Glass的核心设计原则是“内容优先”。通过智能调节UI元素的光学属性(如透明度、折射率和表面反射),系统确保用户注意力始终集中在核心内容上,而非界面控件本身。这种设计在iOS 26、iPadOS 26、macOS Tahoe 26等平台上保持一致,使开发者能够轻松创建跨设备的统一体验。
Liquid Glass不是简单的视觉样式,而是一套完整的材质系统。在代码层面,它通过SwiftUI的修饰符系统暴露给开发者:
// 应用Liquid Glass效果到按钮
Button("探索") {
// 操作逻辑
}
.buttonStyle(.liquidGlass) // 应用Liquid Glass样式
.material(.adaptive) // 自适应材质
.depthEffect(.medium) // 深度效果调节
代码注释:上述代码展示了如何应用Liquid Glass样式。.adaptive
材质会根据环境光自动调整外观,而.depthEffect
控制视觉深度级别。
Icon Composer应用充分利用Liquid Glass特性,允许开发者创建具有多层动态效果的图标。图标现在可以根据渲染模式(默认、深色、单色)自动调整外观,并支持模糊、半透明调整、镜面高光等高级效果。例如,天气应用图标可以在雨天显示湿润的表面反射,在晴天呈现清晰的透光效果。
Foundation Models框架是Xcode 26中最重要的架构创新,它使开发者能够直接利用设备上的Apple Intelligence模型,实现智能功能同时确保用户数据永不离开设备。
与依赖云端的AI服务不同,Foundation Models框架专为设备端推理优化。模型直接在iPhone、iPad或Mac上运行,带来三个关键优势:离线可用性、零成本推理和强隐私保护。开发者只需几行Swift代码即可集成强大AI能力:
import FoundationModels
// 初始化Apple Intelligence模型
let model = try AppleIntelligenceModel()
// 使用引导式生成创建内容
let response = try await model.generate(
prompt: "总结今天的主要活动",
guidance: .summarization // 引导生成类型
)
代码注释:此代码演示了如何初始化Apple Intelligence模型并进行引导式生成。guidance
参数控制生成内容的方向,如摘要、创作或翻译。
Automattic在Day One日记应用中集成Foundation Models框架,实现了智能日记分析功能。应用可以自动识别日记中的情绪模式、重要事件和建议提醒,所有处理均在设备上完成。用户获得个性化AI体验的同时,确保敏感日记内容不会上传到云端。
框架内置工具调用能力,使AI模型能够与设备功能交互。例如,模型可以调用日历API检查时间冲突,或访问健康数据提供个性化建议。未来更新将支持多模态输入,结合文本、图像和传感器数据实现更丰富的交互。
Xcode 26的Coding Tools不是简单的代码补全工具,而是深度集成到开发环境中的智能辅助系统,能够在任何代码位置提供上下文相关的操作建议。
Coding Tools的核心优势在于其无缝的工作流集成。当开发者在编写代码时,工具会自动分析当前上下文(如光标位置、选中代码、错误信息)并提供最相关的操作。这些操作包括生成预览、创建Playground、修复问题或进行内联更改,所有操作都无需离开编码环境。
传统的代码搜索基于精确匹配,而Xcode 26引入了“多词搜索”技术,采用搜索引擎算法在项目中查找词语组合。例如,搜索“clipped resizable image”会找到这些词在相近位置出现的所有文档,即使它们跨越多行或以不同顺序出现。这种搜索方式特别适合探索不熟悉的代码库。
// 多词搜索示例:查找创建可调整大小图像的位置
// 搜索"clipped resizable image"可能匹配以下代码
Image("landscape")
.resizable()
.clipShape(RoundedRectangle(cornerRadius: 10))
代码注释:多词搜索能够识别代码语义关联,即使关键词分散在不同行也能准确匹配。
Xcode 26为Voice Control添加了Swift模式支持,开发者可以通过语音听写Swift代码。系统不仅识别单词,更理解Swift语法规则——自动处理空格位置、运算符对应和驼峰命名法。这一功能为行动不便的开发者打开了编程的大门,也提供了全新的交互方式。
图标是应用品牌识别的核心,Xcode 26中的Icon Composer应用彻底重构了图标创建工作流,支持创建基于Liquid Glass的多层动态图标。
Icon Composer引入全新的多层图标格式,每个图层可以独立应用动态效果。开发者可以调整深度属性、动态光照效果,并在默认、深色和单色渲染模式间自定义外观。例如,导航应用图标可以包含道路层、交通层和背景层,各层以不同速度响应设备运动,创造伪3D效果。
工具与Xcode无缝集成,支持从单一设计创建所有所需尺寸和风格的图标。高级功能包括为不同渲染模式注释图层、模糊处理、调整半透明度和测试镜面高光。完成后,可以导出扁平化版本用于营销材料,确保品牌一致性。
考虑天气应用图标设计:晴天版本显示明亮的光照效果和清晰的阴影;雨天版本则应用湿润表面效果和柔和的光线散射。通过Icon Composer,可以定义条件规则,使图标根据实时天气数据自动调整外观:
// 图标条件规则示例
IconCondition.when(.weatherIsSunny) {
$0.applyEffect(.brightSunlight)
$0.adjustLayerOpacity(1.0)
}
IconCondition.when(.weatherIsRainy) {
$0.applyEffect(.wetSurface)
$0.adjustLayerOpacity(0.8)
}
代码注释:此代码演示了如何为不同天气条件定义图标外观规则。效果参数控制视觉表现,如阳光亮度或表面湿润感。
Swift 6.2作为Xcode 26的核心组成部分,引入了严格的并发检查、简化的主线程编程和对WebAssembly的支持,显著提升了语言的安全性、性能和跨平台能力。
Swift 6.2建立在Swift 6的并发模型之上,通过编译时检查防止数据竞争。新编译器能够识别潜在的数据竞争条件,并强制开发者明确标记跨线程共享的数据。这一特性在大型项目中尤为重要,能够避免难以调试的并发错误。
// Swift 6.2中的安全并发实践
@MainActor // 默认在主Actor运行
class DataModel: ObservableObject {
@Published var items: [String] = []
func updateItems() async {
// 异步操作,但更新UI时自动调度到主线程
let newItems = await fetchItems()
items = newItems // 编译器确保线程安全
}
}
代码注释:@MainActor
注解确保所有方法默认在主线程执行,避免UI更新时的线程问题。编译器会验证所有可能的并发访问路径。
通过与开源社区合作,Swift 6.2新增对WebAssembly的支持,使Swift代码能够编译为WebAssembly模块在浏览器中运行。这一特性为Swift开发者打开了Web开发的大门,允许共享业务逻辑 between iOS应用和Web应用。
Swift 6.2显著改善了与其他编程语言的互操作性。新的API使Swift能够更自然地调用C++代码,与Java和JavaScript的数据交换也更加高效。这对于集成现有库和跨平台开发尤其有价值。
Xcode 26的容器化框架(Containerisation Framework)允许开发者在Mac上直接创建、下载和运行Linux容器镜像,为服务器端Swift开发和跨平台测试提供统一环境。
框架基于开源技术构建,并针对Apple芯片进行了深度优化。它利用macOS的沙箱机制提供安全的容器隔离,同时通过虚拟化技术实现x86容器的无缝运行。这意味着开发者可以在Apple芯片Mac上运行传统的x86 Linux环境,无需复杂的配置。
容器化框架的核心价值在于确保开发环境与生产环境的一致性。开发者可以定义包含所有依赖的Dockerfile,在本地构建和测试后,直接部署到服务器。这种方法消除了“在我机器上能运行”的经典问题。
# 使用Swift 6.2的Linux容器示例
FROM swift:6.2
WORKDIR /app
COPY Package.swift .
COPY Sources ./Sources
RUN swift build -c release
CMD ["./.build/release/MyServerApp"]
代码注释:此Dockerfile演示了如何为Swift服务器应用创建容器镜像。Xcode 26支持直接在IDE中构建和运行此类容器。
考虑一个需要与多个微服务交互的iOS应用。使用容器化框架,开发者可以在本地启动完整的微服务环境,每个服务运行在独立容器中。这使前端开发能够在不依赖后端团队的情况下进行完整测试,显著加速开发周期。
针对游戏开发者,Xcode 26提供了全面的工具更新,包括Metal 4图形框架、Game Porting Toolkit 3和专门的Apple Games应用,为Apple平台带来主机级游戏体验。
Metal 4是首个专门为Apple芯片设计的图形框架,支持高级图形和机器学习技术。新特性包括在着色器中直接运行推理网络计算光照、材质和几何体,实现电影级视觉效果。
MetalFX框架包含两个关键技术:帧插值(Frame Interpolation)和降噪(Denoising)。帧插值为每两个输入帧生成中间帧,实现更高更稳定的帧率;降噪则使实时光线追踪和路径追踪在高级游戏中成为可能。
// Metal 4着色器中的光线追踪示例
kernel void rayTracingKernel(uint2 tid [[thread_position_in_grid]]) {
// 初始化光线
Ray ray = generateCameraRay(tid);
// 执行光线追踪
HitResult hit = traceRay(ray);
// 使用AI降噪
if (hit.isValid) {
float3 color = denoise(hit.color, hit.albedo, hit.normal);
writeToFramebuffer(tid, color);
}
}
代码注释:此Metal着色器代码演示了光线追踪与AI降噪的结合。denoise
函数使用设备端AI模型减少光线追踪噪声。
Game Porting Toolkit 3提供更新工具用于评估和优化游戏性能。开发者可以自定义Metal Performance HUD,获取屏幕上的性能洞察和图形代码优化指导。新增的Processor Trace工具捕获每个函数调用,帮助识别最细微的性能瓶颈。
Xcode 26强化了辅助功能工具集,使开发者能够更轻松地创建适合所有用户的应用,包括新的Declared Age Range API、增强的Voice Control和Sensitive Content Analysis框架。
新API允许开发者根据用户年龄范围提供适龄内容,而无需收集具体出生日期。家长可以选择允许孩子分享年龄范围而不暴露敏感信息,平衡个性化体验与隐私保护。
Sensitive Content Analysis框架帮助应用检测和处理可能不适当的内容,特别是保护未成年用户。框架在设备上运行,确保分析过程不泄露用户数据。
如前所述,Voice Control现在支持Swift代码听写,这不仅帮助行动不便的开发者,也为编码教育开辟了新途径。学生可以通过语音命令学习编程概念,而不必先掌握键盘输入。
Xcode 26改进了应用分发和管理的多个环节,包括App Store Connect API增强、TestFlight集成和本地化流程优化。
App Store产品页面现在显示可访问性营养标签,帮助用户在下载前了解应用支持的辅助功能,如VoiceOver、Voice Control、大文本支持等。这鼓励开发者优先考虑可访问性,也为用户提供了更好的选择依据。
String Catalogs在Xcode 26中获得重大增强,现在为本地化字符串提供类型安全的Swift符号,支持直接字符串访问和自动完成。AI生成的上下文注释帮助翻译人员理解字符串使用场景,提高翻译质量。
// String Catalogs中的类型安全访问
let greeting = String(localized: "WelcomeMessage",
defaultValue: "Welcome, %@!",
comment: "主屏幕欢迎消息")
let formattedGreeting = String(format: greeting, userName)
代码注释:此代码演示了如何安全地访问本地化字符串。defaultValue
提供回退值,comment
帮助翻译人员理解上下文。
开发者现在可以使用App Store Connect API创建webhooks获取实时更新,自动化应用管理流程。API支持Apple-Hosted Background Assets和Game Center配置,使大规模应用分发更加高效。
Xcode 26不仅仅是一个开发工具更新,而是Apple为应对现代应用开发挑战提供的全面解决方案。从AI辅助编程到隐私保护框架,从革命性设计语言到跨平台开发支持,这十大特性共同构成了一个强大而协调的生态系统。
开发范式转变:Xcode 26标志着从手动编码向AI协作开发的转变,智能工具处理重复任务,让开发者专注于创造性工作。
设计一致性突破:Liquid Glass设计语言和Icon Composer确保了Apple生态系统内的视觉一致性,同时为品牌表达留下了充足空间。
隐私与性能平衡:Foundation Models框架证明设备端AI能够提供强大功能而不牺牲隐私,为行业树立了新标准。
跨平台开发成熟:Swift 6.2的WebAssembly支持和容器化框架使Swift成为真正的全栈语言,统一了移动、桌面和Web开发。
正如Apple全球开发者关系副总裁Susan Prescott所言:“开发者塑造了用户在Apple平台上的体验,Xcode 26赋予他们构建更丰富、更直观应用的能力。”随着开发者社区开始探索这些新工具,我们可以期待iOS生态系统将迎来新一轮创新浪潮。
Flutter是Google推出的一款跨平台移动应用开发框架,它允许开发者使用一套代码库同时构建iOS和Android应用。本教程将详细介绍如何从零开始搭建Flutter开发环境,让你快速上手Flutter开发。
首先,我们需要从Flutter官网下载最新版本的Flutter SDK。
Windows系统:
C:\src\flutter
Path
,点击"编辑"C:\src\flutter\bin
macOS/Linux系统:
# 解压到目标目录
cd ~/development
unzip ~/Downloads/flutter_macos_3.19.0-stable.zip
# 配置环境变量
echo 'export PATH="$PATH:$HOME/development/flutter/bin"' >> ~/.zshrc
source ~/.zshrc
打开命令行工具,运行以下命令检查Flutter是否安装成功:
flutter --version
如果安装成功,你将看到类似以下的输出:
Flutter 3.19.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 6d1f6c8b3a (4 weeks ago) • 2024-02-21 16:33:06 -0800
Engine • revision 204e6b6c64
Tools • Dart 3.3.0 • DevTools 2.31.1
File
→ Settings
→ Plugins
Android Studio
→ Preferences
→ Plugins
Tools
→ SDK Manager
Ctrl+Shift+X
或 Cmd+Shift+X
)在VSCode的设置中(Ctrl+,
或 Cmd+,
),添加以下配置:
{
"dart.flutterSdkPath": "C:\\src\\flutter",
"dart.checkForSdkUpdates": true,
"dart.openDevTools": "flutter"
}
Tools
→ AVD Manager
Create Virtual Device
# 查看可用的系统镜像
flutter emulators --create --name My_Emulator
# 启动模拟器
flutter emulators --launch My_Emulator
# 查看可用的iOS模拟器
flutter emulators
# 启动iOS模拟器
flutter emulators --launch apple_ios_simulator
设置
→ 关于手机
设置
→ 开发者选项
→ USB调试
flutter devices
查看已连接的设备flutter devices
确认设备连接# 创建新项目
flutter create my_first_app
# 进入项目目录
cd my_first_app
# 运行应用(请确保有可用的设备或模拟器)
flutter run
my_first_app/
├── android/ # Android平台特定代码
├── ios/ # iOS平台特定代码
├── lib/ # 主要的Dart代码
│ └── main.dart # 应用入口文件
├── test/ # 测试代码
├── pubspec.yaml # 项目依赖配置
└── README.md
打开 lib/main.dart
文件,你将看到默认生成的代码:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
让我们简单修改一下应用,改变标题和颜色:
// 修改MyApp类的theme属性
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
// 修改MyHomePage的标题
home: const MyHomePage(title: '我的第一个Flutter应用'),
保存文件,应用将自动热重载并显示更新后的界面。
r
): 保持应用状态,快速加载代码更改R
): 重置应用状态,重新启动应用# 启动DevTools
flutter pub global activate devtools
flutter pub global run devtools
DevTools提供了强大的调试功能,包括:
运行 flutter doctor
检查环境配置,常见问题及解决方案:
Android许可证未接受:
flutter doctor --android-licenses
Xcode未配置(macOS):
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -runFirstLaunch
在中国大陆地区,可能需要配置镜像:
# 设置环境变量
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
Android设备无法识别:
iOS设备信任问题:
恭喜!你已经成功搭建了Flutter开发环境并创建了第一个Flutter应用。通过本教程,你学会了:
接下来,你可以继续学习Flutter的核心概念,如Widget、State管理、路由导航等,开始构建更复杂的应用。
祝你Flutter开发之旅愉快!
方法1: 使用 drawHierarchy
extension View {
func snapshot() -> UIImage {
let controller = UIHostingController(rootView: self)
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
view?.bounds = CGRect(origin: .zero, size: targetSize)
view?.backgroundColor = .clear
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
}
}
注意:这个方法需要swiftUI view 设置 .edgesIgnoringSafeArea(.all)
完整demo:
struct ContentView: View {
var textView: some View {
VStack{
Text("ABCSF1")
Text("ABCSF2")
Text("ABCSF3")
Text("Hello, SwiftUI")
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(Capsule())
}
.edgesIgnoringSafeArea(.all)
}
var body: some View {
VStack {
textView
Button("Save to image") {
let image = textView.snapshot()
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
}
.edgesIgnoringSafeArea(.all)
}
}
extension View {
func snapshot() -> UIImage {
let controller = UIHostingController(rootView: self)
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
view?.bounds = CGRect(origin: .zero, size: targetSize)
view?.backgroundColor = .clear
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
}
}
方法2: 使用ImageRenderer,需要iOS16以上
struct RenderView: View {
let text: String
var body: some View {
Text(text)
.font(.largeTitle)
.foregroundStyle(.white)
.padding()
.background(.blue)
.clipShape(Capsule())
}
}
struct ContentView: View {
@State private var text = "Your text here"
@State private var renderedImage = Image(systemName: "photo")
@Environment(\.displayScale) var displayScale
var body: some View {
VStack {
renderedImage
ShareLink("Export", item: renderedImage, preview: SharePreview(Text("Shared image"), image: renderedImage))
TextField("Enter some text", text: $text)
.textFieldStyle(.roundedBorder)
.padding()
}
.onChange(of: text) { _ in render() }
.onAppear { render() }
}
@MainActor
func render() {
let renderer = ImageRenderer(content: RenderView(text: text))
// make sure and use the correct display scale for this device
renderer.scale = displayScale
if let uiImage = renderer.uiImage {
renderedImage = Image(uiImage: uiImage)
}
}
}
参考文章:
未经审视的人生是不值得过的。
我们都听过这句话,可能还会有小小的触动,但之后该刷手机还是刷手机,该工作还是工作。
但如果你真的把它当回事,这可能会是你为自己做的,回报率(ROI)最高的投资之一。
把自己的人生旅程想象成一个项目,这个项目的合伙人,是你自己——-过去的你、现在的你,以及未来的你。
而反思,就是你们每周一次的董事会议。你们坐下来,诚实地对话,为了一个共同的目标:让这个项目变得更好。
这是你与未来自己的合作。你现在的每一个决定,都是在为未来的自己铺路,或者挖坑。
如果不主动管理自己的生活会怎样?
帕金森定律说:工作会自动膨胀,直至填满所有可用时间。生命也是如此。琐事、噪音、他人的议程……如果你不主动捍卫自己的时间和精力,就会被它们吞噬殆尽。
遗忘和混乱是人生的默认设置。一个反思系统,就是对抗这种熵增的工具。
不要等到撞上南墙(比如经历人生重大变故),或者在泥潭里动弹不得时,才想起需要一张地图。这张地图,现在就可以开始画。
一个好的反思系统,会强迫你梳理清楚,自己最关注的是什么。
当你必须写下来,那些模糊的想法就无处遁形,你无法再欺骗自己。
把它当作一个产品来对待。量化关键指标,将进度可视化,记录关键决策。不是为了给别人看,而是为了让你对自己一目了然。
系统会帮你找到自己行为中的 Bug,发现那些让你在错误道路上越走越远的模式。
它让你清晰地回答这两个问题:
最关键的不是某一次彻夜长谈式的深度反思,而是持续。
让反思成为生活的一部分。像刷牙一样自然,像吃饭一样必须。
不需要完美。只需要开始,然后坚持。
这套反思系统,不会给你人生的标准答案。
但它会确保,那个最重要的问题,始终被提出。
以下是一些可以参考的每日/每周/每月反思问题。
类别 | 问题 |
---|---|
习惯 | 冥想 |
习惯 | 运动 (例如:30分钟,具体类型) |
习惯 | 阅读 (例如:15分钟) |
习惯 | 写日记/反思 |
习惯 | 饮水 (例如:8杯水) |
习惯 | 屏幕时间 (例如:低于X小时) |
习惯 | 没有吃垃圾食品 |
习惯 | 高质量睡眠 (例如:7-9小时) |
效率与专注 | 我是否按照计划的日程行事? |
效率与专注 | 我完成了多少个番茄工作法会话? |
效率与专注 | 我今天是否容易分心? |
成果和进步 | 今天我完成了哪些重要的任务或目标? |
成果和进步 | 我今天有哪些小小的胜利? |
学习与成长 | 我今天学到了什么新东西? |
幸福感与情绪 | 今天我的整体情绪如何 (1-10分)? |
幸福感与情绪 | 今天有哪些事情让我感到感恩? |
幸福感与情绪 | 今天有哪些事情挑战了我的耐心或造成了压力? |
反思与改进 | 今天我有哪些地方可以做得更好? |
反思与改进 | 明天我将做一件什么不同的事情? |
关键指标 | 睡眠时长 (小时) |
关键指标 | 饮水量 (杯/升) |
关键指标 | 深度工作时长 (小时) |
类别 | 问题 |
---|---|
成果与进步 | 本周我最主要的3项成就是什么? |
成果与进步 | 我是否在关键项目/目标上取得了进展? |
成果与进步 | 我获得了哪些新技能或知识? |
挑战与反思 | 本周我遇到了哪些主要的困难或挑战? |
挑战与反思 | 什么事情让我感到最大的压力或沮丧? |
挑战与反思 | 我从这些挑战中学到了什么教训? |
习惯与日常 | 我坚持了哪些日常习惯? |
习惯与日常 | 我哪些日常习惯做得不好?为什么? |
习惯与日常 | 我目前的日常安排是否支持我的目标? |
幸福感与平衡 | 本周我的精力水平如何 (1-10分)? |
幸福感与平衡 | 我是否为休息、爱好和社交留出了时间? |
幸福感与平衡 | 本周有哪些事情给我带来了快乐或让我感到充满活力? |
计划与优先级 | 我是否有效地安排了任务和时间优先级? |
计划与优先级 | 有哪些任务延续到下周了?为什么? |
展望未来 | 下周我最重要的3项优先事项是什么? |
展望未来 | 我将对我的计划或习惯做出哪些调整? |
关键指标 | 平均每日睡眠时长 (小时) |
关键指标 | 总运动时长 (小时) |
关键指标 | 总深度工作时长 (小时) |
关键指标 | 社交互动次数 |
类别 | 问题 |
---|---|
主要目标与进展 | 本月我的主要目标是什么? |
主要目标与进展 | 我是否实现了它们?如果没有,为什么? |
主要目标与进展 | 我在长期目标上取得了哪些重大进展? |
学习与成长 | 本月我获得了哪些新技能、知识或见解? |
学习与成长 | 我完成了哪些书籍、文章或课程? |
挑战与挫折 | 我面临的最大障碍或失败是什么? |
挑战与挫折 | 我是如何应对它们的?我学到了什么? |
习惯与系统 | 哪些习惯变得更强或更稳定了? |
习惯与系统 | 哪些习惯仍然需要更多关注或调整? |
习惯与系统 | 我目前的系统 (例如:计划、组织) 有效吗? |
人际关系与联系 | 本月我如何培养了重要的人际关系? |
人际关系与联系 | 我是否结识了新朋友或加强了现有联系? |
财务 (可选) | 本月我的支出与预算相比如何? |
财务 (可选) | 我在财务目标上取得了进展吗? |
幸福感与自我关怀 | 本月我的整体身心健康状况如何? |
幸福感与自我关怀 | 我是否优先考虑了自我关怀和休息? |
反思与感恩 | 本月我最引以为豪的事情是什么? |
反思与感恩 | 本月我最感恩的事情是什么? |
展望未来 | 下个月我最重要的3个目标是什么? |
展望未来 | 我将实施哪些新习惯或系统? |
展望未来 | 下个月我希望重点改进的一个领域是什么? |
关键指标 | 平均生产力得分 (例如:1-10) |
关键指标 | 总学习时长 (小时) |
关键指标 | 新建立的联系数量 |
25年的雨水太少 小区的树木枯死很多 希望缺的冬天补上
使用Xcode26刚打包的包在iOS26上测试发现右滑直接退出了flutter的页面回到了native页面,此时不管flutter页面中跳转了很多次。
一番搜索后确认是iOS26新增的interactiveContentPopGestureRecognizer
属性的,问题。那么在跳转到FlutterViewController
的时候直接设置为false。
@objc
private func jumpToTaskVC() {
let vc = HXDispatchMainFlutterViewController.init(withEntrypoint: nil)
self.navigationController?.pushViewController(vc, animated: false)
self.navigationController?.interactivePopGestureRecognizer?.isEnabled = false
if #available(iOS 26.0, *) {
self.navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = false
} else {
// DO nothing
}
完美。
问了AI,尝试了AI提供的所有办法都不行。 AI还是需要努力啊,无法解决人类不知道的,或者没有告诉AI的事。
目前, iOS小组件中无法直接显示LottieFiles动画,如果要实现iOS小组件的动画效果,必须通过一些黑科技将LottieFiles动画转换为GIF图片帧,通过一些自定义字体或者时钟旋转等方式,实现iOS小组件播放动画的功能。
Lottie动画依赖 Core Animation + CADisplayLink 来实时渲染帧动画。
但 WidgetKit 的设计理念是「静态快照」:
Widget 并不是实时渲染的 view,而是定期刷新生成的快照(snapshot)。
它运行在后台 extension 中,不允许持续的动画循环或渲染。
即使引入 Lottie 库,也无法使用 AnimationView.play() 这种方法——因为 widget 不支持 RunLoop 或连续帧刷新。
Xcode运行时输出报错信息,表示SwiftUI 无法序列化(encode)一个自定义 UIViewRepresentable(或 NSViewRepresentable)类型:LottieView。
PotentialCrashError: BankletWidgetExtension may have crashed
ArchivingError: failedToEncode(types: [SwiftUI.(unknown context).PlatformViewRepresentableAdaptor<BankletWidgetExtension.LottieView>])
iOS系统显示的小组件也会因为无法序列化,显示一个黄底红色的禁止小组件(下图左侧样式)。
因此,在 widget 中「播放」Lottie 动画是不被允许的。
需要将现有的Lottie文件转换为GIF图片格式,这样才可以在小组件中显示Lottie动画。
建议使用LottieFiles的格式转换页面:lottiefiles.com/tools/lotti…
此外还有其他两种LottieFiles格式转换方法,具体请见《Lottie动画转GIF图片》文章进行了解。
打开Xcode项目,点击左侧的项目名称 – PROJECT – 项目名 – Package Dependencies,点击“添加”按钮,引入GitHub第三方库ClockHandRotationKit(github.com/octree/Cloc…)。
导入ClockHandRotationKit框架后,主应用和小组件都必须配置这一框架。
点击左侧的项目名称 – TARGETS,分别检查主应用和小组件的General – Frameworks, Libraries, and Embedded Content,是否包含ClockHandRotationKit框架。
在Xcode小组件项目中,创建一个Gif组,将Gif图片拖入到这个组中。
在拖入Gif组时,Targets选择主应用和小组件,如果不勾选,后面可能无法从Bundle.main.path查找。
Xcode中Gif组:
Gif图片放入Xcode项目的原因:因为需要通过Bundle.main.path访问Gif图片。
如果将Gif放到Assets资源文件夹中,就无法在文件系统里查找真实文件路径,Bundle.main.path会返回nil。
1、创建一个UIImage扩展文件
因为扩展代码过长,具体请见文章底部的扩展代码部分,或者参考GitHub代码(github.com/fangjunyu1/…)。
import UIKit
extension UIImage {
static func fromBundle(_ bundle: Bundle? = nil, forName name: String?, ofType ext: String?) -> UIImage? {
guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: ext) else {
return nil
}
return UIImage(contentsOfFile: path)
}
}
...
2、创建一个弧形视图文件。
弧形视图文件代码:
import SwiftUI
struct ArcView: Shape {
var arcStartAngle: Double
var arcEndAngle: Double
var arcRadius: Double
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
radius: arcRadius,
startAngle: .degrees(arcStartAngle),
endAngle: .degrees(arcEndAngle),
clockwise: false)
return path
}
}
这个文件的三个变量,分别控制弧形的起始角度、结束角度和弧形半径。
创建一个SwiftUI自定义视图,将GIF动画拆分成帧,在每一帧图片上面添加一个圆弧遮罩,然后旋转所有圆弧遮罩,通过旋转效果组合成一个动态的弧形GIF展示效果。
import SwiftUI
import ClockHandRotationKit
struct GifImageView: View {
var gifName: String // Bundle中 gif图片的名称
func getGif(_ name: String) -> UIImage.GifResult? {
guard let path = Bundle.main.path(forResource: "gif_(name)", ofType: "gif"),
let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
print("未找到该数据")
return nil
}
return UIImage.decodeGIF(data)
}
var body: some View {
if let gif = getGif(gifName) {
GeometryReader { proxy in
let width = proxy.size.width
let height = proxy.size.height
let arcWidth = max(width, height)
let arcRadius = arcWidth * arcWidth
let angle = 360.0 / Double(gif.images.count)
ZStack {
ForEach(1...gif.images.count, id: .self) { index in
Image(uiImage: gif.images[(gif.images.count - 1) - (index - 1)])
.resizable()
.scaledToFill()
.mask(
ArcView(arcStartAngle: angle * Double(index - 1),
arcEndAngle: angle * Double(index),
arcRadius: arcRadius)
.stroke(style: .init(lineWidth: arcWidth, lineCap: .square, lineJoin: .miter))
.clockHandRotationEffect(period: .custom(gif.duration))
.offset(y: arcRadius)
)
}
}
.frame(width: width, height: height)
}
} else {
// 如果没有图片,显示空白占位符
Image("png_Home0")
.resizable()
}
}
}
这个代码可以理解为,首先通过getGif方法获取Gif图片的每一帧以及运行时间。
通过ZStack排列所有的图片帧,因为ZStack视图是从后往前,在ForEach循环时,也是从后往前的顺序遍历。
假设某个Gif图片共20帧,每帧在ZStack中显示的排序为:
在每一个Gif帧上设置一个mask遮罩层,Gif帧只会显示mask的视图部分,非mask的视图不显示。mask遮罩层显示一个弧形。
弧形的开始角度、结束角度都是根据Gif帧数平均计算。
每个Gif帧的mask遮罩层都会对应到弧形上,通过设置弧形的边框,让遮罩层可以更好的显示每一个Gif帧。
设置描边可以让遮罩层覆盖到视图,弧形向下偏移半径的长度。
当对应黄色区域逆时针旋转,与蓝色的View视图区域重合时,对应的Gif图片帧就会显示出来。
因为每个Gif图片帧的黄色区域不同,所以当弧形蒙版不断旋转,就可以实现Gif图片的效果。
import WidgetKit
import SwiftUI
import ClockHandRotationKit
struct GifView : View {
var entry: GifWidgetEntry
var body: some View {
VStack {
GifImageView(gifName: "(entry.loopAnimation)")
}
}
}
struct GifAnimateWidget: Widget {
let kind: String = "GifWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: GifWidgetProvider()) { entry in
GifView(entry: entry)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Animation Widget")
.description("Play animation on the desktop in a loop.")
.supportedFamilies([.systemSmall])
}
}
#Preview(as: .systemSmall) {
GifAnimateWidget()
} timeline: {
GifWidgetEntry(date: Date(), loopAnimation: "Home33")
}
在GifView视图中,显示GifImage视图。
1、主应用和小组件都需要导入框架
Xcode导入ClockHandRotationKit框架时,必须考虑导入到主应用和小组件中。
如果没有导入主应用,就会存在Xcode调试真机时,报错并断开连接的情况。
可能是主应用也需要编译小组件的代码,当缺少小组件代码运行的框架时,就会报错。
2、Xcode项目导入Gif图片
这里使用Group或者Folder等形式,管理Gif图片。在导入图片时,需要在Target Membership中勾选主应用和小组件。
可能只需要勾选主应用,因为Bundle.main.path通过主应用的包进行查询。如果这里没有勾选主应用,就会存在查不到的情况。
3、透明背景
透明背景需要使用私有API,具体请见《iOS App小组件(Widget)设置透明背景》
4、控制GIF播放速度
默认按照GIF动画时间进行播放,如果想要调整GIF播放速度,可以在GifImageView视图代码中,调整mask蒙版的代码:
.mask(
ArcView(arcStartAngle: angle * Double(index - 1),
arcEndAngle: angle * Double(index),
arcRadius: arcRadius)
.stroke(style: .init(lineWidth: arcWidth, lineCap: .square, lineJoin: .miter))
.clockHandRotationEffect(period: .custom(gif.duration * 2)) // 控制 GIF 动画速度,选择 *2 或者 * 3
.offset(y: arcRadius)
)
5、小组件播放卡顿或空白
因为iOS 小组件内容比较低,如果Gif图片过大,帧数过多,就可能导致超过30MB内存并无法运行小组件。
目前实际测试发现5MB以内的Gif图片,小组件显示存在压力。超过5MB的Gif图片可能会直接显示空白。
建议压缩Gif图片大小,将Gif图片尽量控制在1MB以内,这样可以正常的显示/切换Gif动画。否则Gif图片越大,小组件在显示/切换的过程中,就会出现卡顿或空白的情况。
本文尽量通过简单的描述,来讲解iOS实现GIF图片的效果,通过蒙版和旋转弧形实现GIF图片动画。
除此之外,还可以通过字体实现GIF动画效果,具体请见GitHub仓库WidgetAnimation(github.com/brycebostwi…)。
我除了这两种方式外,还尝试使用倒计时显示每一帧图片,但实际上并不能实现GIF动画效果,我猜测原因是小组件不支持Image动态显示。
struct GIFPlayerView: View {
let gif: UIImage.GifResult
@State private var currentFrameIndex = 0
@State private var timer: Timer? = nil
var body: some View {
GeometryReader { proxy in
let width = proxy.size.width
let height = proxy.size.height
Image(uiImage: gif.images[currentFrameIndex])
.resizable()
.scaledToFill()
.frame(width: width, height: height)
.clipped()
当使用计时器调整数组索引显示Gif图片时,小组件不会让Image动态切换,也就无法通过计时器实现GIF动画效果。
1、Display lottie animation in iOS WidgetKit:stackoverflow.com/questions/7…
2、How to animate WidgetKit Widgets like other apps do it?
stackoverflow.com/questions/7…
3、Lottie动画转GIF图片:fangjunyu.com/2025/10/12/…
4、GitHub WidgetAnimation:github.com/brycebostwi…
5、ClockHandRotationKit:github.com/octree/Cloc…
6、WidgetsWall:github.com/MisterZhouZ…
7、【iOS】GIF小组件的巧妙实现:juejin.cn/post/739998…
8、【iOS小组件实战】gif动态小组件:juejin.cn/post/742669…
9、GitHub UIImage扩展代码:github.com/fangjunyu1/…
10、SwiftUI控制视图透明度可见区域的mask:fangjunyu.com/2025/03/20/…
11、iOS App小组件(Widget)设置透明背景:fangjunyu.com/2025/10/08/…
import UIKit
extension UIImage {
static func fromBundle(_ bundle: Bundle? = nil, forName name: String?, ofType ext: String?) -> UIImage? {
guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: ext) else {
return nil
}
return UIImage(contentsOfFile: path)
}
}
extension UIImage {
struct GifResult {
let images: [UIImage]
let duration: TimeInterval
}
static func decodeBundleGIF(_ bundle: Bundle? = nil, forName name: String) async -> GifResult? {
guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: "gif") else {
return nil
}
return await decodeLocalGIF(URL(fileURLWithPath: path))
}
static func decodeLocalGIF(_ url: URL) async -> GifResult? {
guard let data = try? Data(contentsOf: url) else {
return nil
}
return decodeGIF(data)
}
static func decodeGIF(_ data: Data) -> GifResult? {
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
return nil
}
let count = CGImageSourceGetCount(imageSource)
var images: [UIImage] = []
var duration: TimeInterval = 0
for i in 0 ..< count {
guard let cgImg = CGImageSourceCreateImageAtIndex(imageSource, i, nil) else { continue }
let img = UIImage(cgImage: cgImg)
images.append(img)
guard let proertyDic = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil) else {
duration += 0.1
continue
}
guard let gifDicValue = CFDictionaryGetValue(proertyDic, Unmanaged.passRetained(kCGImagePropertyGIFDictionary).autorelease().toOpaque()) else {
duration += 0.1
continue
}
let gifDic = Unmanaged<CFDictionary>.fromOpaque(gifDicValue).takeUnretainedValue()
guard let delayValue = CFDictionaryGetValue(gifDic, Unmanaged.passRetained(kCGImagePropertyGIFUnclampedDelayTime).autorelease().toOpaque()) else {
duration += 0.1
continue
}
var delayNum = Unmanaged<NSNumber>.fromOpaque(delayValue).takeUnretainedValue()
var delay = delayNum.doubleValue
if delay <= Double.ulpOfOne {
if let delayValue2 = CFDictionaryGetValue(gifDic, Unmanaged.passRetained(kCGImagePropertyGIFDelayTime).autorelease().toOpaque()) {
delayNum = Unmanaged<NSNumber>.fromOpaque(delayValue2).takeUnretainedValue()
delay = delayNum.doubleValue
}
}
if delay < 0.02 {
delay = 0.1
}
duration += delay
}
guard images.count > 0 else {
return nil
}
return GifResult(images: images, duration: duration)
}
}
经常做马甲包的朋友都是知道,在账号没有被封之前是好的。
苹果过审的时候,你是心高气傲。封号3,2f的时候,你是爱莫能助。如有需要请后台留言,专注AppStore各种疑难杂症!
被封禁的账号除了要做好,基本上物理隔离、收款隔离,还有一点就是付款隔离
。(目前这块,其实并不严格。但是求稳肯定是隔离了问心无愧。)
省流版本:
今天就分享一个新的付款支付方式-抖*支*付!
前往设置 -》 Apple账户 -》 付款与配送 -》 添加新的付款方式
选择支付方式-》抖*,即将前往App。
绑定完毕,可以查看绑定账户信息。同时,可以支付设置中配置支付信息。
账号申请不易,3.2f亦是雪上加霜。愿各位且行且珍惜,多行大道,毕竟陷入囧地!
遵守规则,方得长治久安
,最后祝大家大吉大利,今晚过审!
# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。
月球基地的虚拟屏幕上,星核数据的传输延迟仍在跳动 ——0.1 秒的滞后,在普通人眼中微不足道,在要求 “零误差” 的月球矩阵里却堪比 “定时炸弹”。
上集里 Main Actor 默认隔离让代码摆脱了数据竞争的 “紊流”,但在本集,它似乎露出了 “性能杀手” 的獠牙。
在本篇月球探险中,您将学到如下内容:
零号攥紧了鼠标,目光扫过星核文明传来的最新性能报告:“线程跳跃成本”、“操作耗时阈值”、“模块隔离适配”,这些术语像散落的拼图,只有拼起来,才能看清 Main Actor 的完整面貌。
—— 零号技术员的星核接口调试手记
零号调出基地主控电脑的线程监控日志,红色的延迟曲线刺得人眼睛发疼。
他一直担心 “主线程塞太多任务会卡顿”,但实测后才发现,这事儿根本不是 “非黑即白”—— 就像月球基地的主控电脑,处理小任务时比外派机器人更高效,只有遇到重活才需要分流。
星核文明的技术文档里藏着关键数据:线程之间的切换,就像让机器人从主控舱跑到副控舱,看似简单,实则要消耗 “调度时间”、“数据拷贝” 两道成本。
零号做了个对比实验:
处理 1KB 的星核校验数据(小任务):主线程直接处理耗时 0.02 秒;分流到后台线程再返回主线程,耗时 0.05 秒 ——多花了 1.5 倍时间
处理 100MB 的星核影像数据(重任务):主线程处理耗时 2.1 秒;后台线程处理耗时 0.8 秒 ——差距立竿见影
这就好比:给主控电脑递一张纸条(小任务),直接递过去最快;但要搬一整箱设备(重任务),肯定得叫外派机器人。
Main Actor 默认隔离的聪明之处,就在于让 “小任务默认留主线程”,避免了不必要的 “线程跑腿成本”。
零号突然明白,之前的延迟警报,根本不是主线程 “扛不住”,而是他忘了给perform
函数里的网络请求 “分流”。
星核的电影数据接口返回速度慢,让主线程一直等着,自然拖慢了后续任务。
他立刻修改代码,给耗时的网络请求加了@concurrent
,相当于给机器人发了 “外派指令”:
@MainActor
func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
// 网络请求耗时久,用@concurrent丢到后台,主线程先去忙别的
let (data, _) = try await @concurrent URLSession.shared.data(for: request)
return try await decode(data)
}
运行日志瞬间变绿 —— 延迟从 0.1 秒降到 0.03 秒。
零号苦笑:这哪里是主线程的锅,分明是自己没找准 “任务分流点”。在月球矩阵里,性能优化的核心从不是 “避开主线程”,而是 “让对的任务待在对的地方”。
解决了性能问题,零号转头对付更棘手的 SPM 包 —— 星核的代码库分两类:一类是处理数据传输的 “网络模块”,一类是对接基地界面的 “UI 模块”,如若给它们套一样的隔离规则,简直是 “张冠李戴”。
零号翻出星核模块的设计文档,像给不同机器人贴 “任务标签”:
SPM 包类型 | 核心需求 | 隔离适配方案 | 类比场景 |
---|---|---|---|
网络模块(如星核数据传输) | 后台并行处理、避免主线程阻塞 | 不设默认隔离,全模块标记 Sendable | 外派机器人在副控舱独立干活 |
UI 模块(如基地监控界面) | 主线程安全、UI 更新稳定 | 模块级默认隔离为 MainActor | 主控舱机器人专注处理界面任务 |
星核的网络包之前一直报数据竞争错,零号给它加了 “Sendable 全标记”,又在Package.swift
里关了默认隔离:
// 星核网络包的Package.swift设置
let package = Package(
name: "StellarNetwork",
products: [.library(name: "StellarNetwork", targets: ["StellarNetwork"])],
targets: [
.target(
name: "StellarNetwork",
// 网络包无需默认隔离,让代码自由分配线程
swiftSettings: [.defaultIsolation(nil)]
)
]
)
// 所有类和结构体加Sendable,相当于给机器人装“安全锁”
struct StellarTransmitter: Sendable {
func sendData(_ data: Data) async throws {
// 后台线程自由运行,无数据竞争风险
}
}
而 UI 模块则直接开了默认隔离,省了一堆@MainActor
注解:
// 基地UI包的Package.swift设置
.target(
name: "BaseMonitorUI",
// 模块里所有代码默认归MainActor管
swiftSettings: [.defaultIsolation(MainActor.self)]
)
// 无需手动加@MainActor,自动在主线程运行
class MonitorViewModel {
var screenData: [String: String] = [:]
func updateScreen() async {
// 直接更新UI数据,安全无报错
screenData = try await fetchMonitorData()
}
}
零号运行测试,两类包都不再报错 —— 原来 SPM 包的抉择根本不是 “要不要默认隔离”,而是 “要不要给模块‘量身定制’隔离规则”。
调试完最后一行代码,月球矩阵的信号恢复了稳定,星核文明传来一段意味深长的留言:“并发不是‘越多越好’,也不是‘主线程独大’,而是让每个任务都有‘归属地’。”
零号合上日志,终于想通了开头的问题 ——该不该开 Main Actor 默认隔离?答案藏在 “场景” 里:
@concurrent
分流即可;深夜的月球基地,主控屏幕泛着柔和的光。
零号看着稳定跳动的星核信号,突然明白:Swift 6.2 的改动,根本不是 “限制自由”,而是给混乱的并发世界立了 “秩序”。就像月球矩阵的运行逻辑 —— 不是靠 “无拘无束的代码” 维系,而是靠 “每个任务都在对的地方” 的平衡。
Main Actor 默认隔离,不过是 Apple 给开发者的 “一把尺子”:它让新手少踩数据竞争的坑,让老手更懂 “任务分配的艺术”。在星核文明的眼里,好的代码从不是 “技术炫技”,而是 “恰到好处的平衡”—— 就像地球与月球的引力,不多一分,不少一分,才成就了稳定的星系。
而那些还在争论 “该不该开默认隔离” 的开发者,或许该问问自己:你的代码,到底是 “需要自由的外派机器人”,还是 “该待在主控舱的核心设备”?想清楚这个问题,答案自然浮现。
那么,各位微秃宝子你们想清楚了吗?
感谢观赏,我们下次不见不散!8-)
月球背面的 “守望者” 基地里,零号技术员的指尖在泛着冷光的虚拟键盘上翻飞 —— 他刚接到外星 “星核文明” 的紧急通讯:人类上周推送的星核数据接口程序,因并发漏洞触发了矩阵 “数据紊流”,再晚 0.3 秒就会导致地球与月球的信号断联。
而罪魁祸首,正藏在 Swift 6.2 那项让全宇宙开发者吵得不可开交的新特性里:Main Actor 默认隔离。
在本次月球冒险中,您将学到如下内容:
今天,他要剖开这个特性的内核,看看这究竟是 “矩阵稳定剂”,还是 “代码枷锁”。
—— 零号技术员的星核接口调试手记
Swift 6.2 这次更新堪称 “石破天惊”,其中最扎眼的改动,是新增了一个编译器 flag—— 默认情况下,所有没标 “非隔离 nonisolated” 的代码,都会被 “绑” 到 Main Actor(主线程)上。
这可不是小打小闹的调整,相当于给混乱的并发世界立了 “主心骨”,以前 “群龙无首” 的混乱代码,现在突然有了统一的 “指挥中心”。
零号翻着星核文明传来的技术文档,越看越明白:这个改动的核心争议,在于 “是否该让主线程当默认掌舵人”。
要搞懂答案,得先拆两个关键问题:
在月球矩阵里,任何技术选择都关联着地球的信号稳定,零号不敢急着妄下定论 —— 他决定先从 Xcode 26 的实测开始,摸透这套新规的 “脾气”。
零号新建了一个对接星核数据的测试项目,刚运行就发现两个 “隐藏设定” 自动开启,像基地刚启动时的 “安全协议”:
全局 Actor 隔离设为 MainActor.self:所有代码默认归主线程管
可访问并发(Approachable Concurrency)开启:降低并发的使用门槛
这意味着什么?零号敲了一段测试代码,每一行都像给星核接口贴了 “身份标签”:
// 这个类默认被@MainActor隔离,相当于在月球基地“主控区”运行
class MyClass {
// 这个属性也默认归MainActor管,就像主控区里的核心数据
var counter = 0
// 这个异步函数默认在MainActor里执行,相当于在主控区处理星核请求
func performWork() async {
// 这里的操作都会被主线程“盯紧”,避免数据乱套
}
// 加了nonisolated,相当于“脱离主控区”,去副控舱干活
nonisolated func performOtherWork() async {
// 这里的代码不受MainActor管控,适合处理不碰核心数据的杂活
}
}
// 单独的Actor类,自带“独立舱室”,不会被MainActor“接管”
actor Counter {
var count = 0 // 这个属性只归Counter自己管,避免和主线程抢资源
}
零号盯着运行日志恍然大悟:按这个默认逻辑,只要不手动 “松绑”,代码就会一直待在主线程里 —— 就像基地里的机器人,没收到 “外派指令” 就永远待在主控区。
这倒是省了以前手动加 @MainActor 的功夫,但也意味着:想让代码 “去后台打杂”,必须明明白白写清楚,再也不能 “随心所欲”。
正当零号以为摸清了规律,把星核的加密模块做成 SPM 包时,意外出现了 —— 这个包居然 “不吃主线程那套”,默认根本没开 Main Actor 隔离,像基地里的 “自由派” 机器人,不手动发指令就不按常理出牌。
他翻了 Swift 的官方文档才知道:新创建的 SPM 包,默认不会设置defaultIsolation
flag,也就是说,代码不会自动扎进 Main Actor 的 “安全区”。要改也简单,只要在 target 的swiftSettings
里加一行 “强制指令”:
swiftSettings: [
// 给SPM包手动挂上MainActor“标签”,让它融入主线程体系
.defaultIsolation(MainActor.self)
]
更有意思的是,SPM 包不仅默认不沾主线程,连 “NonIsolatedNonSendingByDefault” 都没开 —— 这就导致它和 App 项目成了 “两类画风”:
App 项目里:非隔离的异步函数像 “跟屁虫”,调用者在哪它就在哪。如果从主线程调,它就乖乖待在主线程;
SPM 包里:非隔离的异步函数像 “独行侠”,不管谁调,都一头扎进后台线程,根本不看 “调用者脸色”。
“这要是混着用,不触发矩阵冲突才怪!” 零号揉了揉太阳穴 —— 星核文明之前警告的 “数据紊流”,说不定就藏在这种 “风格差异” 里。
零号调出上次引发故障的代码 —— 那是一个对接星核电影数据库的列表视图,当时就是因为没搞懂主线程隔离,才差点出大事。
代码长这样:
struct MoviesList: View {
// 星核电影仓库实例,默认归MainActor管
@State var movieRepository = MovieRepository()
// 电影数据,也是主线程里的“核心资产”
@State var movies = [Movie]()
var body: some View {
Group {
if !movies.isEmpty {
List(movies) { movie in
Text(movie.id.uuidString) // 渲染星核返回的电影ID
}
} else {
ProgressView() // 加载时显示的“矩阵缓冲动画”
}
}.task {
do {
// 这里报了错:传递self.movieRepository有数据竞争风险
// 零号当时没注意:movieRepository在主线程,loadMovies却跑在后台
movies = try await movieRepository.loadMovies()
} catch {
movies = []
}
}
}
}
星核文明的技术顾问当时给的解释,零号至今记得很清楚:“这就像同一个仓库,前门(主线程的视图)有人拿货,后门(后台的 loadMovies)有人搬货,不撞车才怪。”
问题的根儿,在于loadMovies
是 “非隔离异步函数”—— 它在后台线程能访问movieRepository
,而视图又在主线程同时访问,相当于 “两个线程抢同一个资源”,数据紊流就是这么来的。
要解决这个问题,当时有两条出路:
loadMovies
跟调用它的线程 “同频”(用nonisolated(nonsending)
),调用者在哪它就在哪;loadMovies
归 MainActor 管,跟视图 “待在同一个舱室”。零号当时选了第二条路 —— 毕竟视图本来就在主线程,让loadMovies
也过来,相当于 “大家都在主控区干活,省得跨线程传数据”。
但新的麻烦又冒了出来:loadMovies
里调的其他函数,有的不归 MainActor 管,结果编译器报错从 “视图” 转移到了 “仓库”,像 “按下葫芦浮起瓢”让人不省心。
零号当时把MovieRepository
改了又改,最后改成了这样 —— 相当于给整个仓库 “安上 MainActor 的标签”:
class MovieRepository {
// 让loadMovies归MainActor管,跟视图“同处一室”
@MainActor
func loadMovies() async throws -> [Movie] {
let req = makeRequest()
let movies: [Movie] = try await perform(req)
return movies
}
// 普通函数,默认跟着类走,也归MainActor管
func makeRequest() -> URLRequest {
let url = URL(string: "https://example.com")! // 星核电影数据接口
return URLRequest(url: url)
}
// perform也归MainActor管,确保整个流程都在主线程
@MainActor
func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, _) = try await URLSession.shared.data(for: request)
// 这里又报错了:传递self给非隔离的decode有风险
// 就像把主控区的密钥给副控舱的人,不安全
return try await decode(data)
}
// decode是非隔离的,跑在后台线程
nonisolated func decode<T: Decodable>(_ data: Data) async throws -> T {
return try JSONDecoder().decode(T.self, from: data)
}
}
问题卡在了decode
上 —— 它是非隔离的,perform
在主线程调它,相当于 “从主控区往副控舱传数据”,编译器直接亮了红灯。
零号当时想过给MovieRepository
加Sendable
标签(相当于给仓库 “加安全锁”),但星核的电影数据里有可变状态,根本加不了。
最后他才发现:要是让整个MovieRepository
都默认归 MainActor 管,既能安全传self
,又能让decode
继续在后台 “打杂”—— 这正是 Swift 6.2 新默认设置想解决的问题!
零号按照 Swift 6.2 的默认设置,把MovieRepository
重写了一遍 —— 这次居然没加一个 @MainActor,代码却比之前清爽十倍:
class MovieRepository {
// 默认归MainActor管,不用手动加注解,省了不少功夫
func loadMovies() async throws -> [Movie] {
let req = makeRequest()
let movies: [Movie] = try await perform(req)
return movies
}
// 普通函数也默认在MainActor里,跟整个类“同频”
func makeRequest() -> URLRequest {
let url = URL(string: "https://example.com")! // 星核接口地址
return URLRequest(url: url)
}
// 异步函数默认归MainActor,流程丝滑
func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, _) = try await URLSession.shared.data(for: request)
// 这里不报错了!因为整个类默认在MainActor,传self安全
return try await decode(data)
}
// 加@concurrent,明确让它去后台线程,相当于“外派任务”
@concurrent func decode<T: Decodable>(_ data: Data) async throws -> T {
return try JSONDecoder().decode(T.self, from: data)
}
}
零号盯着屏幕,突然明白了新规的 “高明之处”:它把 “并发开关” 反过来了 —— 以前是 “默认开并发,手动关”,现在是 “默认关并发,手动开”。
只需要给decode
加个@concurrent
,就能让它去后台 “打杂”,其他函数安安稳稳待在主线程,既没了数据竞争,又省了一堆注解。
更妙的是,遇到await
的时候,主线程会 “暂停待命”,去处理其他任务 —— 就像基地主控电脑在等星核数据时,先去处理地球的信号请求,一点不浪费时间。
这哪是 “代码枷锁”,简直是 “矩阵润滑剂”啊!
零号刚想把这个发现同步给星核文明,虚拟屏幕突然弹出一条新警报:“星核数据传输延迟增加 0.1 秒,疑似主线程负载过高。”
他心里一沉 —— 刚才光顾着解决数据安全,却忘了最关键的问题:把代码都塞进主线程,会不会像给月球基地的主控电脑 “塞太多任务”,拖慢星核数据的传输速度呢?
而 SPM 包的抉择更棘手:如果是网络模块,总不能让它默认待在主线程吧?但如果是 UI 模块,又必须跟主线程绑定。这些问题,就像月球矩阵里没探明的 “暗礁”,藏在下一集的日志里。
下一集,零号将剖开 “主线程默认隔离” 的性能真相,还会给出 SPM 包的终极抉择 —— 毕竟在地球与星核的连接中,没有 “绝对正确” 的答案,只有 “最适合矩阵的选择”。
Swift 并发编程(Swift Concurrency)中,任务(Task)是执行异步代码的最小单元。Swift 提供了三种创建任务的方式:
本文将重点讲解 非结构化任务 和 分离任务,帮助你深入理解它们的区别、使用场景以及注意事项。
✅ 1. 什么是非结构化任务?
非结构化任务通过 Task { ... }
创建,它不会与调用它的上下文形成父子任务关系,因此 不参与结构化并发。这意味着:
✅ 2. 上下文继承
虽然非结构化任务不参与结构化并发,但它会继承以下上下文信息:
继承项 | 说明 |
---|---|
当前 Actor | 如果在 Actor 中创建任务,它会运行在该 Actor 上 |
Task Local 值 | 会继承当前任务的局部值 |
任务优先级 | 会继承当前任务的优先级 |
✅ 3. 示例讲解
示例 1:在 Actor 中使用非结构化任务
actor SampleActor {
var someCounter = 0
func incrementCounter() {
Task {
someCounter += 1 // ✅ 安全访问 actor 内部状态
}
}
}
✅ 说明:虽然 Task
是异步的,但由于它在 Actor 内部创建,因此可以安全地访问 someCounter
,无需使用 await
。
示例 2:在 @MainActor 上下文中创建任务
@MainActor
func fetchData() {
Task {
// ✅ 这个任务运行在 MainActor 上
let data = await fetcher.getData() // 不会阻塞主线程
self.models = data // ✅ 主线程更新 UI
}
}
✅ 说明:await
不会阻塞主线程,主线程在等待期间可以继续处理其他任务。
示例 3:fire-and-forget 风格日志函数
func log(_ string: String) {
print("LOG:", string)
Task {
await uploadMessage(string) // ✅ 异步上传日志
print("message uploaded")
}
}
✅ 说明:调用 log
函数后无需等待上传完成,适合日志、埋点等场景。
✅ 1. 什么是分离任务?
分离任务通过 Task.detached { ... }
创建,它是非结构化任务的一种特殊形式,不继承任何上下文:
@MainActor
中创建)✅ 2. 示例讲解
示例 1:在 Actor 中使用分离任务
actor SampleActor {
var someCounter = 0
func incrementCounter() {
Task.detached {
// ❌ 错误:不能直接在非隔离上下文中访问 actor 状态
// someCounter += 1
await self.someCounter += 1 // ✅ 需要显式使用 await
}
}
}
⚠️ 说明:分离任务不在 Actor 上运行,因此访问 actor 状态必须通过 await
。
示例 2:在 @MainActor 中使用分离任务
@MainActor
func fetchData() {
Task.detached {
// ✅ 运行在后台线程
let data = await fetcher.getData()
self.models = data // ❌ 注意:此时更新 UI 可能不在主线程
}
}
⚠️ 说明:分离任务不会运行在 MainActor 上,因此更新 UI 时需要注意线程安全。
特性 | 非结构化任务(Task {}) | 分离任务(Task.detached) |
---|---|---|
是否参与结构化并发 | ❌ | ❌ |
是否继承 Actor | ✅ | ❌ |
是否继承 Task Local 值 | ✅ | ❌ |
是否继承优先级 | ✅ | ❌ |
是否推荐优先使用 | ✅ | ❌(仅在必要时使用) |
✅ 使用非结构化任务的场景:
viewDidLoad
中调用异步接口)⚠️ 使用分离任务的场景(谨慎):
✅ 总结:
Task {}
是最常用的方式,适用于大多数异步任务。Task.detached
是高级工具,仅在确实需要脱离上下文时使用。await
。struct ContentView: View {
@State private var data: String = ""
var body: some View {
Text(data)
.task {
// ✅ SwiftUI 提供的 task 修饰符会自动管理生命周期
data = await fetchData()
}
}
}
✅ 说明:SwiftUI 的 .task
会在视图消失时自动取消任务,适合生命周期绑定。
class Logger {
func log(_ message: String) {
print("本地日志:", message)
Task {
await uploadLog(message)
}
}
private func uploadLog(_ message: String) async {
// 模拟网络上传
try? await Task.sleep(nanoseconds: 1_000_000_000)
print("日志上传完成:", message)
}
}
✅ 说明:调用 log
后立即返回,不阻塞主线程,适合埋点、日志收集等。
TaskGroup
实现结构化并发await withTaskGroup(of: String.self) { group in
for i in 1...3 {
group.addTask {
return "任务 \(i) 完成"
}
}
for await result in group {
print(result)
}
}
✅ 说明:与 Task {}
不同,TaskGroup
提供结构化并发,适合并行任务管理。
在 Swift 5 时代,开启 Strict Concurrency 后,以下代码会报错:
class MyClass { var count = 0 }
func foo() {
let obj = MyClass()
Task { // 🚨 Capture of 'obj' with non-sendable type 'MyClass'
obj.count += 1
}
}
Swift5时 Task的初始化方法定义
public init(
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async -> Success
)
原因:Task
的 operation
被标注为 @Sendable
,意味着闭包只能捕获 Sendable
的值。
Swift 6 变化:同样的代码,不再报错。因为 Task
的签名被悄悄改成了 sending
闭包。
Swift6之后的Task初始化定义
public init(name: String? = nil,
priority: TaskPriority? = nil,
operation: sending @escaping @isolated(any) () async -> Success)
Sendable
与 sending
:一字之差,天壤之别比较项 | Sendable | sending |
---|---|---|
特性 | 类型(struct / class / actor) | 值(实例、闭包) |
作用对象 | 类型(struct / class / actor) | 值(实例、闭包) |
关键词位置 | 类型声明处 | 参数/变量前 |
编译器要求 | “永远线程安全” | “转移后不再使用”即可 |
典型场景 | 全局共享、长期存活 | 一次性移交、临时捕获 |
示例 | final class Box: Sendable { ... } |
func f(_: sending () -> Void) |
一句话记忆:Sendable
是“终身荣誉”,sending
是“一次性通行证”。
sending
解决了什么痛点?@Sendable
闭包要求所有捕获变量都线程安全,导致大量临时对象被迫做成 Sendable
,甚至强行加锁,过度设计。
sending
的思路编译器不再要求对象本身线程安全,只保证:
✅ 合法:一次性移交
func foo() async {
let obj = MyClass()
Task { // obj 被“sending”进任务
obj.count += 1 // 只在任务内部使用
}
// 下面再访问 obj 会编译错误
}
❌ 非法:移交后仍访问
func foo() async {
let obj = MyClass()
Task { // Sending value of non-Sendable type '() async -> ()' risks causing data races
obj.count += 1
}
print(obj.count)
}
编译器会精准指出:later accesses could race。
sending
函数——语法与注意点class ImageCache {
func warmUp() {
}
}
/// 自定义与 Task 相同能力的“发送”函数
func runLater(_ body: sending @escaping () async -> Void) {
Task { await body() }
}
// 使用
func demo() async {
let cache = ImageCache() // 非 Sendable
runLater {
await cache.warmUp() // 安全:cache 不再被外部持有
}
}
语法小结:
sending
写在参数类型最前面,与 @escaping
位置相同。@Sendable
的对比实验实验项 |
@Sendable 闭包 |
sending 闭包 |
---|---|---|
捕获非 Sendable 对象 | ❌ 编译失败 | ✅ 允许 |
捕获后外部再访问 | ❌ 编译失败 | ❌ 编译失败 |
跨并发域传递 | ✅ 安全 | ✅ 安全(靠“一次性”) |
长期全局共享 | ✅ 适合 | ❌ 不适合 |
结论:@Sendable
负责“终身安全”,sending
负责“临时过户”。
sending
?异步回调框架
你的框架提供 func deferWork(_: sending () async -> Void)
,让用户把任意对象塞进来,而无需强迫它们做成 Sendable
。
并行 Map/Reduce 工具
把大块非线程安全数据切成临时片,通过 sending
交给子任务,主线程后续不再触碰。
与遗留代码的接缝
旧代码里大量 NSObject
子类无法改成 Sendable
,用 sending
包装一次性异步迁移,渐进式现代化。
不要返回 sending
值
目前 sending
只能用于参数,不能作为返回类型,防止“转移”语义被滥用。
不要捕获 sending
变量再逃逸
编译器会阻止你把 sending
闭包再次存到全局变量或属性,确保“生命周期唯一”。
单元测试多线程场景
即使编译器通过,也要写压力测试:
(0..<1000).concurrentForEach { _ in
await foo() // 确保无数据竞争
}
与 actor 搭配更香
把 sending
当作“进入 actor 世界的门票”:
sending
交给 actor → 后续所有访问都在 actor 内部,零锁代码。Sendable
├─ 终身线程安全
├─ 可被任意并发环境长期持有
└─ 实现代价高(锁、不可变、snapshot)
sending
├─ 一次性“过户”
├─ 编译器保证“原主人不再碰”
└─ 实现代价低,适合临时对象
sending
不是 @Sendable
的替代品,而是互补品:
前者解决“临时搬迁”,后者解决“长期共处”。
在 Swift 6 的并发宇宙里,数据竞争被提前到编译期。
理解并善用 sending
,可以让你少写 80 % 的锁,少改 80 % 的旧代码,同时不牺牲线程安全。
在 iOS 26 中,Apple 对 UISearchController 做出了两项重要改进:
iOS 26 中如果 UISearchController 集成在 UINavigationItem,默认情况下搜索栏会显示在底部,如果希望像之前在顶部显示,可以将 UINavigationItem 的preferredSearchBarPlacement
属性设置为UINavigationItem.SearchBarPlacement.stacked
。
import UIKit
class ViewController: UIViewController {
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "abc")
tableView.dataSource = self
tableView.rowHeight = 60.0
tableView.translatesAutoresizingMaskIntoConstraints = false
return tableView
}()
lazy var searchController: UISearchController = {
let controller = UISearchController(searchResultsController: nil)
controller.searchBar.searchBarStyle = .minimal
controller.searchBar.placeholder = "搜索学校"
return controller
}()
let schoolArray = ["清华大学", "北京大学", "中国人民大学", "北京交通大学", "北京工业大学",
"北京航空航天大学", "北京理工大学", "北京科技大学", "中国政法大学",
"中央财经大学", "华北电力大学", "北京体育大学", "上海外国语大学", "复旦大学",
"华东师范大学", "上海交通大学", "同济大学", "上海财经大学", "华东理工大学"]
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.largeTitle = "大学列表"
navigationItem.searchController = searchController
// 恢复成之前的顶部显示
navigationItem.preferredSearchBarPlacement = .stacked
}
}
// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return schoolArray.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "abc", for: indexPath)
cell.textLabel?.text = schoolArray[indexPath.row]
return cell
}
}
iOS 26 之前 UISearchController 只能出现在导航栏或者内容视图顶部,而现在可以将搜索栏直接放入 UIToolbar,打造一种更轻盈、紧凑的搜索体验。
import UIKit
class ViewController: UIViewController {
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "abc")
tableView.dataSource = self
tableView.rowHeight = 60.0
tableView.translatesAutoresizingMaskIntoConstraints = false
return tableView
}()
lazy var searchController: UISearchController = {
let controller = UISearchController(searchResultsController: nil)
controller.searchBar.searchBarStyle = .minimal
controller.searchBar.placeholder = "搜索学校"
return controller
}()
let schoolArray = ["清华大学", "北京大学", "中国人民大学", "北京交通大学", "北京工业大学",
"北京航空航天大学", "北京理工大学", "北京科技大学", "中国政法大学",
"中央财经大学", "华北电力大学", "北京体育大学", "上海外国语大学", "复旦大学",
"华东师范大学", "上海交通大学", "同济大学", "上海财经大学", "华东理工大学"]
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.largeTitle = "大学列表"
navigationItem.searchController = searchController
navigationController?.setToolbarHidden(false, animated: false)
// iOS26新增,允许将searchBar集成到UIToolbar
navigationItem.searchBarPlacementAllowsToolbarIntegration = true
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let addBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: nil)
let refreshBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: nil)
// 将searchBar集成到UIToolbar
toolbarItems = [addBarButtonItem, navigationItem.searchBarPlacementBarButtonItem, flexibleSpace, refreshBarButtonItem]
}
}
// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return schoolArray.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "abc", for: indexPath)
cell.textLabel?.text = schoolArray[indexPath.row]
return cell
}
}
在之前的《Flutter 完成全新 devicectl + lldb 的 Debug JIT 运行支持》我们提到,在 iOS 26 上为了更好的 Debug 体验,Flutter 在将开发和调试场景切换到了 devicectl + lldb ,从而支持 JIT 运行和 hotload,不过暂时这部分还在 master 没有 3.35 版本。
上述说的这个调整主要影响真机 Debug ,不会影响 Release 和模拟器。
所以 3.35 版本虽然也能在 iOS 26 上进行 Debug 开发,但是在 Xcode 26 的真机上的体验会相对较差,比如 timeout 和耗时是比较常见的情况。
但是最近的一些开发者里发现,它们在 iOS 26 模拟器上也“随机”出现无法运行的情况,运行时会出现 Unable to find a destination matching the provided destination specifie
这样的提示,而在之前的 iOS 18.6 模拟器又运行良好:
Uncategorized (Xcode): Unable to find a destination matching the provided destination specifier:
{ id:6B4F9D28-C76C-4146-9527-E844395B4434 }
Available destinations for the "Runner" scheme:
{ platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00006020-000221002EE8C01E, name:My Mac }
{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }
{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }
这是 iOS 26 模拟器和 Flutter 的适配问题吗?其实问题确实是适配导致,但是却不是 Flutter 的问题,而是一些插件和模拟器之间的适配问题,实际上问题是:
用的插件不支持 “ARM 模拟器”,而你默认使用的 iOS 26 模拟器只支持 ARM 。
而解决问题的方式也很简单,只需在 Mac 上安装 Rosetta ,然后从 Xcode 中移除 iOS 26 平台,然后运行以下命令:
xcodebuild -downloadPlatform iOS -architectureVariant universal
重新下载的会是具有通用架构支持的 iOS 26,而不仅仅是基于 Apple 的 ARM 架构默认配置:
所以,解决方案是强制 Xcode 下载 iOS 26 模拟器的“通用”版本,而不是默认的“Apple Silicon”,所以你首先要通过 Xcode
-> Settings
-> Components
-> iOS 26.0 info symbol
确定你的模拟器架构:
删除后重新下载“通用”模拟器,通过 xcodebuild -downloadPlatform iOS -architectureVariant universal
之后,就可以看到通用的 iOS 26 模拟器组件以及 Rosetta 模拟器:
当然,Rosetta 只能说是一个临时的解决方式,核心还是要看哪些插件仍然无法运行 ARM ,所以对于这个问题,更建议的是:
可以创建一个新的 Flutter 项目,然后逐个现在添加插件,看看哪些插件无法在 iOS 26 模拟器上运行,从而找出哪个插件配置错误,因为有可能只是老旧插件 ARCHS 配置错误,它不一定真的就不支持 arm64 。
所以这次的问题核心并不是 Flutter 的兼容问题,这也是为什么有的人发现,换了个电脑居然有可以跑的原因,主要是升级 Xcode16 之后模拟器重新安装后默认只支持 ARM 架构,如果你的插件之前配置或者设置并没有完全兼容,那么就会让问题暴露出来。
所以,这只是升级 iOS26 下的微小插曲,后面有时间再介绍更大的坑。
// 反引号可用于包含空格/数字/关键字等非常规标识符
struct Person {
var `full name`: String
var `class`: String // 关键字作为属性名
}
func `run task`(_ value: Int) -> Int { value * 2 }
let 🧭 = "north" // 扩展的可用字符示例(Emoji)
let p = Person(`full name`: "Ada Lovelace", `class`: "VIP")
let output = `run task`(21)
print(p.`full name`, p.`class`, 🧭, output)
??
的写法,插值默认值语义更直观、噪音更少。let nickname: String? = nil
let age: Int? = nil
print("Hi, \(nickname, default: \"Guest\")") // Guest
print("Age: \(age, default: 18)") // 18
let payload: [String: String]? = ["city": "Hangzhou"]
print("City: \(payload?["city"], default: \"Unknown\")")
InlineArray<N, Element>
,在栈上紧凑存储,具备更好的性能与缓存局部性,适合小容量、频繁创建/销毁的场景。var numbers: InlineArray<4, Int> = [1, 2, 3, 4]
var letters: InlineArray = ["A", "B", "C"]
var sum = 0
for n in numbers { sum += n }
print("sum =", sum) // 10
for (i, ch) in letters.enumerated() {
print(i, ch)
}
enumerated()
的返回类型在 6.2 起遵守 Collection
协议,可直接用于需要集合语义的 API(如 SwiftUI List
)。Array(...)
包装,减少不必要的分配与拷贝。let names = ["ZhangSan", "LiSi", "WangWu", "ZhaoLiu"]
// 直接在 enumerated() 上链式使用 Collection 能力(无需 Array(...) 包装)
let evenIndexed = names
.enumerated()
.filter { $0.offset % 2 == 0 }
.map(\.element)
print(evenIndexed) // ["ZhangSan", "WangWu"]
nonisolated
异步函数会在后台线程执行;6.2 起默认在调用者的 actor 上执行。@concurrent
:
Sendable
。actor SomeActor {
// CPU 密集型任务:后台并发执行,参数/返回须 Sendable
@concurrent
nonisolated func heavyCompute(_ input: [Int]) async -> Int {
input.reduce(0, +)
}
}
@MainActor
func demo() async {
let data = Array(0...1_000_00) // 10 万项
let result = await SomeActor().heavyCompute(data)
print("result =", result)
}
UIWindowScene.open(_:options:completionHandler:)
打开位于沙盒可访问位置的文件 URL。tmp/
)再打开,避免只读路径限制。UIWindowScene
;无前台场景时给出用户可理解的降级提示。success
与错误路径,必要时提示“缺少可处理此类型的 App”。import UIKit
final class OpenFileViewController: UIViewController {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let src = Bundle.main.url(forResource: "sample", withExtension: "zip") else { return }
openExternally(fileURL: src)
}
@MainActor
private func openExternally(fileURL: URL) {
let dst = URL.temporaryDirectory.appendingPathComponent(fileURL.lastPathComponent)
try? FileManager.default.removeItem(at: dst)
do {
try FileManager.default.copyItem(at: fileURL, to: dst)
} catch {
print("copy failed: \(error)")
return
}
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else {
print("no active scene")
return
}
scene.open(dst, options: nil) { success in
print(success ? "已交由系统/他端 App 打开" : "打开失败或无可用 App")
}
}
}
UIColor
新增 exposure
与 linearExposure
构造,UIColorWell
、UIColorPickerViewController
支持 HDR 选择。maximumLinearExposure
限定取色上限;吸管可通过 supportsEyedropper
控制。import UIKit
final class HDRColorViewController: UIViewController {
private lazy var colorWell: UIColorWell = {
let well = UIColorWell()
well.title = "HDR 背景"
well.maximumLinearExposure = 2.0
well.supportsEyedropper = false
well.addTarget(self, action: #selector(onColor), for: .valueChanged)
return well
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(red: 1, green: 0, blue: 0, alpha: 1, exposure: 2.5)
view.addSubview(colorWell)
colorWell.center = view.center
colorWell.sizeToFit()
}
@objc private func onColor() {
view.backgroundColor = colorWell.selectedColor
}
}
sliderStyle
与 trackConfiguration
支持内置刻度与自定义刻度,并可仅允许落在刻度上。numberOfTicks
快速生成均分刻度;或提供 ticks
自定义不均匀刻度。allowsTickValuesOnly=true
时配合手动“吸附”提升易用性。neutralValue
可以设定一个基准点,让进度条可以分成左右两个进度,类似音效均衡器中的默认值,或参数的零点enabledRange
定义滑块的有效范围,范围之外不可以交互import UIKit
final class TickedSliderViewController: UIViewController {
private lazy var slider: UISlider = {
let s = UISlider()
s.sliderStyle = .default
var cfg = UISlider.trackConfiguration = UISlider.TrackConfiguration(
allowsTickValuesOnly: true,
neutralValue: 0.5,
enabledRange: 0...1,
numberOfTicks: 11 // 0.0 ~ 1.0, 11 个刻度
)
cfg.allowsTickValuesOnly = true
s.trackConfiguration = cfg
s.addTarget(self, action: #selector(onChange), for: .valueChanged)
return s
}()
override func viewDidLoad() {
super.viewDidLoad()
slider.frame = CGRect(x: 20, y: view.center.y, width: view.bounds.width - 40, height: 44)
view.addSubview(slider)
}
@objc private func onChange(_ sender: UISlider) {
print(slider.value)
// 吸附到最近刻度,测试enabledRange时注释下面代码
let ticks: Float = 10
sender.value = round(sender.value * ticks) / ticks
}
}
preferredTransition = .zoom { ... }
并返回触发的 UIBarButtonItem
。zoomedViewController
类型正确。import UIKit
final class ZoomSourceVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .add, primaryAction: UIAction { [weak self] _ in
let next = UIViewController()
next.view.backgroundColor = .systemPink
next.preferredTransition = .zoom { context in
guard context.zoomedViewController === next else { return nil }
return self?.navigationItem.rightBarButtonItem
}
self?.present(next, animated: true)
})
}
}
UIButton.Configuration.glass()
UIButton.Configuration.clearGlass()
UIButton.Configuration.prominentGlass()
UIButton.Configuration.prominentClearGlass()
symbolContentTransition
import UIKit
final class GlassButtonViewController: UIViewController {
private let button = UIButton(type: .system)
override func viewDidLoad() {
super.viewDidLoad()
var cfg = UIButton.Configuration.prominentGlass()
cfg.title = "点赞"
cfg.image = UIImage(systemName: "hand.thumbsup")
cfg.preferredSymbolConfigurationForImage = .init(pointSize: 20, weight: .regular)
cfg.symbolContentTransition = UISymbolContentTransition(.replace, options: .speed(0.12))
button.configuration = cfg
button.isSymbolAnimationEnabled = true
button.addAction(UIAction { [weak self] _ in self?.toggle() }, for: .primaryActionTriggered)
button.frame = CGRect(x: 100, y: 200, width: 180, height: 56)
view.addSubview(button)
}
private func toggle() {
let filled = (button.configuration?.image == UIImage(systemName: "hand.thumbsup.fill"))
button.configuration?.image = UIImage(systemName: filled ? "hand.thumbsup" : "hand.thumbsup.fill")
}
}
final class GlassEffectViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
makeGlassContainers()
makeLiquidGlassExample()
}
func makeGlassContainers() {
// 创建容器 effect(多个 glass 元素会合并视觉)
let container = UIGlassContainerEffect()
let containerView = UIVisualEffectView(effect: container)
containerView.frame = view.bounds
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(containerView)
// 创建两个玻璃子项
func makeGlassElement(frame: CGRect, tint: UIColor) -> UIVisualEffectView {
let e = UIGlassEffect(style: .regular)
e.tintColor = tint
e.isInteractive = **false******
let v = UIVisualEffectView(effect: e)
v.frame = frame
v.layer.cornerRadius = frame.height / 2
v.clipsToBounds = **true******
return v
}
let a = makeGlassElement(frame: CGRect(x: 60, y: 200, width: 120, height: 50), tint: .systemBlue)
let b = makeGlassElement(frame: CGRect(x: 180, y: 200, width: 120, height: 50), tint: .systemPink)
containerView.contentView.addSubview(a)
containerView.contentView.addSubview(b)
}
// 在 UIViewController 中示例
**func** makeLiquidGlassExample() {
guard #available(iOS 26.0, *) else {
// 回退:普通模糊
let blur = UIBlurEffect(style: .systemMaterial)
let blurView = UIVisualEffectView(effect: blur)
blurView.frame = CGRect(x: 40, y: 120, width: 240, height: 72)
blurView.layer.cornerRadius = 12
blurView.clipsToBounds = true
view.addSubview(blurView)
return
}
// 创建玻璃 effect(可设置 style)
**let** glassEffect = UIGlassEffect(style: .regular)
glassEffect.tintColor = .systemGray5
glassEffect.isInteractive = true // 启用交互感
// 使用 UIVisualEffectView 承载 effect
let glassView = UIVisualEffectView(effect: glassEffect) // 先为 nil,稍后动画 materialize
glassView.frame = CGRect(x: 40, y: 120, width: 240, height: 72)
glassView.layer.cornerRadius = 12
glassView.clipsToBounds = true
// 在 contentView 添加内容(label 会自动成为 vibrant)
let label = UILabel(frame: glassView.contentView.bounds.insetBy(dx: 12, dy: 8))
label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
label.text = "Liquid Glass Button"
label.textAlignment = .center
label.textColor = .label
glassView.contentView.addSubview(label)
view.addSubview(glassView)
}
}
addSymbolEffect(.drawOn/.drawOff, options: .speed(_))
,搭配合适的 SymbolConfiguration
。import UIKit
final class SymbolDrawViewController: UIViewController {
private let imageView: UIImageView = {
let cfg = UIImage.SymbolConfiguration(pointSize: 96, weight: .thin)
return UIImageView(image: UIImage(systemName: "bolt", withConfiguration: cfg))
}()
override func viewDidLoad() {
super.viewDidLoad()
imageView.center = view.center
imageView.frame.size = CGSize(width: 160, height: 160)
view.addSubview(imageView)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
imageView.addSymbolEffect(.drawOff, options: .speed(0.12))
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
self.imageView.addSymbolEffect(.drawOn, options: .speed(0.12))
}
}
}
cornerConfiguration
以组合/胶囊/均匀/单边等方式定义圆角,且可被动画过渡。capsule()
、uniformCorners(radius:)
、corners(topLeftRadius:...)
、uniformEdges(leftRadius:rightRadius:)
。import UIKit
final class CornerConfigViewController: UIViewController {
private let demo = UIView(frame: CGRect(x: 140, y: 200, width: 120, height: 120))
override func viewDidLoad() {
super.viewDidLoad()
demo.backgroundColor = .systemBlue
demo.cornerConfiguration = .uniformCorners(radius: 12)
view.addSubview(demo)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
UIView.animate(withDuration: 1) {
self.demo.cornerConfiguration = .corners(topLeftRadius: 6, topRightRadius: 24, bottomLeftRadius: 24, bottomRightRadius: 6)
}completion: { _ in
UIView.animate(withDuration: 1) {
self.demo.cornerConfiguration = .capsule()
}completion: { _ in
UIView.animate(withDuration: 1) {
self.demo.cornerConfiguration = .uniformEdges(leftRadius: 25, rightRadius: 29)
}completion: { _ in
UIView.animate(withDuration: 1) {
self.demo.cornerConfiguration = .uniformEdges(topRadius: 12, bottomRadius: 20)
}
}
}
}
}
}
.flushUpdates
自动追踪 @Observable
数据或 AutoLayout 约束变更并添加动画,无需手动 layoutIfNeeded()
。UIViewPropertyAnimator
。import UIKit
@Observable final class Model { var bg: UIColor = .systemGray }
final class FlushUpdatesViewController: UIViewController {
private let box = UIView()
private var w: NSLayoutConstraint!
private var h: NSLayoutConstraint!
private let model = Model()
override func viewDidLoad() {
super.viewDidLoad()
box.backgroundColor = .systemRed
box.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(box)
w = box.widthAnchor.constraint(equalToConstant: 80)
h = box.heightAnchor.constraint(equalToConstant: 80)
NSLayoutConstraint.activate([
w, h,
box.centerXAnchor.constraint(equalTo: view.centerXAnchor),
box.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
view.backgroundColor = model.bg
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
UIView.animate(withDuration: 1.0, delay: 0, options: .flushUpdates) {
self.model.bg = .systemBlue
} completion: { _ in
_ = UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1.0, delay: 0, options: .flushUpdates) {
self.w.constant = 220
self.h.constant = 220
}
}
}
}
tabBarMinimizeBehavior = .onScrollDown
:向下滚动时仅保留首个 Tab 与搜索 Tab 图标,中部显示 UITabAccessory
。bottomAccessory
:为 TabBar 上方增设工具条等辅助视图。import UIKit
final class TabsVC: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
tabs.append(configTab(title: "聊天", image: "message", id: "chats"))
tabs.append(configTab(title: "通讯录", image: "person.2", id: "contacts"))
tabs.append(configTab(title: "发现", image: "safari", id: "discover"))
tabs.append(configTab(title: "我", image: "person", id: "me"))
tabs.append(configSearchTab(title: "搜索"))
selectedTab = tabs.last
tabBarMinimizeBehavior = .onScrollDown
bottomAccessory = UITabAccessory(contentView: UIToolbar())
}
private func configTab(title: String, image: String, id: String) -> UITab {
UITab(title: title, image: UIImage(systemName: image), identifier: id) { _ in
let vc = UIViewController()
let scroll = UIScrollView(frame: UIScreen.main.bounds)
scroll.backgroundColor = .secondarySystemBackground
scroll.contentSize = CGSize(width: UIScreen.main.bounds.width, height: 1600)
vc.view.addSubview(scroll)
return UINavigationController(rootViewController: vc)
}
}
private func configSearchTab(title: String) -> UISearchTab {
UISearchTab { _ in
let vc = UIViewController()
vc.view.backgroundColor = .systemBackground
return UINavigationController(rootViewController: vc)
}
}
}
UIMenu
。UIMainMenuSystem.shared.setBuildConfiguration
在运行时构建或替换菜单层级。import UIKit
final class MenuBarViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let config = UIMainMenuSystem.Configuration()
UIMainMenuSystem.shared.setBuildConfiguration(config) { builder in
let refresh = UIKeyCommand(input: "R", modifierFlags: [.command], action: #selector(self.refresh))
refresh.title = "Refresh"
refresh.image = UIImage(systemName: "arrow.clockwise")
builder.insertElements([refresh], beforeMenu: .about)
let sort = UIMenu(title: "Sort", children: [
UICommand(title: "By Name", action: #selector(self.sortByName)),
UICommand(title: "By Date", action: #selector(self.sortByDate))
])
builder.insertSibling(sort, afterMenu: .help)
}
}
@objc private func refresh() { view.backgroundColor = .systemTeal }
@objc private func sortByName() { view.backgroundColor = .systemGreen }
@objc private func sortByDate() { view.backgroundColor = .systemOrange }
}
UIViewController.updateProperties()
/ UIView.updateProperties()
用于不触发布局的轻量 UI 更新。layoutSubviews()
的更新。setNeedsUpdateProperties()
可手动请求一次更新;与 @Observable
协同可自动追踪。import UIKit
@Observable final class BannerModel { var text = "Hello"; var color: UIColor = .label }
final class UpdatePropsViewController: UIViewController {
private let label = UILabel()
private let model = BannerModel()
override func viewDidLoad() {
super.viewDidLoad()
label.font = .systemFont(ofSize: 40, weight: .bold)
label.textAlignment = .center
label.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 100)
label.center = view.center
view.addSubview(label)
}
override func updateProperties() {
super.updateProperties()
label.text = model.text
label.textColor = model.color
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
model.text = "iOS26"
model.color = .systemBlue
// 手动触发一次
setNeedsUpdateProperties()
}
}
@Observable
是一个 属性包装器(Property Wrapper) ,用于标记类或结构体,使其内部属性 自动可观察(observable) 。被标记的对象会自动生成 可监听的事件,UI 或其他订阅者可以自动响应属性变化,而不必手动发布通知。类似 Combine 的 @Published + ObservableObject,但不依赖 Combine,更加轻量和原生。@Observable
类实例属性变化,自动驱动 layoutSubviews()
/viewWillLayoutSubviews()
与 .flushUpdates
动画。Info.plist
添加 UIObservationTrackingEnabled=YES
。import UIKit
@Observable final class PhoneModel { var name: String; var os: String; init(name: String, os: String) { self.name = name; self.os = os } }
final class PhoneCell: UITableViewCell {
var model: PhoneModel? { didSet { setNeedsLayout() } }
private let nameLabel = UILabel(); private let osLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
[nameLabel, osLabel].forEach { contentView.addSubview($0) }
nameLabel.font = .boldSystemFont(ofSize: 22); osLabel.font = .systemFont(ofSize: 16)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
nameLabel.frame = CGRect(x: 20, y: 12, width: bounds.width - 40, height: 28)
osLabel.frame = CGRect(x: 20, y: 44, width: bounds.width - 40, height: 22)
nameLabel.text = model?.name; osLabel.text = model?.os
}
}
final class PhoneListVC: UIViewController, UITableViewDataSource {
private let table = UITableView(frame: .zero, style: .plain)
private let data = [
PhoneModel(name: "iPhone 16", os: "iOS 18"),
PhoneModel(name: "iPhone 16 Pro", os: "iOS 18")
]
override func viewDidLoad() {
super.viewDidLoad()
table.dataSource = self; table.rowHeight = 78
table.register(PhoneCell.self, forCellReuseIdentifier: "cell")
table.frame = view.bounds; view.addSubview(table)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.data[0].name = "iPhone 17"; self.data[0].os = "iOS 26"
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { data.count }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! PhoneCell
cell.model = data[indexPath.row]
return cell
}
}
userInfo
方式,提升线程/类型安全。MainActorMessage
与异步消息 AsyncMessage
。import UIKit
public final class NotifySubject { static let shared = NotifySubject() }
public struct TitleChanged: NotificationCenter.MainActorMessage {
public typealias Subject = NotifySubject
public static var name: Notification.Name { .init("TitleChanged") }
let title: String
}
final class TypedNotificationVC: UIViewController {
private var token: NotificationCenter.ObservationToken!
private let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
label.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 60)
label.center = view.center; label.textAlignment = .center; view.addSubview(label)
token = NotificationCenter.default.addObserver(of: NotifySubject.shared, for: TitleChanged.self) { [weak self] msg in
self?.label.text = msg.title
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
Task {
//子线程发送,在主线程收到
NotificationCenter.default.post(TitleChanged(title: "Updated"), subject: NotifySubject.shared)
}
}
deinit { NotificationCenter.default.removeObserver(token) }
}