普通视图

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

阿权的开发经验小集

作者 权咚
2025年9月16日 17:13

小集是日常开发中遇到问题的小结,或许可以帮助你少走一些弯路~

Git

跟踪上游分支

背景:执行 pull 没拉下新代码,但远端确实是有更新的。

目标:恢复与上游的同步。

git branch -u origin/branch_name

删除分支

目标:本地分支和远程分支一起删除。

# 删除本地分支
git branch -d localBranchName

# 删除远程分支
git push origin --delete remoteBranchName

空提交

背景:有些操作需要通过 git 提交记录的更新来触发,这里通过一个不影响代码的空提交触发。

git commit --allow-empty -m "Empty-Commit"

Tag vs. Branch

背景:Tag 和 Branch 在执行 git 命令时常常不需要显式说明,但当两者同名时就需要显式声明。

Tag 是记录一个 commit 点,Branch 是记录一个 commit 序列串。

  • branch 的特点是该分支的指针的位置随着提交不断更新,一般是存储在 refs/heads/
  • tag 的特点与分支恰恰相反,指向的 commit 不会随着新的提交去更新。一般是存储在 refs/tags/

git merge 可以合并 tag 或 branch。若出现 tag 和 branch 重名的 case,可以通过补全路径处理:

# push
git push origin :refs/heads/branch_name
git push origin :refs/tags/tag_name

# merge
git merge refs/heads/branch_name
git merge refs/tags/tag_name

回退合并

背景:执行了 merge 操作希望回退到 merge 前的 commit。

# 场景:合并操作还没完成,希望中断并回退到合并前的状态。
# 中断当前正在合并还没提交的分支的合并操作
git merge --abort

# 场景:合并操作已完成,甚至已经 push 到远程,希望回退到合并前的状态。
# 回退刚才已经提交的第一个合并
git reset --merge HEAD~1

# 若合并还没 push 到远程,经过上面操作后,分支可能会落后于远程分支,所以还要同步一遍,以确保跟远程分支同步。
git pull

# 场景:需要将本地的覆盖远程分支状态
# force push
git push --force
git push --force-with-lease # 更安全,会检查远程是否有新提交(有则拒绝 push)

Rebase vs. Merge

merge rebase
作用 创建一个新的 “合并提交”(merge commit),将两个分支的历史记录连接起来,保留双方完整的提交历史(包括分支的分叉和合并节点)。 将当前分支的所有提交 “移植” 到目标分支的最新提交之后,改写当前分支的提交历史,使历史呈现线性(无合并提交)。
优点 完整保留操作分支的所有提交历史,仅新增一个合并提交。遇到冲突只需解决一次。 历史记录整洁,合并后历史呈线性,没有多余的合并提交。rebase 过程中可以通过 --interactive(交互式)对提交进行压缩、修改、删除,让历史更清晰(例如将多个 “修复 bug” 的小提交合并为一个有意义的大提交)。
缺点 频繁合并会产生大量 “合并提交”,主分支历史可能出现很多分叉节点,长期来看难以快速理解项目演进脉络。不能对当前分支的提交进行压缩、修改(如需优化提交历史,需额外操作)。 rebase 会修改当前分支的提交哈希,因为提交被 “移植” 到了新的基础上。 甚至会修改提交顺序。可能需要多次解冲突,如果多个提交与目标分支有冲突,需要逐个提交解决冲突。修改提交顺序后会引入更多冲突。
选择 新手优先 整洁优先

选择:

多人共用一分支 单人单分支
保留完整分支历史 merge rebase
分支历史不重要 rebase rebase

为了平衡提交历史的简洁性与准确性(少点冲突),合并代码时可以这样做:先使用 rebase,遇到冲突时 abort 回退,切换为 merge,然后解冲突。

注意:主分支被团队所有人依赖,应尽可能使用 merge。

从分支维度:可以简单约定合并策略,主分支用 merge,功能分支用 rebase,来平衡两者的优缺点。

解冲突最佳实践

基本常识:

  • HEAD/ours 是指自己的改动;origin/theirs 是指上游的改动。

操作原则:

  • 只有是自己写的才应用自己的改动,否则应用上游的改动。
  • 存疑的(自己写的混合了他人写的,应用上游后编译不过)应保留两者的修改,对于不确定要丢弃的代码用段落注释(合并代码少用行注释)。

最佳实践:

  1. 处理资源/二进制文件,简单选择用自己的还是用上游的。
  2. 处理文本:以行甚至段落为单位,选用自己的还是上游的版本。
  3. 处理存疑文本修改:以行为单位,不选用自己和上游的版本,直接编写预期的文本。
  4. 对解完冲突的文件暂存修改(git add)。
  5. 解完 git 仓库本次所有冲突后,提交修改(git commit)。

推荐工具:vscode。使用 vscode 打开 git 仓库,并用其解冲突。

修改作者信息

背景:提交完代码了才发现用错了邮箱提交,例如:外部仓库使用了公司邮箱提交了代码,需要改用私人邮箱。

Git 会为每一次提交记录提交者的姓名和邮箱,这是本地 Git 配置的 “身份标识”,用于区分不同开发者的提交。

