阿权的开发经验小集
小集是日常开发中遇到问题的小结,或许可以帮助你少走一些弯路~
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
是指上游的改动。
操作原则:
- 只有是自己写的才应用自己的改动,否则应用上游的改动。
- 存疑的(自己写的混合了他人写的,应用上游后编译不过)应保留两者的修改,对于不确定要丢弃的代码用段落注释(合并代码少用行注释)。
最佳实践:
- 处理资源/二进制文件,简单选择用自己的还是用上游的。
- 处理文本:以行甚至段落为单位,选用自己的还是上游的版本。
- 处理存疑文本修改:以行为单位,不选用自己和上游的版本,直接编写预期的文本。
- 对解完冲突的文件暂存修改(git add)。
- 解完 git 仓库本次所有冲突后,提交修改(git commit)。
推荐工具:vscode。使用 vscode 打开 git 仓库,并用其解冲突。
修改作者信息
背景:提交完代码了才发现用错了邮箱提交,例如:外部仓库使用了公司邮箱提交了代码,需要改用私人邮箱。
Git 会为每一次提交记录提交者的姓名和邮箱,这是本地 Git 配置的 “身份标识”,用于区分不同开发者的提交。
如何修改:
-
git log
查看 commit id -
git rebase -i <最早commit>
重新设置基准线 -
git commit --amend --author="Author Name autolinkemail@address.comautolink"
来修改 commit - ``git rebase --continue` 移动到下个 commit 作为基准线
例子:如当前历史为 A-B-C(HEAD),我想修改 B 和 C,这两个 commit 的作者。
-
git rebase -i A
,即要修改的最早提交的前一个节点。- 如果想改 A 则使用
git rebase -i --root
- 如果想改 A 则使用
- pick 改为 edit。按 ESC,输入:wq。保存修改。
- 现在你已经开始可以修改,此时当前 commit 为 B。
-
git commit --amend --author="Author Name autolinkemail@address.comautolink"
修改 B 的提交。 -
git rebase --continue
定位到 C -
git commit --amend --author="Author Name autolinkemail@address.comautolink"
修改 C 的提交。 -
git rebase --continue
修改已完成。 -
git push -f
提交代码,大功告成。
文件修改检测不到
背景:本地文件有修改,但 Git 检测不到了。重启似乎就可以检测得到,但只能一次有效。
排查:去检查文件所在的路径,与 Git 识别的路径是否有大小写的差异。Git 区分大小写差异,但系统不区分,所以会有 gap,目录名只改了大小写,会导致一些奇怪的问题。
解决:把有大小写的路径段重命名。改大小写名称时,先重命名为临时名,再改为正确的大小写,分两次提交以避免文件系统的不识别。
iOS
留心延迟执行的代码
代码里看到延时执行要谨慎,非常可能是枯叶掩埋的陷阱。
延时执行可能是能解决作者提交时遇到的问题。但随着业务发展,可能后续那次修改后,延时执行就兜不住了。
- 首先自己不要写延时执行代码,不要期望延时能根治某个问题,延时能绕过的问题一般是执行时机、时序问题,应找到合适的时机执行逻辑。
- 其次看到别人写的延时代码要十分谨慎,可以先不去改别人写的延时代码,但尽可能不要依赖延时执行的时机做后续的逻辑,应自己找到合适的时机编写自己的代码。
主队列执行时序问题
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)
}
}
}
锁
使用锁,最终目的是为了解决竞态条件。
相关链接:
- swift - Is DispatchSemaphore a good replacement for NSLock? - Stack Overflow
- Concurrency with swift 3 – Pritesh Nandgaonkar – iOS App Developer
锁从基本原理上可分为互斥锁和自旋锁,其他类型的锁如:条件锁、递归锁、信号量,甚至是 GCD 的队列都是基于这两个基本锁的封装或扩展。
互斥锁 Mutex Lock | 自旋锁 Spin Lock | |
---|---|---|
原理 | 当线程尝试获取锁时,若锁已被占用,该线程会进入休眠状态(阻塞),直到锁被释放后被唤醒。 | 线程在获取锁失败时不会休眠,而是通过循环(忙等待)不断检查锁状态。 |
特性 | 互斥锁会休眠线程,避免了 CPU 空转,但涉及线程上下文切换,可能带来性能开销。适合高竞争或长时间持有。 | 自旋锁保持线程活跃,避免了上下文切换,但长时间等待会消耗 CPU 资源,适用于锁持有时间短的场景。适合短时间锁竞争。 |
具体实现 | 不可重入锁(非递归锁):线程必须释放锁后才能再次获取,否则会死锁。NSLock 、pthread_mutex (默认模式)可重入锁(递归锁) :允许同一线程多次获取同一锁而不死锁。NSRecursiveLock 、@synchronized 条件锁:基于条件变量实现,线程需等待特定条件满足后才能继续执行。NSCondition 和NSConditionLock ,需与互斥锁配合使用。 |
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 版本匹配的模拟器,这个过程总是很久。可以试试手动下载。
- 官网下载 Xcode 对应的模拟器版本:developer.apple.com/download/al…
- 执行命令:
# 需要先选定操作的 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 的调用栈。
转码控制台中的 JSON
背景:Xcode 控制台输出 json 常常是转义过的,配合 vscode 可以还原出原始的 json。
拷贝到 vscode,结合 Text Power Tools 插件,使用 json 解析。
- 去除头尾到双引号。
- 右键: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 的静态方法/属性。
- 关联值可忽略标签,直接用类型表达。
数组在遍历中删除元素
背景:遍历数组并删除元素一不小心就会数组越界。
可以通过以下方式规避:
- 使用高阶函数直接创建/修改一个符合条件的数组。如
filter
、removeAll(where:)
。 - 反向遍历,可以安全地按索引删除元素,如
reversed()
。
枚举 raw value 不能是表达式
枚举 raw value 在定义的时候等号右侧不可以是表达式,而是一个字面常量,不可加条件。
读取大文件
可以使用 FileHandle
或 InputStream
来读取大文件。
它们之间存在一些主要的不同:
-
使用方式:
InputStream
是基于流的,可以连续读取数据,这对于处理大文件或网络数据非常有用,因为你不需要一次性将所有数据加载到内存中。另一方面,FileHandle
允许你更精细地控制文件访问,例如,你可以选择从文件的任何位置开始读取或写入数据。 -
数据处理:使用
InputStream
时,你需要自己处理数据缓冲区的分配和释放。使用FileHandle
时,你可以直接获取Data
对象,而无需关心底层的内存管理。 -
可用性:
InputStream
可以处理来自各种来源的数据,如文件、网络数据或内存中的数据。而FileHandle
主要用于文件操作。 -
错误处理:
InputStream
有一个streamError
属性,可以用来检查在读取或写入过程中是否发生错误。FileHandle
的方法则会抛出异常,需要使用try
、catch
来处理。
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)管理的。当一个对象的强引用计数降至零时,该对象会被销毁。以下是一些可能导致对象未在作用域结束时被销毁的情况:
- 强引用计数:当对象的作用域结束时,如果对象的强引用计数不为零,对象不会被立即销毁。这可能是因为在作用域外还有其他地方保持着对该对象的强引用。
- 强引用循环:当对象之间存在强引用循环时,即使它们的作用域已经结束,对象也不会被销毁。强引用循环会导致内存泄漏,因为对象互相保持强引用,使得它们的引用计数永远不会降至零。这时,需要使用
weak
或unowned
关键字来解决强引用循环问题。 - 延迟释放: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 退出的时候调用这个事实,造成了这个错误。在if
,guard
,for
,try
这些语句中使用defer
时,应该要特别注意这一点。
另一方面,利用这个特性,把锁的加锁和解锁放在同一行是个比较不错的实践,这样作用域内(从该代码开始到作用域结束)的代码都加锁了,而且即使后面 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 符号断点似乎要重新编译?
否则不生效?
💬注释
文档注释标记
一般规则: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 后面使用,它不是个逻辑表达式,不能赋值到布尔量的。
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;重建字典。
- 使用
mapValues(_:)
方法:- 仅能修改值,过程中无法对 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]
- 【不推荐】使用
map
+init(uniqueKeysWithValues:)
:- 会中间生成个 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]
- 【推荐】使用
reduce
方法:- 通过元组的方式遍历整个字典,注意两个 reduce 方法的异同,根据使用场景来选择:
-
reduce(_:_:)
:闭包中每次都需要返回每次修改的片段值。 -
reduce(into:_:)
:【更推荐】闭包中直接对结果重新赋值,无须返回。
-
- 通过元组的方式遍历整个字典,注意两个 reduce 方法的异同,根据使用场景来选择:
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]
- 另外起一个字典变量在遍历中重新赋值:
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.return
和flags: 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
:旁路处理成功值,不影响流程。 -
race
、when
:组合多个 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)展示的目录有两种类型,在引入之初就决定了:
group | folder reference | |
---|---|---|
使用场景 | 最常用。代码、资源引入无脑选它。 | 蓝色图标。仅用于资源,如 bundle 资源。 |
细分 |
![]() |
无 |
更新逻辑 | 外部更新不会同步。引入时目录文件的结构就确定了,后续文件在 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.intrinsicContentSize
、UIView.frame
、UIView.bounds
。
- 相关 API:auto layout、
- 响应区域:根据 UX 要求扩大或缩小。
- 相关 API:
UIView.point(inside:with:)
、UIView.hitTest(_:with:)
。
- 相关 API:
- 展示区域:按照设计稿扩大或缩小。
- 相关 API:
UIView.clipsToBounds
、UIView.mask
、CALayer.masksToBounds
、CALayer.mask
。
- 相关 API:
通过修改对应 API 来修改对应的区域,三者相互独立解耦。
弹簧动画
usingSpringWithDamping
是 UIView
的一个动画方法,用于创建一个弹簧动画。usingSpringWithDamping
方法接受两个参数:dampingRatio
和 initialSpringVelocity
,分别用于指定弹簧动画的阻尼比和初始速度。
-
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 似乎不能重写构造函数
视图不展示问题排查
可按照以下思路排查:
- 对象不在视图层级中(可能没 addSubview):lookin 找到对应的 view 对象。
- 视图隐藏:
alpha == 0
,isHidden == true
。 - frame 是否正常:
- w/h 为 0 都会表现为视图不展示。
- 超出父视图可能会被裁切。
- 确定是否有 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 嵌套类型
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 路径绘制
一些走过的弯路:
- PaintCode 导入的 SVG 会做一些处理,导致与原来的 SVG 参数有些偏差。应该是我使用的姿势不对!
- swiftvg – Convert SVG path data to a Swift 3 UIBezierPath:精度比较高,但画出来的图形会残缺。
最佳实践:
SVG Converter,直接从文本编辑器打开 SVG,把其中的 viewBox 和路径参数拷贝出来,到这个网站进行转换。
备选方案:
参考 SVGKit 源码,使用代码直接从 SVG 读取并生成路径。
其他第三方组件:
- GenerallyHelpfulSoftware/SVGgh: A framework for using SVG artwork in iOS Apps. Includes a UIView and a button class, printing and PDF export.
- ap4y/UIBezierPath-SVG: NS/UIBezierPath from SVG string
- IconJar/IJSVG: MacOS SVG rendering and exporting library
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 问题。
- One solution for 90% of Auto Layout exceptions · aplus.rs
- ios - What is NSLayoutConstraint "UIView-Encapsulated-Layout-Height" and how should I go about forcing it to recalculate cleanly? - Stack Overflow
- UIView-Encapsulated-Layout-Width and Height Constraints error | by Goal 栈 | Medium
所以,基本的解法是降低发生冲突方向布局的优先级。但这样会有不确定性,不确定 break 掉的约束会是什么效果。
另一种方案是配置约束时考虑 width 会变成 0 的 case,确保各种约束(如缩进)不会导致某个 view 的 width 为负数。然后在 layoutSubviews 方法中更新约束到目标效果,或干脆直接重建约束。
💬UITableViewCell
设置背景色
backgroundColor
无效时,设置 backgroundView
。目前发现 UITableViewHeaderFooterView 子类设置 backgroundColor
无效。
取消高亮
selectionStyle = .none
不行的话,在 prepareForReuse
中也设置下。
💬UIView 生命周期
获得上屏 view
-
init + main.async
要获得显示在屏幕上的 View,最简单粗暴的方式是在初始化的位置,加个 DispatchQueue.main.async
闭包。
优点:
- 确保只执行一次
缺点:
- 不确定是否真的布局完成;
- 只适合那种初始化就配置好视图的情况。
-
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
,则它的宽度在拉伸时会尽可能地保持其内容的最小宽度,而在压缩时会尽可能地保持其内容的最大宽度。
参考资料:
- AutoLayout - 内容压缩阻力(Content Compression Resistance)和内容吸附(Content Hugging)
- UIView.AutoresizingMask
- setContentCompressionResistancePriority(_:for:)
- setContentHuggingPriority(_:for:)
若出现没有自动跟随尺寸变化,检查确保全部使用了 equalTo!!!
布局更新时机
-
layoutSubviews
放心在这里更新 auto layout 的约束常量,这不会出发循环调用。
-
didMoveToWindow
这是 UI 更新布局的最晚时机,这时 superview、responder 都已经有值,但这时 auto layout 可能还没完成布局,要获得 auto layout 后到布局可以在下一次 runloop 中获取。
这个方法调用时机很巧妙,当 view appear/disappear 的时候也会被调用,因为这时的 window 对象会置为 nil,这时就可以把 controller 生命周期的事情归还到 UIView 中来做。
获取自动布局后的 frame
-
强制布局
调用 setNeedsLayout()
+ layoutIfNeeded()
,触发同步布局。然后获取 view 的 frame。
-
获得布局后的尺寸
调用 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
命令行工具执行异步代码
相关链接:
- macos - Using NSURLSession from a Swift command line program - Stack Overflow
- xcode - How to prevent a Command Line Tool from exiting before asynchronous operation completes - Stack Overflow
- How to make async command-line tools and scripts - a free Swift Concurrency by Example tutorial
大概有几种方式:
- 阻塞进程,让其不退出。
- 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()