如何修改:

  1. git log 查看 commit id
  2. git rebase -i <最早commit> 重新设置基准线
  3. git commit --amend --author="Author Name autolinkemail@address.comautolink"来修改 commit
  4. ``git rebase --continue` 移动到下个 commit 作为基准线

例子:如当前历史为 A-B-C(HEAD),我想修改 B 和 C,这两个 commit 的作者。

  1. git rebase -i A,即要修改的最早提交的前一个节点。
    1. 如果想改 A 则使用git rebase -i --root
  2. pick 改为 edit。按 ESC,输入:wq。保存修改。
  3. 现在你已经开始可以修改,此时当前 commit 为 B。
  4. git commit --amend --author="Author Name autolinkemail@address.comautolink" 修改 B 的提交。
  5. git rebase --continue 定位到 C
  6. git commit --amend --author="Author Name autolinkemail@address.comautolink" 修改 C 的提交。
  7. git rebase --continue 修改已完成。
  8. git push -f 提交代码,大功告成。

文件修改检测不到

背景:本地文件有修改,但 Git 检测不到了。重启似乎就可以检测得到,但只能一次有效。

排查:去检查文件所在的路径,与 Git 识别的路径是否有大小写的差异。Git 区分大小写差异,但系统不区分,所以会有 gap,目录名只改了大小写,会导致一些奇怪的问题。

解决:把有大小写的路径段重命名。改大小写名称时,先重命名为临时名,再改为正确的大小写,分两次提交以避免文件系统的不识别。

iOS

留心延迟执行的代码

代码里看到延时执行要谨慎,非常可能是枯叶掩埋的陷阱。

延时执行可能是能解决作者提交时遇到的问题。但随着业务发展,可能后续那次修改后,延时执行就兜不住了。

  1. 首先自己不要写延时执行代码,不要期望延时能根治某个问题,延时能绕过的问题一般是执行时机、时序问题,应找到合适的时机执行逻辑。
  2. 其次看到别人写的延时代码要十分谨慎,可以先不去改别人写的延时代码,但尽可能不要依赖延时执行的时机做后续的逻辑,应自己找到合适的时机编写自己的代码。

主队列执行时序问题

public extension DispatchQueue {
    private static var token: DispatchSpecificKey<Void> = {
        let key = DispatchSpecificKey<Void>()
        DispatchQueue.main.setSpecific(key: key, value: ())
        return key
    }()
    
    static var isMain: Bool {
        DispatchQueue.getSpecific(key: Base.token) != nil
    }
    
    static func onMainQueue(_ work: @escaping @convention(block) () -> Void) {
        if isMain {
            work()
        } else {
            DispatchQueue.main.async(execute: work)
        }
    }
}

通过标记 DispatchQueue.main 队列可以准确判断当前执行的队列是否是主队列。使用 onMainQueue 方法可以确保让任务在主线程和主队列中执行。这个做法在不要求时序的场景下,确实是最保险的。要保证时序性,就要重新思考了。

主队列只能在主线程中执行。主线程是 runloop 机制,DispatchQueue.main.async 就是把任务(一段代码)放入到下一个 runloop 中执行。主线程还会执行其他队列,如在主线程中 sync 执行一个普通 serial queue,这个 queue 也是在主线程中执行,但就不是主队列了,上面的 isMain 方法会判断为 false。如果这种 case 在需要任务按照严格时序执行的场景下,就会出现时序错乱的问题。因为这里会把一些在主线程但不在主队列的任务错误地放置到下一个 runloop 中执行。

相反要考虑时序性,只需要使用 Thread.isMainThread 就能准确识别当前是否是主线程了,绝大数 UI 场景都适用。

public extension UtilExtension where Base: DispatchQueue {
    static func onMainThread(_ work: @escaping @convention(block) () -> Void) {
        if Thread.isMainThread {
            work()
        } else {
            DispatchQueue.main.async(execute: work)
        }
    }
}

使用锁,最终目的是为了解决竞态条件。

相关链接:

锁从基本原理上可分为互斥锁和自旋锁,其他类型的锁如:条件锁、递归锁、信号量,甚至是 GCD 的队列都是基于这两个基本锁的封装或扩展。

互斥锁 Mutex Lock 自旋锁 Spin Lock
原理 当线程尝试获取锁时,若锁已被占用,该线程会进入休眠状态(阻塞),直到锁被释放后被唤醒。 线程在获取锁失败时不会休眠,而是通过循环(忙等待)不断检查锁状态。
特性 互斥锁会休眠线程,避免了 CPU 空转,但涉及线程上下文切换,可能带来性能开销。适合高竞争或长时间持有。 自旋锁保持线程活跃,避免了上下文切换,但长时间等待会消耗 CPU 资源,适用于锁持有时间短的场景。适合短时间锁竞争。
具体实现 不可重入锁(非递归锁):线程必须释放锁后才能再次获取,否则会死锁。NSLockpthread_mutex(默认模式)可重入锁(递归锁) :允许同一线程多次获取同一锁而不死锁。NSRecursiveLock@synchronized条件锁:基于条件变量实现,线程需等待特定条件满足后才能继续执行。NSConditionNSConditionLock,需与互斥锁配合使用。 iOS 中早期使用OSSpinLock,但因优先级反转问题被废弃;现推荐使用os_unfair_lock作为轻量级替代。读写锁:允许并发读取(多个读线程),但写入时需独占资源。属于自旋锁的特殊形式,例如pthread_rwlock
信号量

通过计数器控制并发访问数量,底层可能依赖互斥锁实现,所以如果重入会死锁

class Lock {
    private let semaphore = DispatchSemaphore(value: 1)
    func lock() {
        semaphore.wait()
    }
    func unlock() {
        semaphore.signal()
    }
    @inline(__always)
    final func performLocked<T>(_ action: () -> T) -> T {
        self.lock(); defer { self.unlock() }
        return action()
    }
}
同步队列

同步队列通过在一个串行队列中执行操作,也可以实现资源安全访问。

同步执行在一段时间内不会切换线程,异步执行会切线程,但在队列执行的任务还是串行的。这个这个特性,可以实现异步锁。但就会发生上下文切换,即线程切换。

Xcode

手动安装模拟器

背景:新安装 Xcode 时总要额外下载一个与该 Xcode 版本匹配的模拟器,这个过程总是很久。可以试试手动下载。

  1. 官网下载 Xcode 对应的模拟器版本:developer.apple.com/download/al…
  2. 执行命令:
# 需要先选定操作的 Xcode
sudo xcode-select -s /Applications/Xcode_16.1.app
xcodebuild -runFirstLaunch
xcodebuild -importPlatform "$HOME/Downloads/iOS_18.1_Simulator_Runtime.dmg"

启动 Xcode 即可。

安装系统不支持的 Xcode 版本

背景:新系统不能打开旧 Xcode。

plutil -replace CFBundleVersion -string 30000 /Applications/Xcode.app/Contents/Info.plist

查找 setter

背景:希望找到某属性所有修改的地方。

可以将属性改写成计算属性,这样就可以单独查找 setter 的调用栈。

img

转码控制台中的 JSON

背景:Xcode 控制台输出 json 常常是转义过的,配合 vscode 可以还原出原始的 json。

拷贝到 vscode,结合 Text Power Tools 插件,使用 json 解析。

  1. 去除头尾到双引号。
  2. 右键:Text Power Tools > Encode/decode > Unescape JSON escaped text

Swift 语言

KVO 备忘

背景:Swift 的 KVO 语法常常检索不到。

// 定义可 KVO 监听的属性变量
@objc dynamic var myDate

// 监听,options 若不设置,change 的 oldValue、newValue 为 nil
observation = observe(\.objectToObserve.myDate, options: [.old, .new]) { object, change in
    print("myDate changed from: \(change.oldValue!), updated to: \(change.newValue!)")
}

Using Key-Value Observing in Swift | Apple Developer Documentation

枚举语义

  • enum 表达互斥的
  • 表达常量或常量表达式,其关联值都是常量,都需要构造的时候确定。
  • indirect 修饰 case 或 enum 可以在关联值使用自身类型,即表达递归语义。常用于常量表达式的表达。

特性:

  • 便捷的构造,直接点语法直接构建。类比到 struct/class 的静态方法/属性。
  • 关联值可忽略标签,直接用类型表达。

数组在遍历中删除元素

背景:遍历数组并删除元素一不小心就会数组越界。

可以通过以下方式规避:

  1. 使用高阶函数直接创建/修改一个符合条件的数组。如 filterremoveAll(where:)
  2. 反向遍历,可以安全地按索引删除元素,如 reversed()

枚举 raw value 不能是表达式

枚举 raw value 在定义的时候等号右侧不可以是表达式,而是一个字面常量,不可加条件。

读取大文件

可以使用 FileHandleInputStream 来读取大文件。

它们之间存在一些主要的不同:

  1. 使用方式InputStream 是基于流的,可以连续读取数据,这对于处理大文件或网络数据非常有用,因为你不需要一次性将所有数据加载到内存中。另一方面,FileHandle 允许你更精细地控制文件访问,例如,你可以选择从文件的任何位置开始读取或写入数据。
  2. 数据处理:使用 InputStream 时,你需要自己处理数据缓冲区的分配和释放。使用 FileHandle 时,你可以直接获取 Data 对象,而无需关心底层的内存管理。
  3. 可用性InputStream 可以处理来自各种来源的数据,如文件、网络数据或内存中的数据。而 FileHandle 主要用于文件操作。
  4. 错误处理InputStream 有一个 streamError 属性,可以用来检查在读取或写入过程中是否发生错误。FileHandle 的方法则会抛出异常,需要使用 trycatch 来处理。

InputStream 使用实例:github.com/gonsolo/gon…

guard let s = InputStream(fileAtPath: path) else {
    throw PbrtScannerError.noFile
}
stream = s
stream.open()
if stream.streamStatus == .error {
    throw PbrtScannerError.noFile
}
var bytes = Array<UInt8>(repeating: 0, count: bufferLength)
buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferLength)
buffer.initialize(from: &bytes, count: bufferLength)
bufferIndex = 0
bytesRead = stream.read(buffer, maxLength: bufferLength)

FileHandle 的使用:

let fileURL = URL(fileURLWithPath: "path/to/file")
if let fileHandle = try? FileHandle(forReadingFrom: fileURL) {
    let data = fileHandle.readData(ofLength: 12)
    // 处理读取到的数据
    fileHandle.closeFile()
}

省略 inout 参数

背景:inout 参数是不能设置默认值的,但有时候想让其成为可选参数。

把 inout 参数改成 UnsafeMutablePointer 类型可以做成像默认参数的省略用法,如:

func checkIfSupport(draft: Data, isSingle: inout Bool) -> Bool
func checkIfSupport(draft: Data, isSingle: UnsafeMutablePointer<Bool>? = nil) -> Bool

参考:option type - Swift optional inout parameters and nil - Stack Overflow

不建议在 extension 中重写

swift2 - Overriding methods in Swift extensions - Stack Overflow

使用 @objc 修饰的方法即使定义在 extension 中,也能被重写。@objc 可以直接修饰 extension。类似的,NSObject 子类定义的 objc 方法也可以在 extension 中重写。

// MARK: Override
extension ExportViewControllerNew {
    override var preferredStatusBarStyle: UIStatusBarStyle {
        .lightContent
    }
}

这样写会把方法暴露给 Runtime。

但不太建议这么做,似乎不太正统的方式。需要重写的方法还是应放到类的定义中。

Decodable 详细使用

定義 Decodable 的 init(from:) 解析 JSON | by 彼得潘的 iOS App Neverland | 彼得潘的 Swift iOS App 開發問題解答集 | Medium

我在想,为什么不用 ObjectMapper 呢?

weak 对象所在的作用域结束后还不销毁

对于 Swift 中的对象,其销毁时机与作用域有关,但不是唯一决定因素。对象的生命周期是由引用计数(reference counting)管理的。当一个对象的强引用计数降至零时,该对象会被销毁。以下是一些可能导致对象未在作用域结束时被销毁的情况:

  1. 强引用计数:当对象的作用域结束时,如果对象的强引用计数不为零,对象不会被立即销毁。这可能是因为在作用域外还有其他地方保持着对该对象的强引用。
  2. 强引用循环:当对象之间存在强引用循环时,即使它们的作用域已经结束,对象也不会被销毁。强引用循环会导致内存泄漏,因为对象互相保持强引用,使得它们的引用计数永远不会降至零。这时,需要使用 weakunowned 关键字来解决强引用循环问题。
  3. 延迟释放:Swift 使用自动引用计数(ARC)来管理内存。ARC 通常在对象不再需要时立即释放内存,但在某些情况下,ARC 可能会延迟释放对象。这种延迟释放可能会导致对象在作用域结束后仍然存在。

虽然作用域对于对象的销毁有一定影响,但对象的生命周期主要还是由引用计数管理。因此,在编写 Swift 代码时,需要特别注意避免强引用循环和内存泄漏。

获取代码位置

"\(#function) @\(#fileID):\(#line):\(#column)"

类判等

对于类实例,判断是否相同,可以简单以地址区分,使用 === 运算符比较。

if b === Test.self {
    print("yes")
} else {
    print("no")
}

ios - Comparing types with Swift - Stack Overflow

打印地址

有时候我们想直接打印对象的地址,可以这么做:

// 方式一
let s = Struct() // Struct
withUnsafePointer(to: s) {
    print(String(format: "%p", $0)
}

// 方式二
func printPointer<T>(ptr: UnsafePointer<T>) {
    print(ptr)
}
printPointer(ptr: &x)

// 方式三
///
/// http://stackoverflow.com/a/36539213/226791
///
func addressOf(_ o: UnsafeRawPointer) -> String {
    let addr = unsafeBitCast(o, to: Int.self)
    return String(format: "%p", addr)
}

func addressOf<T: AnyObject>(_ o: T) -> String {
    let addr = unsafeBitCast(o, to: Int.self)
    return String(format: "%p", addr)
}
  
// 方式三
Unmanaged.passUnretained(self).toOpaque()

参考:

获取磁盘空间

背景:快速获取与系统设置计算方式一致的剩余磁盘空间。

let fileURL = URL(fileURLWithPath:"/")
do {
    let values = try fileURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
    if let capacity = values.volumeAvailableCapacityForImportantUsage {
        print("Available capacity for important usage: \(capacity)")
    } else {
        print("Capacity is unavailable")
    }
} catch {
    print("Error retrieving capacity: \(error.localizedDescription)")
}

[SWIFT] Get available disk space w… | Apple Developer Forums

Checking Volume Storage Capacity | Apple Developer Documentation

return

背景:当想通过插入个 return 来提前中断代码,结果发现 return 后面的代码被执行了。

return 下一行接个表达式,下一行的表达式也会被执行。因此要避免这种情况应写成:

func returnInTheMiddle() {
  print("This is called as expected")
  return;
  print("This is called as well")
}

returnInTheMiddle()

Return keyword and following expression - Mateusz Karwat

因此 return 充当个截断的语句时,警告应该是这样的:

Code after 'return' will never be executed

而不是:

Expression following 'return' is treated as an argument of the 'return'

当然,有返回值的就不会出现上面的歧义。

didSet loop

背景:发现在 disSet 中调用 set 逻辑不会循环调用,但在 didSet 中调用一个方法,在其中调用 set 就会造成循环调用。

didSet 观察器会将旧的属性值作为参数传入,可以为该参数指定一个名称或者使用默认参数名 oldValue。如果在 didSet 方法中再次对该属性赋值,那么新值会覆盖旧的值。

按照上面的意思,隐含表达了在 didSet 中再次对属性赋值不会再触发 didSet,更不会陷入循环调用。但这也是仅限于 didSet 内,如下的 case,还是会陷入循环调用中:

class Manager {
    var isEnable: Bool = true {
        didSet {
            updateEnableState()
        }
    }
    
    func updateEnableState() {
        print("isEnable: \(isEnable)")
        isEnable = true
    }
}

let manager = Manager()
manager.isEnable = true

所以要进行属性值处理,需在 didSet 中完成,而不能新建一个方法。

另外,在构造方法中对属性赋值,也不会触发观察器的执行。

URL 语义化

不要直接使用 String 表达 URL 的组成部分以及解析 URL,而是使用这些类:URL、URLComponents、URLQueryItem。

你会发现 NSString 的“Working with Paths”章节的 API 在 String 上都移除了,这是因为这些 API 使用 URL 可以更准确地表达语义:

/// NSString Working with Paths
class func path(withComponents: [String]) -> String
var pathComponents: [String]
var lastPathComponent: String
var pathExtension: String
func appendingPathComponent(String) -> String
func appendingPathExtension(String) -> String?
var deletingLastPathComponent: String
var deletingPathExtension: String

扩展管理:使用“命名空间”

背景:扩展方法太多,希望对扩展方法归类拆分。

Swift 没有 C++ 的命名空间,但可以用类型仿照一个,实现访问权限的收拢。

下面代码对原本在 MediaContext 扩展的 maxWidth 方法转移到了 MediaContext.VideoWrapper。

// 建立个命令空间
private extension MediaContext {
    struct VideoWrapper {
        let base: MediaContext
    }
    
    var video: VideoWrapper {
        VideoWrapper(base: self)
    }
}

// 在命名空间内写扩展方法
private extension MediaContext.VideoWrapper {
    func maxWidth() -> CGFloat {
        max(base.contentWidth(of: .video, flag: .normal), base.globalContentWidth())
    }
}

使用:

class ClipController {
    let context: MediaContext
    
    func readWidth() {
        // 调用
        let width = context.video.maxWidth()
    }
}

结构体默认构造函数不能跨模块使用

结构体定义了属性,就会自动有个默认的按属性顺序的构造函数,但这个默认构造函数只能在结构体定义的 Module 中能访问,在别的 Module 无法访问,需显示声明。

Default initializer is inaccessible

获取类型信息

模块类名:

String(reflecting: type(of: receiver))

获取地址:

Unmanaged.passUnretained(receiver).toOpaque()

Error.localizedDescription

自己实现一个 Error 并实现 localizedDescription 属性,并不能正常调用。

struct StringError: Error {
    let content: String
    var localizedDescription: String { content }
}
print("错误".makeError().localizedDescription) // 会输出:"The operation couldn’t be completed. (InfraKit.StringError error 1.)"

defer

A defer statement is used for executing code just before transferring program control outside of the scope that the defer statement appears in.

即 deder 定义的代码在作用域结束的时候会调用。

从语言设计上来说,defer 的目的就是进行资源清理和避免重复的返回前需要执行的代码,而不是用来以取巧地实现某些功能。这样做只会让代码可读性降低。

defer 放在函数末尾相当于没写,应尽可能放在靠前的地方。

以前很单纯地认为 defer 是在函数退出的时候调用,并没有注意其实是当前 scope 退出的时候调用这个事实,造成了这个错误。在 ifguardfortry 这些语句中使用 defer 时,应该要特别注意这一点。

关于 Swift defer 的正确使用 | OneV's Den

另一方面,利用这个特性,把锁的加锁和解锁放在同一行是个比较不错的实践,这样作用域内(从该代码开始到作用域结束)的代码都加锁了,而且即使后面 guard 语句提前返回了,也不担心出现加锁了忘记解锁的问题。

locker.lock(); defer { locker.unlock() }

🔜

💬高频复用又经常忘记的代码

Hashable 实现

Hashable 继承于 Equatable,所以两者都要实现。

import Foundation

struct Person: Hashable {
    var name: String
    var age: Int

    // 实现 == 操作符
    static func == (lhs: Person, rhs: Person) -> Bool {
        return lhs.name == rhs.name && lhs.age == rhs.age
    }

    // 实现 hash(into:) 方法
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
        hasher.combine(age)
    }
}

let person1 = Person(name: "Alice", age: 30)
let person2 = Person(name: "Bob", age: 25)
let person3 = Person(name: "Alice", age: 30)

let peopleSet: Set<Person> = [person1, person2, person3]
print(peopleSet) // 输出: [Person(name: "Alice", age: 30), Person(name: "Bob", age: 25)]

💬调试

Swift 符号断点似乎要重新编译?

否则不生效?

img

💬注释

文档注释标记

一般规则:Tag: Content

/**
    两个整数相加
    # 加法(标题一)
    这个方法执行整数的加法运算。
    ## 加法运算(标题二)
    想加个试试看

    中间隔着一个横线
    ***

代码块的*使用*方法:
``(不用添加括号)`

        let num = func add(a: 1, b: 2)
        // print 3
    ``(不用添加括号)`

    - c: 参数一
    - d: 参数二
    - f: 参数三

    - Parameters:
        - a: 加号左边的整数
        - b: 加号右边的整数
    - Throws: 抛出错误,此方法不抛出错误,只为另外演示注释用法。
    - Returns: 和

    - Important: 注意这个方法的参数。
    - Version: 1.0.0
    - Authors: Wei You, Fang Wang
    - Copyright: 版权所有
    - Date: 2020-12-28
    - Since: 1949-10-01
    - Attention: 加法的运算
    - Note: 提示一下,用的时候请注意类型。
    - Remark: 从新标记一下这个方法。
    - Warning: 警告,这是一个没有内容的警告。
    - Bug: 标记下bug问题。
    - TODO: 要点改进的代码
    - Experiment: 试验点新玩法。
    - Precondition: 使用方法的前置条件
    - Postcondition:使用方法的后置条件
    - Requires: 要求一些东西,才能用这个方法。
    - Invariant: 不变的
 */
func add(a: Int, b: Int) throws -> Int {
    return a + b
}

更多:

代码冲突

使用段落注释可以避免一些代码合并的冲突,但同时也会让你容易忽略掉注释内容的变更。

💬泛型

范型类型不支持存储属性
Static stored properties not supported in generic types

所以想要在扩展中定义存储属性,要么放到具体的类中,要么定一个 fileprivate 的全局变量,再用一个计算属性中转一下(不推荐)。

泛型扩展声明

以下两种形式指定范型类型的扩展都支持且等价:

// 定义 UtilExtension 的 UIViewController 及其子类的泛型类型
extension UtilExtension<UIViewController> {}
extension UtilExtension where Base: UIViewController {}
容器元素类型不能为范型

背景:希望一个包含泛型实例的数组能声明为泛型类型的数组。

struct Car<T> {
    let p: T
}

let arr = [
    Car(p: 45),
    Car(p: "String"),
    Car(p: [1]),
] as [Any]

// 实际的类型
[
    Car<Int>(...),
    Car<String>(...),
    Car<Array<Int>>(...),
]

容器是范型的,其类型必须确定,Swift 不能识别不同的范型类型,这样只会被认为是 Any 类型,因为泛型的具体实例之间没有继承关系,也没有公共遵循的协议。

使用范型可以还原类型

相比使用协议,使用范型可以还原类型。示例:

func addTargetAction(for controlEvents: UIControl.Event, _ action: @escaping (Base) -> Void) -> RemovableControlTarget<Base>

换到 C++ 的概念,就把泛型理解为模板吧,具体使用泛型时,即确定泛型类型时,其实就是泛型定义的占位符(如:T)替换成具体的类型。

💬闭包

嵌套函数循环引用陷阱

函数在 Swift 中几乎等同于闭包,从调用的视角,函数除了可以使用参数名称、参数标签外,与闭包无异。如下代码的 ②③ 的定义就是等价的。嵌套函数定义和使用都很方便,但嵌套函数的自动捕获的机制容易造成循环引用。

var button: UIButton!

override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = .u.systemCyan
    
    // ② 嵌套函数,也会自动捕获 self
    func printButtonNested() {
        print("🚧 button: \(self.button!)")
    }
    
    // ③ printButtonNested 等同于定义个捕获实例变量的闭包常量
    let printButtonNested0 = { [self] in
        print("🚧 button: \(self.button!)")
    }
    
    // ④ 比较保险的是定义成弱引用捕获变量的闭包,使用 weak self 打破循环引用
    let printButtonClosure = { [weak self] in guard let self else { return }
        print("🚧 button: \(self.button!)")
    }
    
    let button = makeButton(title: "Tap", action: printButtonClosure)
    self.button = button
}

// ① 实例方法,自动捕获 self
func printButton() {
    print("🚧 button: \(self.button!)")
}

上面 makeButton 方法会将 action 传入的闭包让 button 持有,button 被 self 持有,若 action 传入闭包强捕获了 self,就会造成循环引用。

所以如果将上面的 ①②③ 传入 makeButton 方法都会造成循环引用,Xcode 不会给任何警告或报错。

最佳实践:对于要传递的函数/闭包,应如 ④ 这样定义成闭包,并使用捕获列表,弱引用声明需要捕获的值。类似的若需要捕获一些可能触发循环引用的的引用类型值,也需要在捕获列表中弱引用声明。

闭包中的 self 判断可能不会中断点
let updateSelectedSegmentIfNeeded = { [weak self] (new: LVMediaSegment) in
    guard let self = self else { return }
    guard panel.isShowing else { return }
    
    panel.disableAdjust()
    
    self.viewModel.updateSelectedSegment(new)
    panel.reset(dataSource: self.viewModel)    // reset后会自动enable adjust
}

闭包中的第一行 guard let self = self else { return } 可能不会中断点,需要对下一行下断点。这个情况在自定义 tool chain 中可能会比较常见。

@escaping 等价于 optional?

背景:以下代码都能通过编译,看起来用 Optional 包一下闭包就不用写 @escaping 了?

var actionHandler: (() -> Void)?

func a(action: @escaping () -> Void) {
    actionHandler = action
}

func b(action: (() -> Void)?) {
    actionHandler = action
}

function - Swift optional escaping closure parameter - Stack Overflow

Swift 如何给回调添加 @escaping 和 optional | Gpake's

可以理解为 Optional 把闭包包装成一个 enum,闭包已经不再是参数列表中了。所被包装的闭包成了 Optional enum 的关联值,其实是个枚举实例的成员了,跟属性类似,默认就是 eacaping。所以 Optional 的闭包已经是 escaping 语义了。

💬分支处理技巧

if/guard/case let/var

在所有分支语句中,包含 if/guard/switch,都可以用 let 创建一个符合条件的常量。

从 Swift 5.7 开始,if let a = a 的形式可以写成 if let a

注意 guard let/var 和 if let/var 在作用域上会有些细微的差别:

  • guard 创建的常量/变量作用域是当前行代码到结尾,可以覆盖前面的参数列表,但不能覆盖前面定义的常量/变量。
    • 但 else 里面不能访问 guard let 创建的常量。
  • if 创建的常量/变量作用域是后续紧接着的花括号,所以即使前后出现同名常量/变量也不会编译冲突。
if ↔︎ guard

guard 的语义:确保后续语句都是基于 guard 条件为 true 的前提。

实际使用中经常需要对 if 和 guard 相互转换:

// 对于提前退出的 case
guard condition else { return }
// 等同于
if !condition { return }

简单记忆:相同效果的语句,guard 和 if 后面的条件刚好相反

对于提前退出的 if 语句其实可以不改写成 guard,有些改写反而降低了可读性。例如表达“如果满足 A 条件就退出”,这样直接写成 if 就好;如果表达“确保后续的代码都满足 B 条件(否则退出)”,这样则考虑写成 guard 语句。

但嵌套的 if 语句改写成 guard 则有利于让代码更清晰。

带关联值枚举判等

背景:枚举只要有一个带关联值的 case,该枚举就不能使用 == 判等(除非该枚举实现了 Equatable)。

需修改判断方式:

if effectType == .prop
// ⬇️
if case .prop = effectType

具体实例:

// 未遵循 Equatable 的枚举
enum Message {
    case text(String)
    case attachment(name: String, size: Int)
    case timestamp(Date)
}

let message: Message = .attachment(name: "report.pdf", size: 10240)

// 1. 仅匹配枚举类型,忽略关联值
if case .attachment = message {
    print("这是一个附件消息") // 会执行
}

// 2. 匹配枚举类型并绑定关联值(可用于后续判断)
if case .attachment(let name, let size) = message {
    print("附件名:\(name),大小:\(size)") // 会执行
}

// 3. 匹配枚举类型并判断关联值条件
if case .attachment(_, let size) where size > 5000 {
    print("大附件(超过5000字节)") // 会执行
}
// 条件等同于:
// if case .attachment(_, let size), size > 5000 {

// 4. 完全匹配关联值(需手动判断)
if case .attachment(let name, let size) = message, 
   name == "report.pdf", 
   size == 10240 {
    print("匹配到指定附件") // 会执行
}

同时也应注意到,这样的表达式只能在 if/guard 后面使用,它不是个逻辑表达式,不能赋值到布尔量的。

How to compare enum with associated values by ignoring its associated value in Swift? - Stack Overflow

switch-case
作为右值

当然 if 语句也可以,多用于常量的定义。

let menuIdentifier: MenuIdentifier = switch entrance {
case .global: .effectRoot
case .video: .videoEffectRoot
case .subVideo: .subVideoEffectRoot
}
case let

case let 是创建变量,这其中用法很丰富。

可以做类型转换:

var imageData: Data? = nil
switch mediaAsset {
case let asset as ImageDataAsset:
    imageData = asset.data
    if let carttonImageFilePath = asset.cartoonFilePath, let cartoonImage = UIImage(contentsOfFile: carttonImageFilePath) {
        imageData = cartoonImage.pngData()
    }
case let asset as DraftImageAsset:
    imageData = asset.photo.resize(limitMaxSize: size).pngData()
case let asset as DataAsset:
    imageData = asset.data
default:
    break
} 

注意这里是直接使用 as 关键字,而不是 as?,与 if/gruard let 的变量定义有差别。

case range

做值域 case 划分,case 后可接 range,需要有个起点:

func calculateUserScore() -> Int {
    let diff = abs(randomNumber - Int(bullsEyeSlider.value))
    switch diff {
    case 0:
        return PointsAward.bullseye.rawValue
    case 1..<10:
        return PointsAward.almostBullseye.rawValue
    case 10..<30:
        return PointsAward.close.rawValue
    default:
        return 0
    }
} 

区间判断对类型为整型的就比较好处理,如果是浮点数,就不一定能满足需求,因为它不能表达 if value > 0.1 的语义,即至少有一个起点,这就要求这些 case 排列是从小到大排列。但也不是不行,如:

var progress: CGFloat!
switch CGFloat(progress) {
case 0 ... 0.25:
    barColor = .red
case 0.25 ... 0.5:
    barColor = .yellow
default:
    break
}

因为 case 0 占用了 0.25,所以 case 1 是不会匹配 0.25 的。

注意:分支判断需要覆盖所有值域。

💬Dictionary map

背景:批量修改字典 key、value;重建字典。

  1. 使用 mapValues(_:) 方法:
    1. 仅能修改值,过程中无法对 key 访问。
let dictionary = ["foo": 1, "bar": 2, "baz": 5]

let newDictionary = dictionary.mapValues { value in
    return value + 1
}
//let newDictionary = dictionary.mapValues { $0 + 1 } // also works

print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]
  1. 【不推荐】使用 map + init(uniqueKeysWithValues:)
    1. 会中间生成个 tuple array,需要多一步转换。
let dictionary = ["foo": 1, "bar": 2, "baz": 5]

let tupleArray = dictionary.map { (key: String, value: Int) in
    return (key, value + 1)
}
//let tupleArray = dictionary.map { ($0, $1 + 1) } // also works

let newDictionary = Dictionary(uniqueKeysWithValues: tupleArray)

print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]
  1. 【推荐】使用 reduce 方法:
    1. 通过元组的方式遍历整个字典,注意两个 reduce 方法的异同,根据使用场景来选择:
      • reduce(_:_:):闭包中每次都需要返回每次修改的片段值。
      • reduce(into:_:):【更推荐】闭包中直接对结果重新赋值,无须返回。
let dictionary = ["foo": 1, "bar": 2, "baz": 5]
let newDictionary = dictionary.reduce([:]) { (partialResult: [String: Int], tuple: (key: String, value: Int)) in
    var result = partialResult
    result[tuple.key] = tuple.value + 1
    return result
}
print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]

let dictionary = ["foo": 1, "bar": 2, "baz": 5]
let newDictionary = dictionary.reduce(into: [:]) { (result: inout [String: Int], tuple: (key: String, value: Int)) in
    result[tuple.key] = tuple.value + 1
}
print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]
  1. 另外起一个字典变量在遍历中重新赋值:
let dictionary = ["foo": 1, "bar": 2, "baz": 5]

var newDictionary = [String: Int]()
for (key, value) in dictionary {
    newDictionary[key, default: value] += 1
    //newDictionary[key] = value + 1
}

print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]

💬区间

关系

背景:如果准确表达区间

RangeExpression
    ClosedRange
    PartialRangeFrom
    PartialRangeThrough
    PartialRangeUpTo
    Range

# 闭合区间。表达:min <= value <= max。支持遍历。
struct ClosedRange<Bound> where Bound : Comparable
3...5 # 字面量,定义了运算符 ...
    // from Range
    init(Range<Bound>)

# 单侧区间。表达:min <= value。
struct PartialRangeFrom<Bound> where Bound : Comparable
5...

# 单侧区间。表达:value <= max。
struct PartialRangeThrough<Bound> where Bound : Comparable
...5.0

# 单侧区间。表达:value < max。
struct PartialRangeUpTo<Bound> where Bound : Comparable

# 半开区间。表达:min <= value < max。支持遍历。
struct Range<Bound> where Bound : Comparable
0.0..<5.0 # 字面量,定义了运算符 ..<
    # from NSRange
    init?(NSRange, in: String)
    init?(NSRange)
    // from CloseRange
    init(ClosedRange<Bound>)
使用场景
作为 Collection

ClosedRange、Range 都遵循 Collection 协议,可以作为集合使用。常见的用于遍历:

let range: ClosedRange = 0...10
print(range.first!) // 0
print(range.last!) // 10

let names = ["Antoine", "Maaike", "Jaap"]
for index in 0...2 {
    print("Name \(index) is \(names[index])")
}
// Name 0 is Antoine
// Name 1 is Maaike
// Name 2 is Jaap

当然,也可以转换成数组:

let intArray: [Int] = Array(min...max)
取集合子集
let names = ["Antoine", "Maaike", "Jaap"]
print(names[0..<names.count]) // ["Antoine", "Maaike", "Jaap"]
print(names[...2]) // ["Antoine", "Maaike", "Jaap"]
print(names[1...]) // ["Maaike", "Jaap"]

// 字符串会比较特别
let emojiText = "🚀launcher"
let endIndex = emojiText.index(emojiText.startIndex, offsetBy: 7)
let range: Range<String.Index> = emojiText.startIndex..<endIndex
print(emojiText[range]) // 🚀launch
与 NSRange 互转

背景:在字符串处理中,Range 经常要与 NSRange 相互转换,这是两个完全不同的结构体。

// Range -> NSRange
NSRange(range, in: title)

// NSRange -> Range
Range(nsRange, in: title)

具体应用:

public extension String {
    var nsRange: NSRange {
        NSRangeFromString(self)
    }
    
    /// Range<String.Index> -> NSRange
    func nsRange(from range: Range<String.Index>) -> NSRange {
        return NSRange(range, in: self)
    }
    
    /// NSRange -> Range<String.Index>
    func range(from nsRange: NSRange) -> Range<String.Index>? {
        return Range(nsRange, in: self)
    }
}

// 使用示例
let str = "测试转换 Range 和 NSRange"
if let subRange = str.range(of: "转换") {
    let nsR = str.nsRange(from: subRange)
    print("NSRange: location=\(nsR.location), length=\(nsR.length)")
    
    if let convertedRange = str.range(from: nsR) {
        print(str[convertedRange]) // 输出 "转换"
    }
}

注意:String 中的 NSRange 基本是 NSString 使用的,都是基于 UTF-16 编码单元。

// 下面两行代码等价
NSRangeFromString(self)
NSRange(location: 0, length: self.utf16.count)

🔜

🚩PromiseKit

设计思想借鉴
  • 异步/同步逻辑原子化。对一段逻辑封装,统一返回 Promise 泛型,可以让这部分逻辑更容易被外部集成、调用和线程切换。
    • 是 async await 的平替。
    • 逻辑封装方法中,甚至不用指定队列执行,可以在 then 等 API 调用时再切换执行的队列。
  • 同步转异步思路:把终点信号放到闭包返回出去。
  • 短路求值/最小化求值:遇到错误直接忽略后续代码,更安全、高效、易读。
    • 使用返回错误直接终止后续代码逻辑。
    • 链式调用中途的 promise 发生错误也直接终止后续 promise 任务的执行。
API 备忘
  • 提供的 API 大多在其 body 闭包参数中写逻辑,所以最简单使用 PromiseKit API 的方式就只关注 body 闭包的出参和入参即可。
  • API 都提供 on: DispatchQueue? = conf.Q.returnflags: DispatchWorkItemFlags? = nil 的入参,用于配置逻辑 body 闭包执行的队列。

API body 闭包签名:

# Promise
resolver: (Resolver<T>) throws -> Void
pipe: (Result<T>) -> Void

# Thenable
pipe: (Result<T>) -> Void
then: (T) throws -> U: Thenable
map: (T) throws -> U
compactMap: (T) throws -> U?
done: (T) throws -> Void
get: (T) throws -> Void
tap: (Result<T>) -> Void

# CatchMixin
catch: (Error) -> Void
recover: (Error) throws -> U: Thenable
recover: (Error) -> Guarantee<T>
recover: (Error) -> Void
ensure: () -> Void
ensureThen: () -> Guarantee<Void>
finally: () -> Void

# Guarantee
resolver: ((T) -> Void) -> Void
pipe: (Result<T>) -> Void
done: (T) -> Void
get: (T) -> Void
map: (T) -> U
then: (T) -> Guarantee<U>

不常用 API body 闭包签名:

# Thenable where T: Sequence
mapValues/flatMapValues: (T.Iterator.Element) throws -> U
compactMapValues: (T.Iterator.Element) throws -> U?
thenMap/thenFlatMap: (T.Iterator.Element) throws -> U
filterValues: (T.Iterator.Element) -> Bool

# Guarantee where T: Sequence
mapValues/flatMapValues: (T.Iterator.Element) -> U
compactMapValues: (T.Iterator.Element) throws -> U?
thenMap/thenFlatMap: (T.Iterator.Element) -> Guarantee<U>
filterValues: (T.Iterator.Element) -> Bool
sortedValues: (T.Iterator.Element, T.Iterator.Element) -> Bool

不用处理/可忽略返回值的接口:

catch -> PMKFinalizer
finally -> Void
cauterize -> PMKFinalizer # 用于消费/忽略掉 catch 中的错误处理

工具性接口:

firstly # 语法糖
DispatchQueue.global().async(.promise) # 直接切队列构造 Promise/Guarantee
race # 完成其中一个 Promise/Guarantee 就能获得结果
when # 完全全部 Promise/Guarantee 才能获得结果

所以总的来说,仅有这么几个关键词:

  • resolver:构建 Promise/Guarantee 时传递结果。
  • pipe:连接结果。
  • then:做下一步的异步任务,连接另一类型的 Thenable,即 Promise/Guarantee。
  • map/compactMap:成功结果值转换,与 then 的区别是返回值类型,而不是 Thenable。
  • done:无返回值的成功结果处理。与 catch 互斥。
  • catch:失败结果处理。与 done 互斥。
  • recover:修复/忽略/消费 部分/全部 错误。
  • ensure/finally:有结果就执行,无论是成功还是失败结果。
  • get/tap:旁路处理成功值,不影响流程。
  • racewhen:组合多个 Promise/Guarantee。
使用构造函数快速创建

快速创建:

func verify(completion: @escaping (()) -> Void) {}
func fetch(completin: @escaping (String) -> Void) {}

_ = Promise { verify(completion: $0.fulfill) }
_ = Guarantee { verify(completion: $0) }
_ = Guarantee { seal in
    verify {
        seal(())
    }
}
_ = Guarantee { fetch(completin: $0) }
抛错

then 闭包中返回 promise,若需中断/抛错,可以:

  • return Promise.init(error:):包装错误直接返回。
  • throw Error:个人更推荐。Swift 中更自然、通用的抛错语句。

上述的抛错相对于整个方法体/函数体来说也是短路求值,即不会执行语句后续的代码。相对比自己加个 failure: @escaping (Error) -> Void 闭包回调更加安全和易用。闭包调用不紧接 return 就造成范围之外的代码逻辑的执行。

扩展:在自己的封装的方法中,也可以加上(-> 前)throws 关键词使其成为 throwing 函数。日常在设计 API、逻辑时也多多使用 throw Error 的方式来抛错。外部使用时不需要处理错误则直接 try? func 忽略。

throwing 函数的优势:

  • 可以使用抛错来代替 return nil,这样定义函数返回值也更容易使用非 Optional 的类型。
  • 短路求值。
  • 外部调用可选地、规范地处理错误。
错误定义

一个 Service 可以定义一组错误(enum)。

也可以直接使用 PromiseKit 自身定义的错误:PMKError。

  • returnedSelf
  • badInput
  • cancelled

值得借鉴:定义错误时可遵循 LocalizedError 协议,提供 errorDescription 错误描述。可以借鉴 PMKError 同时实现 CustomDebugStringConvertible 和 LocalizedError 协议,更便于 lldb 输出。

忽略错误

Thenable 处理后返回的都是自身,即 Promise/Guarantee。Promise 链式调用一般都需要处理错误,若错误已在 recover 中或别处已处理,需要忽略错误处理环节,可使用 CatchMixin.cauterize() 代替 catch 语句。

切换执行的线程队列

PromiseKit API 都提供 on: DispatchQueue? = conf.Q.return,默认是主队列。要切换其他队列可直接传入 on 参数,如 .then(on: .global()) {}

插入旁路逻辑

对于一些不影响主流程链路的操作,如计时、埋点、log,我们不应直接在主流程链路中插入代码,可以使用 get/tap 旁路地插入代码,也方便移除和屏蔽。

常见编译报错

cannot conform to 'Thenable' when I try to use Promise functions

出现这样的错误大概率是用 then 拼接了不返回 Promise 的函数。解决方法也很简单:

They replaced that usage of then { } with done { }.

firstly {
    promiseGetJWTToken()
}.done { tokenJWT in
    // use your token
}

🚩ObjectMapper

ObjectMapper 最巧妙之处是用自定义运算符 <- 连接了属性和对应的解析方式,将赋值引用与属性类型通过运算符传递到解析方式中,避开了 Codable 还需要定义 CodingKey 的额外操作。

自定义解析

自定义解析的最佳时机是 BaseMappable.mapping(map:)

官方给出的自定义参数是在对 map 取下标时传入 TransformOf 实例,如:

let transform = TransformOf<Int, String>(fromJSON: { (value: String?) -> Int? in 
    // transform value from String? to Int?
    return Int(value!)
}, toJSON: { (value: Int?) -> String? in
    // transform value from Int? to String?
    if let value = value {
        return String(value)
    }
    return nil
})

id <- (map["id"], transform)

查看源码,其实还有更进阶的方式。

🔜 后续有空再展开

扩展支持 plist 序列化反序列化

源码中通过 Mapper 作为解析管理类,通过这个类,甚至可以添加一个扩展,支持 plist 的序列化和反序列化。

//  Mapper+PropertyList.swift

import Foundation
import ObjectMapper

public extension Mapper {
    // MARK: 反序列化
    
    static func parsePropertyList(data: Data) -> [String: Any]? {
        let parsed: Any?
        do {
            parsed = try PropertyListSerialization.propertyList(from: data, format: nil)
        } catch {
            print(error)
            parsed = nil
        }
        return parsed as? [String: Any]
    }
    
    func map(propertyList data: Data) -> N? {
        guard let parsed = Mapper.parsePropertyList(data: data) else { return nil }
        return map(JSON: parsed)
    }
    
    // MARK: 序列化
    
    static func toPropertyList(_ propertyListObject: Any, format: PropertyListSerialization.PropertyListFormat = .xml) -> Data? {
        guard PropertyListSerialization.propertyList(propertyListObject, isValidFor: format) else { return nil }
        let data: Data?
        do {
            data = try PropertyListSerialization.data(fromPropertyList: propertyListObject, format: format, options: 0)
        } catch {
            print(error)
            data = nil
        }
        return data
    }
    
    func toPropertyList(_ object: N, format: PropertyListSerialization.PropertyListFormat = .xml) -> Data? {
        let JSONDict = toJSON(object)
        return Mapper.toPropertyList(JSONDict as Any, format: format)
    }
}

public extension Mappable {
    init?(propertyList data: Data, context: MapContext? = nil) {
        guard let obj: Self = Mapper(context: context).map(propertyList: data) else { return nil }
        self = obj
    }
    
    func toPropertyListData(format: PropertyListSerialization.PropertyListFormat = .xml) -> Data? {
        Mapper().toPropertyList(self, format: format)
    }
}

使用:

let newSong: Song = makeModel(json: jsonText)!

// 序列化到 plist
guard let data = decodedSong.toPropertyListData() else { return }
print("🚧 song plist: \(String(data: data, encoding: .utf8))")

// 从 plist 反序列化
let songFromPlist = Song(propertyList: data)
dump(songFromPlist, name: "songFromPlist")

LLDB

类型转换
p import Lib
po unsafeBitCast(address, to: Type.self)
刷新 UI
e CATransaction.flush()
符号断点

系统 API 或闭源 API 断点需要下符号断点。

遇到 OC 接口,需要 OC 的符号。如:

PHImageManager.h:188
- (PHImageRequestID)requestPlayerItemForVideo:(PHAsset *)asset options:(nullable PHVideoRequestOptions *)options resultHandler:(void (^)(AVPlayerItem *__nullable playerItem, NSDictionary *__nullable info))resultHandler API_AVAILABLE(macos(10.15));

“Copy Symbol Name”或“Copy Qualified Symbol Name”
requestPlayerItemForVideo:options:resultHandler:

CocoaPods

新建文件

Xcode 文件区(project navigator)展示的目录有两种类型,在引入之初就决定了:

img

group folder reference
使用场景 最常用。代码、资源引入无脑选它。 蓝色图标。仅用于资源,如 bundle 资源。
细分 img对应目录的 Group:创建即创建本地同名目录。无对应目录的 Group:虚拟的目录,无对应的本地目录。图标左下角有小三角或箭头。可与其他 group 同名。
更新逻辑 外部更新不会同步。引入时目录文件的结构就确定了,后续文件在 Xcode 外部增删不会同步到 Xcode 中,需手动 add files。Pod install 之所以会更新 group 中内容是因为根据本地目录重建了 group。Xcode 内更新:对应目录的 Group:重命名会直接修改本地目录名。添加文件添加到对应目录中。无对应目录的 Group:可以随意重命名。添加文件会添加到项目根目录。 相互更新。

而 Pod 可能存在两种 Group。所以为了确保新建文件位置正确。新建文件直接在源文件对应目录创建文件,再引入。避免因为目录不在源码目录中而导致 pod install 后索引不到。

访问权限

Pod 作为 Swift Module,所以当设计的类是其他 Module 使用的,则一定要声明为 public!

UI

布局区域、响应区域、展示区域

一般来说,布局区域 = 响应区域 = 展示区域。即一般场景只要布局好视图,基本不用修改响应区域和展示区域,一旦要求响应区域、展示区域和布局区域不一致时,是时候将这三者解耦,单独考虑。

  • 布局区域:1:1 对应还原到设计稿。
    • 相关 API:auto layout、UIView.intrinsicContentSizeUIView.frameUIView.bounds
  • 响应区域:根据 UX 要求扩大或缩小。
    • 相关 API:UIView.point(inside:with:)UIView.hitTest(_:with:)
  • 展示区域:按照设计稿扩大或缩小。
    • 相关 API:UIView.clipsToBoundsUIView.maskCALayer.masksToBoundsCALayer.mask

通过修改对应 API 来修改对应的区域,三者相互独立解耦。

弹簧动画

usingSpringWithDampingUIView 的一个动画方法,用于创建一个弹簧动画。usingSpringWithDamping 方法接受两个参数:dampingRatioinitialSpringVelocity,分别用于指定弹簧动画的阻尼比和初始速度。

  • dampingRatio:阻尼比,用于指定弹簧动画的震荡程度,取值范围为 0.0 到 1.0。当阻尼比为 0.0 时,动画会无限振荡;当阻尼比为 1.0 时,动画会立即停止。建议值为 0.7 到 0.8,较小的值会使动画更加弹性,较大的值会使动画更加刚性。
  • initialSpringVelocity:初始速度,用于指定弹簧动画的初始速度,取值范围为任意值。初始速度为正数时,视图会向上移动;初始速度为负数时,视图会向下移动。建议值为 0,因为较大的值可能会导致动画过快或过慢。

以下是一个示例代码,演示如何使用 usingSpringWithDamping 方法来创建一个弹簧动画:

UIView.animate(withDuration: 1.0, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0, options: [], animations: {
    // 在此处设置视图的动画效果
    view.transform = CGAffineTransform(translationX: 0, y: 100)
}, completion: nil)

在上面的示例中,我们使用 usingSpringWithDamping 方法来创建一个弹簧动画,并将阻尼比设置为 0.7,初始速度设置为 0。在动画块中,我们将视图的 transform 属性设置为一个平移变换,使其向下移动 100 个像素。

需要注意的是,当我们使用 usingSpringWithDamping 方法时,我们需要根据实际情况来选择合适的阻尼比和初始速度。建议在实际开发中进行多次测试和调整,以达到最佳的动画效果。

TextView 根据内容自动增高

背景:希望根据用户输入内容的来实时更新 text view 高度布局。

didChange 回调中重新计算高度,然后更新 textView 高度布局。计算高度如:

let minHeight: CGFloat = Layout.TextView.minHeight
let maxHeight: CGFloat = Layout.TextView.maxHeight
let containerFrame = promptInputView.frame
if editText.isEmpty {
    return minHeight
} else {
    let constraintSize = CGSize(width: containerFrame.width, height: 1000)
    let size = promptInputView.textView.sizeThatFits(constraintSize)
    return min(max(size.height, minHeight), maxHeight)
}

maxHeight 用于实现把 text view 自动拉高到一个最大高度后,开始滚动内容。

ScrollView 居中

背景:让 scroll view 中的内容保持居中。

需要重新计算 cntent size 来设置 inset 实现居中。

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
    guard collectionView.numberOfSections == 1 else { return .zero }

    var viewPortSize = collectionView.bounds.size
    let contentInset = collectionView.contentInset
    viewPortSize.width -= contentInset.horizontal
    viewPortSize.height -= contentInset.vertical
    let count = collectionView.numberOfItems(inSection: 0)
    let contentWidth = CGFloat(count) * UI.itemSize.width + CGFloat(count - 1) * UI.itemSpacing
    let contentHeight = UI.itemSize.height
    var insets = UIEdgeInsets(inset: UIView.defaultOutlineWidth)
    if viewPortSize.width > contentWidth {
        insets.left = (viewPortSize.width - contentWidth) / 2
        insets.right = insets.left
    }
    if viewPortSize.height > contentHeight {
        insets.top = (viewPortSize.height - contentHeight) / 2
        insets.bottom = insets.top
    }
    return insets
}

监听页面页面过渡动画完成

背景:在页面 pod 动画完成后执行逻辑。

func dismissToPresent(completion: @escaping () -> Void) {
    guard let topVC = UIViewController.ibaseTopViewController else { return }
    if let vc = topVC.presentingViewController {
        CATransaction.begin()
        vc.dismiss(animated: false)
        let nav = vc as? UINavigationController ?? vc.navigationController
        nav?.popToRootViewController(animated: false)
        CATransaction.setCompletionBlock(completion)
        CATransaction.commit()
    } else {
        DispatchQueue.main.async(execute: completion)
    }
}

设置行高

背景:自定义行高。

通过配置 NSMutableParagraphStyle 到富文本的 paragraphStyle 中:

func makeText(_ text: String, font: UIFont, lineHeight: CGFloat, color: UIColor) -> NSAttributedString {
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.lineSpacing = 0
    paragraphStyle.maximumLineHeight = lineHeight
    paragraphStyle.minimumLineHeight = lineHeight

    return NSAttributedString(string: text, attributes: [
        .paragraphStyle: paragraphStyle,
        .foregroundColor: color,
        .font: font,
    ])
}

叠加与遮罩

overlay:

  • 叠加效果。
  • 只能加,不能减。

mask:

  • 切除某部分,或让某部分变得透明。
  • 只能减,不能加。
颜色叠加

同色叠加,底部纯色,叠层透明度不同看不出效果。

#FFFFFF33 = #FFFFFFFF - #000000CC # 顺序是从底往上

叠加是减法?越叠越暗。

CGMutablePath

CGMutablePath add arc 会接上之前线段的末尾,若是想画一段一段的圆弧,可能不符合预期,需要再添加 move 逻辑。

CALayer 似乎不能重写构造函数

视图不展示问题排查

可按照以下思路排查:

  1. 对象不在视图层级中(可能没 addSubview):lookin 找到对应的 view 对象。
  2. 视图隐藏:alpha == 0isHidden == true
  3. frame 是否正常:
    1. w/h 为 0 都会表现为视图不展示。
    2. 超出父视图可能会被裁切。
  4. 确定是否有 mask:mask alpha 为 0 也会导致不展示。

获取 icon 名称

在 lookin 中定位到 UIImageView,输出其 image 属性,即可在描述中看到 icon 名称。

<UIImageView: 0x2aea83990> image
<UIImage:0x281b19b00 named(org.cocoapods.LVEditor: ic_none_d) {20, 20} renderingMode=automatic>

storyboard 不支持 Swift 嵌套类型

img

storyboard 设置 Class 时不支持 Swift 的嵌套类型,且必须勾选“Inhert Module From Target”,否则将出现以下错误:

[Storyboard] Unknown class _TtC5UILab22PageDataViewController in Interface Builder file.

storyboard/xib 这套 GUI 布局应该也是差不多要退出历史舞台了。

找到焦点视图

找到当前处于焦点的视图,可对当前 UIWidnow 对象调用 firstResponder 扩展方法:

public extension UIView {
    /// SwifterSwift: Recursively find the first responder.
    func firstResponder() -> UIView? {
        var views = [UIView](arrayLiteral: self)
        var index = 0
        repeat {
            let view = views[index]
            if view.isFirstResponder {
                return view
            }
            views.append(contentsOf: view.subviews)
            index += 1
        } while index < views.count
        return nil
    }
}

// 判断当前是否是焦点视图
xx == window?.firstResponder

SVG 路径绘制

一些走过的弯路:

最佳实践:

SVG Converter,直接从文本编辑器打开 SVG,把其中的 viewBox 和路径参数拷贝出来,到这个网站进行转换。

备选方案:

参考 SVGKit 源码,使用代码直接从 SVG 读取并生成路径。

其他第三方组件:

UIView drawRect 透明

需要额外设置 UIView 的 backgroundColor 属性为 .clear,单单在 drawRect 方法中做操作是做不到的。

💬UITableView

通过 auto layout 自适应高度

UITableViewCell 直接与 contentView 添加布局约束即可实现自适应高度。难搞的是 UITableView 的其他子部件。

header view 的特殊处理

header view 本身是不支持自动布局的,所以要特殊处理一番。

func setAndLayoutTableHeaderView(header: UIView) {
    self.tableHeaderView = header
    self.tableHeaderView?.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        header.widthAnchor.constraint(equalTo: self.widthAnchor)
    ])
    header.setNeedsLayout()
    header.layoutIfNeeded()
    header.frame.size =  header.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
    self.tableHeaderView = header
}

或者在 layoutSubviews 中更新:

func updateTableHeaderSize() {
    if let topView = tableHeaderView {
        let targetSize = bounds.size
        topView.frame.size = topView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
    }
}
reuse view

主要是解决 UIView-Encapsulated-Layout-Width 和 UIView-Encapsulated-Layout-Height 问题。

所以,基本的解法是降低发生冲突方向布局的优先级。但这样会有不确定性,不确定 break 掉的约束会是什么效果。

另一种方案是配置约束时考虑 width 会变成 0 的 case,确保各种约束(如缩进)不会导致某个 view 的 width 为负数。然后在 layoutSubviews 方法中更新约束到目标效果,或干脆直接重建约束。

💬UITableViewCell

设置背景色

backgroundColor 无效时,设置 backgroundView。目前发现 UITableViewHeaderFooterView 子类设置 backgroundColor 无效。

取消高亮
selectionStyle = .none

不行的话,在 prepareForReuse 中也设置下。

💬UIView 生命周期

获得上屏 view
  1. init + main.async

要获得显示在屏幕上的 View,最简单粗暴的方式是在初始化的位置,加个 DispatchQueue.main.async 闭包。

优点:

  • 确保只执行一次

缺点:

  • 不确定是否真的布局完成;
  • 只适合那种初始化就配置好视图的情况。
  1. didMoveToWindow

另外,还可以在 didMoveToWindow() 方法中写相关的逻辑,这时的 next responder 是能拿到的。

优点:

  • 确保已经添加到视图。
  • 视图可以在任意时机布局。

缺点:

  • 可能会执行多次。

💬布局

UIView 如何防止被挤压

UIView 可以通过设置抗压缩和抗拉伸属性来防止被挤压。抗压缩属性表示视图不想缩小到比其内容更小的程度,而抗拉伸属性表示视图不想被拉伸到比其内容更大的程度。可以使用setContentCompressionResistancePriority(_:for:)方法设置抗压缩属性,使用setContentHuggingPriority(_:for:)方法设置抗拉伸属性。这些方法都需要传入一个优先级参数,优先级越高,视图越不容易被压缩或拉伸。默认的优先级为 750 和 250,可以通过设置更高的优先级来防止视图被挤压。

例如,如果您想防止一个 UILabel 的内容被压缩,可以使用以下代码:

label.setContentCompressionResistancePriority(.required, for: .horizontal)

如果您想防止一个 UIView 被拉伸,可以使用以下代码:

view.setContentHuggingPriority(.required, for: .horizontal)

请注意,这些方法只适用于使用 Auto Layout 进行布局的视图。如果您使用的是 Autoresizing Mask,则可以使用autoresizingMask属性来设置视图的自动调整大小行为。

setContentHuggingPriority(_:for:)setContentCompressionResistancePriority(_:for:) 是 Auto Layout 中非常重要的两个方法,它们可以用来控制视图的自适应大小。以下是更详细的介绍和效果:

setContentHuggingPriority(_:for:)

setContentHuggingPriority(_:for:) 方法用于设置视图的抱紧优先级。抱紧优先级决定了视图在自适应大小时的最小大小限制。具体来说,它控制了视图在拉伸时的行为。

  • UILayoutPriority.required:视图的大小必须等于或大于其内容的最小大小。这是默认的优先级。
  • UILayoutPriority.defaultHigh:视图的大小可以小于其内容的最小大小,但不能小于其他具有较低抱紧优先级的视图。
  • UILayoutPriority.defaultLow:视图的大小可以小于其内容的最小大小,并且可以小于其他具有较高抱紧优先级的视图。

例如,在一个水平方向的 UIStackView 中,如果一个视图的抱紧优先级设置为 .required,则它的宽度不会小于其内容的最小宽度。如果一个视图的宽度抱紧优先级设置为 .defaultLow,则它的宽度可以更小,以适应其父视图的大小。

setContentCompressionResistancePriority(_:for:)

setContentCompressionResistancePriority(_:for:) 方法用于设置视图的压缩阻力优先级。压缩阻力优先级决定了视图在自适应大小时的最大大小限制。具体来说,它控制了视图在压缩时的行为。

  • UILayoutPriority.required:视图的大小必须等于或大于其内容的最小大小。这是默认的优先级。
  • UILayoutPriority.defaultHigh:视图的大小可以小于其内容的最小大小,但不能小于其他具有较低压缩阻力优先级的视图。
  • UILayoutPriority.defaultLow:视图的大小可以小于其内容的最小大小,并且可以小于其他具有较高压缩阻力优先级的视图。

例如,在一个水平方向的 UIStackView 中,如果一个视图的压缩阻力优先级设置为 .required,则它的宽度不会小于其内容的最小宽度。如果一个视图的宽度压缩阻力优先级设置为 .defaultHigh,则它的宽度可以更小,以适应其父视图的大小。

需要注意的是,抱紧优先级和压缩阻力优先级通常是成对使用的,以确保视图在自适应大小时的行为符合预期。例如,在一个水平方向的 UIStackView 中,一个视图的抱紧优先级设置为 .required,压缩阻力优先级设置为 .defaultHigh,则它的宽度在拉伸时会尽可能地保持其内容的最小宽度,而在压缩时会尽可能地保持其内容的最大宽度。

参考资料:

  1. AutoLayout - 内容压缩阻力(Content Compression Resistance)和内容吸附(Content Hugging)
  2. UIView.AutoresizingMask
  3. setContentCompressionResistancePriority(_:for:)
  4. setContentHuggingPriority(_:for:)

若出现没有自动跟随尺寸变化,检查确保全部使用了 equalTo!!!

布局更新时机
  1. layoutSubviews

放心在这里更新 auto layout 的约束常量,这不会出发循环调用。

  1. didMoveToWindow

这是 UI 更新布局的最晚时机,这时 superview、responder 都已经有值,但这时 auto layout 可能还没完成布局,要获得 auto layout 后到布局可以在下一次 runloop 中获取。

这个方法调用时机很巧妙,当 view appear/disappear 的时候也会被调用,因为这时的 window 对象会置为 nil,这时就可以把 controller 生命周期的事情归还到 UIView 中来做。

获取自动布局后的 frame
  1. 强制布局

调用 setNeedsLayout() + layoutIfNeeded(),触发同步布局。然后获取 view 的 frame。

  1. 获得布局后的尺寸

调用 systemLayoutSizeFitting(_:) 方法,获取基于当前约束的视图的最佳大小。该方法只是做计算而已,并没有进行布局。

targetSize:偏好的视图尺寸。要获得尽可能小的视图,设置为 UIView.layoutFittingCompressedSize。要获得尽可能大的视图,则设置为 UIView.layoutFittingExpandedSize

label.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)

另外,使用 UIView.sizeThatFits 也可达到同样的效果。

label.sizeThatFits(.zero)

注意这里返回的是 CGSize。

自动布局更新

要控制局部 UI,尽量使用 UIStackView 和约束常量(NSLayoutConstraint.constant)来实现布局更新,而不是使用 snp.remakeConstraints。而 snp.updateConstraints 更不建议使用,因为需要了解之前是怎么布局的,也是只能更新约束常量,且跟之前的布局强强耦合,容易出错,不好维护。

不要尝试给系统的 layout guide 添加约束

UILayoutGuide 的作用如其名,是布局参照,如画图时的辅助线。当使用 layout guide 编写布局约束时,应永远把 layout guide 作为宾语,而不是主语。

let contentGuide = scrollView.contentLayoutGuide
// 不能这样做!!
contentGuide.snp.makeConstraints { make in
    make.edges.equalTo(label)
}

// 而是改成这样
label.snp.makeConstraints { make in
    make.edges.equalTo(contentGuide)
}

如果是自建的一个 layout guide,则可以且优先作为主语进行布局,即先画好辅助线,再使用辅助线布局其他视图。

macOS

命令行工具执行异步代码

相关链接:

大概有几种方式:

  • 阻塞进程,让其不退出。
  • run in main runloop.

使用信号量阻塞:

var semaphore = DispatchSemaphore(value: 0)
runAsyncTask { // 完成回调
    // 释放,退出
    semaphore.signal()
}
// 阻塞不退出
semaphore.wait()

使用 runloop,run in main runloop:

//...your magic here
// add a little 🤓iness to make it fun at least...
RunLoop.main.run(until: Date() + 0x10)  //oh boi, default init && hex craze 🤗
// yeah, 16 seconds timeout

// or even worse (!)
RunLoop.main.run(until: .distantFuture)

dispatchMain:

runAsyncTask { // 完成回调
    // 退出
    exit(EXIT_SUCCESS)
}

// Run GCD main dispatcher, this function never returns, call exit() elsewhere to quit the program or it will hang
dispatchMain()

TipKit与CloudKit同步完全指南

作者 CodingFisher
2025年9月16日 17:01

TipKit与CloudKit同步完全指南

iOS 18为TipKit框架引入了CloudKit同步支持,使应用中的功能提示(Tips)状态能够在用户的所有设备间同步。这意味着用户在一台设备上查看或关闭提示后,无需在其他设备上重复操作,大大提升了用户体验的一致性。

1. TipKit与CloudKit同步的核心价值

TipKit是一个强大的框架,它让开发者能轻松地在应用中创建和管理功能提示,向用户介绍新特性或更高效的操作方式。在iOS 18之前,提示的状态(如是否显示或关闭)仅存储在本地设备上。借助CloudKit同步,这些状态现在可以跨设备共享。

实现同步的好处包括

  • 统一的用户体验:用户在不同Apple设备上使用你的应用时,提示的显示状态保持一致,避免重复打扰。

  • 基于跨设备事件的提示:提示的显示规则可以依赖来自多台设备的事件(例如,用户在iPhone上执行了某个操作,提示随后也可以在iPad上显示)。

  • 高效的状态管理:TipKit自动处理同步逻辑,开发者无需手动管理复杂的状态同步过程。

2. 同步配置详解

实现TipKit与CloudKit的同步需要进行一系列的配置和编码工作。

2.1 在Xcode中启用iCloud与CloudKit

首先,需要在Xcode项目中启用iCloud和CloudKit能力。

  1. 打开项目设置:在Xcode中,选择你的项目文件,进入 "Signing & Capabilities" 标签页。

  2. 添加iCloud能力:点击 "+ Capability" 按钮,选择 "iCloud"

  3. 配置CloudKit

  • 在添加的iCloud功能中,确保 "CloudKit" 选项被勾选。

  • "Containers" 部分,你可以选择使用默认容器,或者更推荐的是,点击 "+" 按钮创建一个新的专用容器。Apple建议为TipKit同步创建一个标识符以 .tips 结尾的新容器(例如 iCloud.com.example.MyApp.tips),这有助于与应用的其他iCloud数据隔离,避免潜在冲突。

  1. 启用后台模式:为了确保TipKit能在后台处理远程同步事件,需要启用后台模式。
  • 再次点击 "+ Capability" 按钮,添加 "Background Modes"

  • 在后台模式中,勾选 "Remote notifications"。这使得App可以静默地接收CloudKit数据变化的通知。

2.2 配置Tips数据存储库

在应用的启动阶段(通常在 AppDelegate 或应用的初始 View 中),需要配置 Tips 库以使用CloudKit容器。


import TipKit

import SwiftUI

  


@main

struct MyApp: App {

init() {

// 配置TipKit数据存储库

do {

try Tips.configure {

// 设置CloudKit容器选项,使用你创建的容器标识符

[Tips.ConfigurationOption.cloudKitContainer("iCloud.com.example.MyApp.tips")]

}

} catch {

print("Failed to configure TipKit: \(error)")

}

}

  


var body: some Scene {

WindowGroup {

ContentView()

}

}

}

代码说明:此Swift代码在应用启动时初始化TipKit,并通过 cloudKitContainer 选项指定了用于同步的CloudKit容器。

2.3 处理与Core Data的共存问题

如果你的应用同时使用 Core Data with CloudKit(通过 NSPersistentCloudKitContainer),需要特别注意容器冲突问题。

  • 问题NSPersistentCloudKitContainer 默认会使用 entitlements 文件中列出的第一个iCloud容器标识符。如果TipKit也尝试使用这个默认容器,可能会导致数据混乱或同步冲突。

  • 解决方案:正如Apple所建议,为TipKit创建一个独立的、专用的容器(标识符以 .tips 结尾),并将其与Core Data使用的容器明确分开。这样能确保应用数据和提示状态数据在iCloud中清晰隔离,互不干扰。

3. 深入TipKit核心概念与代码实践

要有效利用同步功能,需要理解TipKit的几个关键概念。

3.1 创建提示(Tips)

提示是通过定义符合 Tip 协议的结构体来创建的。你可以配置标题、信息、图片、规则和操作。


import TipKit

  


// 定义一个提示,用于介绍指南针的点击功能

struct ShowLocationTip: Tip {

var title: Text {

Text("显示您的位置")

}

  


var message: Text? {

Text("点击指南针可在地图上高亮显示您当前的位置。")

}

  


var image: Image? {

Image(systemName: "location.circle")

}

  


// 定义显示规则:例如,当某个参数为true时显示

@Parameter

static var showTip: Bool = true

  


var rules: [Rule] {

// 此规则要求 ShowLocationTip.showTip 参数为 true 时才显示提示

[#Rule(Self.$showTip) { $0 == true }]

}

}

代码说明:此代码段创建了一个简单的提示,包含标题、信息、图片和一条基于布尔参数的显示规则。

3.2 使用提示组(TipGroups)控制显示顺序

TipGroup 允许你将多个提示分组,并控制它们的显示顺序和优先级。


import SwiftUI

  


struct CompassView: View {

// 创建一个有序的提示组,包含两个提示

@State private var compassTips: TipGroup = TipGroup(.ordered) {

ShowLocationTip() // 先显示这个提示

RotateMapTip() // 只有在第一个提示失效后,这个才会显示

}

  


var body: some View {

CompassDial()

// 使用提示组的 currentTip 来显示当前该显示的提示

.popoverTip(compassTips.currentTip)

.onTapGesture {

// 执行操作...

// 然后使提示失效

ShowLocationTip.showTip = false // 使基于参数的规则失效

// 或者通过 Tip 实例无效化

// ...

}

}

}

  


// 第二个提示:旋转地图

struct RotateMapTip: Tip {

var title: Text {

Text("重新定向地图")

}

var message: Text? {

Text("长按指南针可将地图旋转回北纬0度。")

}

var image: Image? {

Image(systemName: "hand.tap")

}

}

代码说明:此代码展示了如何创建和使用 TipGroup 来管理两个提示(ShowLocationTipRotateMapTip)的显示顺序。ordered 优先级确保第二个提示只有在第一个提示失效后才会显示。

3.3 自定义提示标识符以实现重用

通过覆盖提示的 id 属性,你可以基于不同内容创建可重用的提示模板。


struct TrailTip: Tip {

// 自定义标识符,基于路线名称,使每个路线提示都有独立状态

var id: String {

"trail-\(trail.name)"

}

  


let trail: Trail // 自定义的Trail模型

  


var title: Text {

Text("发现新路线: \(trail.name)")

}

  


var message: Text? {

Text("这条新路线位于 \(trail.region)。")

}

  


// ... 其他属性和规则

}

  


// 在使用时,为不同的Trail实例创建不同的TrailTip

ForEach(trails) { trail in

TrailListItemView(trail: trail)

.popoverTip(TrailTip(trail: trail))

}

代码说明:通过自定义 id 属性,TrailTip 结构体可以根据不同的 trail 实例生成具有唯一标识符的提示。这使得同一个提示结构可以用于多个不同的内容(不同路线),且每个提示的状态(显示、关闭)在CloudKit中都是独立管理和同步的。

3.4 自定义提示视图样式(TipViewStyle)

你可以创建自定义的 TipViewStyle 来让提示的UI完美契合你的应用设计。


// 定义一个自定义的提示视图样式,使用路线英雄图像作为背景

struct TrailTipViewStyle: TipViewStyle {

let trail: Trail

  


func makeBody(configuration: Configuration) -> some View {

VStack {

configuration.title

.font(.headline)

configuration.message?

.font(.subheadline)

configuration.actions? // 操作按钮

}

.padding()

.background(

Image(uiImage: trail.heroImage)

.resizable()

.aspectRatio(contentMode: .fill)

)

.cornerRadius(10)

}

}

  


// 使用时应用自定义样式

TipView(MyTip())

.tipViewStyle(MyCustomTipViewStyle())

代码说明:此示例展示了如何通过实现 TipViewStyle 协议来自定义提示的外观。你可以完全控制标题、信息、图片和操作按钮的布局和样式,使其与应用的整体设计语言保持一致。

4. 高级用法与最佳实践

4.1 利用事件和参数规则

TipKit允许你基于事件(Events)参数(Parameters) 来定义复杂的提示显示规则,这些规则的状态也会通过CloudKit同步。

  • 事件规则:基于特定事件发生的次数来触发提示。

struct ShoppingCartTip: Tip {

// 定义一个事件

static let itemAddedEvent = Event(id: "itemAdded")

  


var rules: [Rule] {

// 当用户添加商品到购物车的次数达到3次时,显示提示

[#Rule(Self.itemAddedEvent) { $0.donations.count >= 3 }]

}

// ... 其他属性

}

  


// 在用户执行操作时“捐赠”事件

func addItemToCart() {

// ... 添加商品的逻辑

Task { @MainActor in

await ShoppingCartTip.itemAddedEvent.donate() // 记录事件

}

}

代码说明:此代码定义了一个事件规则,当 itemAddedEvent 事件被记录(捐赠)至少3次后,ShoppingCartTip 提示才会显示。这个事件计数会在用户的所有设备间同步。

  • 参数规则:基于应用程序状态的布尔值或其他值来触发提示。

struct HighScoreTip: Tip {

// 定义一个参数

@Parameter

static var isHighScoreBeaten: Bool = false

  


var rules: [Rule] {

[#Rule(Self.$isHighScoreBeaten) { $0 == true }]

}

// ... 其他属性

}

  


// 当用户打破记录时,更新参数

func checkHighScore(newScore: Int) {

if newScore > highestScore {

HighScoreTip.isHighScoreBeaten = true

}

}

代码说明:此代码使用一个布尔参数来控制提示的显示。参数值的变化会通过CloudKit同步,从而在其他设备上也触发或隐藏该提示。

4.2 显示频率与最大显示次数

通过提示的 options 属性,你可以精细控制提示出现的频率和次数。


struct WelcomeBackTip: Tip {

// ... 标题、信息等属性

  


var options: [TipOption] {

[

// 忽略全局的显示频率设置,满足条件立即显示

Tip.IgnoresDisplayFrequency(true),

// 此提示最多只显示2次(跨设备累计)

Tip.MaxDisplayCount(2)

]

}

  


// ... 规则

}

代码说明:Tip.IgnoresDisplayFrequency 选项允许此提示绕过在 Tips.configure 中设置的全局频率限制。Tip.MaxDisplayCount(2) 确保该提示在所有设备上最多只显示2次,之后将永久失效。这个计数是跨设备同步的。

4.3 测试与调试

测试CloudKit同步功能时,请考虑以下事项:

  • 使用多台设备:在至少两台登录了相同Apple ID的真实设备上进行测试,以验证同步是否正常工作。

  • 重置数据:在开发过程中,你可能需要重置本地和CloudKit中的提示数据以重新测试。TipKit提供了 resetDatastore 函数**(谨慎使用,尤其在生产环境中)**:


Task {

try await Tips.resetDatastore() // 清除所有提示的状态和历史记录

}

代码说明:此函数会清除应用的TipKit数据存储,包括本地和CloudKit中的记录,主要用于开发和调试阶段。

  • 检查控制台日志:在Xcode的调试控制台中查看相关日志,有助于诊断同步问题。启用CloudKit调试日志(通过在Scheme中添加 -com.apple.CoreData.CloudKitDebug 1 启动参数)可能会提供更多信息。

5. 常见问题与故障排除

即使正确配置,有时同步也可能遇到问题。以下是一些常见原因和解决方案:

  1. 用户未登录iCloud:CloudKit要求用户在其设备上登录iCloud账户。检查 CKContaineraccountStatus,如果状态不可用,应优雅地处理(例如,不依赖同步)。

  2. 网络连接问题:CloudKit同步需要有效的网络连接。实现网络状态监听,并在离线时妥善处理本地操作,待网络恢复后同步会自动进行。

  3. 配置或权限错误

  • 确保:Bundle Identifier、iCloud容器标识符在Xcode项目和Apple Developer门户中完全一致。

  • 确保:在Xcode中正确配置了iCloud和Remote Notifications权限。

  1. 配额限制:每个iCloud容器都有存储配额。虽然TipKit数据通常很小,但 exceeding quotas 会导致操作失败。在CloudKit Dashboard中监控使用情况。

  2. 同步延迟:CloudKit同步不是瞬时的,可能会有几秒钟到几分钟的延迟。这是正常现象。

6. 其他应用场景

TipKit与CloudKit的结合可以解锁许多增强用户体验的场景:

  • 渐进式功能导览:利用 TipGroup 和有序提示,在新用户首次启动应用时,引导他们一步步了解核心功能,且这个“学习进度”会在他们的所有设备上同步。

  • 上下文相关帮助:根据用户在不同设备上的行为(例如,在iPhone上频繁使用功能A,但在Mac上从未使用过),在合适的设备上适时地显示功能B的提示,可能功能B与功能A协同工作能提升效率。

  • 跨设备成就提示:当用户在iPhone上完成某个游戏成就或任务时,提示可以在他们的iPad上弹出,祝贺他们并告知奖励。

总结

iOS 18中TipKit与CloudKit的集成极大地增强了功能提示的体验和管理能力。通过正确配置iCloud容器、启用后台通知、初始化Tips库,并利用TipGroup、自定义标识符、事件规则和参数等高级功能,开发者可以构建出智能、贴心且状态跨设备同步的用户导览系统。

核心要点回顾

  • 价值:提供跨设备一致的用户体验,避免提示重复打扰。

  • 配置:在Xcode中启用iCloud/CloudKit和远程通知,创建专用容器,并在代码中配置 Tips.configure

  • 开发:使用 TipGroup 管理顺序,通过自定义 id 实现提示重用,用 TipViewStyle 定制UI。

  • 控制:利用 EventParameter 以及 options like MaxDisplayCount 来实现精细的显示逻辑。

  • 测试:在多台真实设备上测试,注意网络和iCloud登录状态。

通过遵循本指南中的步骤和最佳实践,你可以有效地实现TipKit的CloudKit同步,为用户提供更 seamless 和专业的应用体验。

原文:xuanhu.info/projects/it…

永远不要站在用户的对立面,挑战大众的公知。

作者 iOS研究院
2025年9月16日 13:37

背景

最近闹得沸沸扬扬的西贝事件,诠释所谓的规则和大众公知的博弈。

从罗永浩质疑 “几乎全是预制菜还卖高价”,到开放后厨时被发现大量预包装食材仅需简单加热,西贝的 “非预制菜” 说辞显然与大众认知相悖。

西贝事件也诞生了众多名梗:

  • 名梗一: 一岁的宝宝吃两岁的西兰花。
  • 名梗二: 🐑:我为什么还不能转世? 😈:因为你还有1条腿在西贝。
  • 名梗三:为了不吃家里的剩饭,所以去了西贝吃了一年前的预制菜。

image.png

所以任何时候都永远不要站在用户的对立面,任何一个品牌站在用户的对立面都没有什么好下场。

简单来说:

作为一个掌门人任何时候,都要更加冷静。

如果不是贾老板的自爆,有专业的公关团队处理,那么事件不会闹得如此严重,舆情也不至于久高不下。

如果说规则是 “硬约束”,那么尊重用户就是产品的 “软实力”,是穿越周期的核心竞争力。

不管在那种情况下,都要习惯性让子弹飞一会。冷静之后再做决定,可以解决很多不必要的麻烦。

同时,也奉劝各位一言堂的老板谨言慎行,因为大多数情况下,老板的认知大概率就决定了公司认知的天花板

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

相关推荐

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

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

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

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

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

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

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

知识星球

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

❌
❌