Skip to content
Calvin's Blog

【WWDC 2023】Xcode 15 更新内容

Jun 12, 2023 — iOS

WWDC 2023 这几天陆续放出各个主题的视频,挑了几个我认为值得看看的,学习一下并做个笔记。当然目前大部分系统、软件都是 Beta 版本,正式版本可能还会更改,但整体更新内容是不会大变的。

我挑的第一个视频是 What’s new in Xcode 15 (opens in a new window),工欲善其事,必先利其器。

下面将根据视频的播放顺序,分析并实践各个段落部分。

2023 年 6 月 9 日,测试 Xcode 15 Beta 版本。

1. 根据文件名,当你想输入相同名字的 struct 或 class 时,会根据你输入的首字母匹配文件名。

比如文件名为”ThisIsACodeCompleteDemo.swift”,当你在文件里输入”struct t”时,自动弹出”ThisIsACodeCompleteDemo”为首选。

alt text

2. 会根据上下文分析推断你需要设置的属性或参数。

对 Text 设置了.font(.title)之后,再次输入”.”,会提示.bold().fontWeight(_ weight:)等字体相关的 modifier。

alt text

如果是图片,首选推荐的是.resizable

alt text

1. 在 Assets.xcassets 里面添加资源文件会自动生成对应的常量名称

这个功能居然内置了,激动啊,泪流满面!

支持的资源文件包括以下几种:

注意 Data Set 不在支持列表,依然使用 NSDataAsset

下面通过几个实例来了解一下具体的操作,在 Assets.xcassets 分别添加一个 “About” 的 Symbol Image,“baseline_bookmarks_black_48pt”的 Image,带 “Home” 目录的”tab-bookmarks” Image,“MainColor”的 Color Set,“mock-login-response” 的 JSON 文件。

alt text

用法如下:

import SwiftUI
struct ThisIsACodeCompleteDemo: View {
var body: some View {
VStack {
Text("This is a code complete demo")
.font(.title)
.bold()
// "About" 的 Symbol Image
Image(.about)
// "baseline_bookmarks_black_48pt"的 Image
Image(.baselineBookmarksBlack48Pt)
// 带 "Home" 目录的"tab-bookmarks" Image
Image(.tabBookmarks)
.foregroundColor(.main) // "MainColor"的 Color Set
Text(loadDataSet() ?? "No data")
}
}
func loadDataSet() -> String? {
guard let data = NSDataAsset(name: "mock-login-response")?.data else {
return nil
}
return String(data: data, encoding: .utf8)
}
}

这是怎么做到的呢?其实就是 Xcode 编译时自动生成了一个 GeneratedAssetSymbols.swift,根据 Assets 类型 生成了 2 个 struct:ColorResource, ImageResource 。两者的代码是一样的,我们就看 ImageResource

/// An image resource.
struct ImageResource: Hashable {
/// An asset catalog image resource name.
fileprivate let name: String
/// An asset catalog image resource bundle.
fileprivate let bundle: Bundle
/// Initialize an `ImageResource` with `name` and `bundle`.
init(name: String, bundle: Bundle) {
self.name = name
self.bundle = bundle
}
}

然后对 UIKit 的 UIImage 和 SwiftUI 的 Image 各写了一个初始化 extension :

@available(iOS 11.0, tvOS 11.0, *)
@available(watchOS, unavailable)
extension UIKit.UIImage {
/// Initialize a `UIImage` with an image resource.
convenience init(resource: ImageResource) {
#if !os(watchOS)
self.init(named: resource.name, in: resource.bundle, compatibleWith: nil)!
#else
self.init()
#endif
}
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension SwiftUI.Image {
/// Initialize an `Image` with an image resource.
init(_ resource: ImageResource) {
self.init(resource.name, bundle: resource.bundle)
}
}

上面这些是基本的工具方法,具体的 Assets 名称是存在于 ImageResource 的,绑定常量和名称。

// MARK: - Image Symbols -
@available(iOS 11.0, macOS 10.7, tvOS 11.0, *)
extension ImageResource {
/// The "baseline_bookmarks_black_48pt" asset catalog image resource.
static let baselineBookmarksBlack48Pt = ImageResource(name: "baseline_bookmarks_black_48pt", bundle: resourceBundle)
/// The "tab-bookmarks" asset catalog image resource.
static let tabBookmarks = ImageResource(name: "tab-bookmarks", bundle: resourceBundle)
/// The "About" asset catalog image resource.
static let about = ImageResource(name: "About", bundle: resourceBundle)
}

注意上面的名称转化为常量的规则,我自己观察下来有点类似 Codable JSON 转 model 的规则:

这有什么好处?

这个功能也可以用于 UIKit ,并且是 iOS 11.0 以上就支持的,点赞!

alt text

唯一让我觉得遗憾的地方是没有对有目录的 Image 增加命名空间的概念,比如上面提到的带 “Home” 目录的”tab-bookmarks” Image,Xcode 是直接取的 “tab-bookmarks” 来解析,忽略了”Home”层级。实际上大部分情况,我们会对图片资源也进行分级,比如这样:

alt text

在 Xcode 14 包括以前的版本,我习惯于用 SwiftGen (opens in a new window) 来自动生成一些常量,功能比 Xcode 15 更强大,支持以下类型:

SwiftGen 既支持 Xcode 15 这种扁平化的方式,也支持目录层级的方式。层级模式生成的代码大概如下:

internal enum Asset {
internal enum Home {
internal static let alarmRing = ImageAsset(name: "alarm_ring")
internal static let battery1 = ImageAsset(name: "battery1")
}
}
internal struct ImageAsset {
internal fileprivate(set) var name: String
internal typealias Image = UIImage
@available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *)
internal var image: Image {
let bundle = BundleToken.bundle
let image = Image(named: name, in: bundle, compatibleWith: nil)
guard let result = image else {
fatalError("Unable to load image asset named \(name).")
}
return result
}
}

使用起来也很简单:

let imageView = UIImageView(image: Asset.Home.alarmRing.image)

这是一个新的管理翻译字符串的方式,采用新的文件格式 .xcstrings。相比 Xcode 15 以前使用不同语言的目录的方式,更加集中统一了,这是一个比较好的改进。另外,这个功能是向后兼容的,iOS 17 以前也可以使用,👍🏻!

.xcstrings文件实际上是 JSON 文件,终于不是 Xcode 祖传的 XML 格式,Git 版本化就方便多了,感激涕零。

新建文件通过 “File” -> “New” -> “File” ,然后输入 string 过滤,下面图示。我们可以看到默认就是 String Catalog 了,Strings File 和 StringsDict 都变成了 Legacy ,Next 之后生成的默认文件名依然是”Localizable”,这个就最好不改了。

alt text

接下来,我们先来学习一下新的界面操作,首先依然会有一个 Base 的概念,就是其他语言根据 Base 来翻译,默认是 English。

在 Xcode 15 以前,这个 Base 基本上不改,但是 Xcode 15 多了一个默认语言的修改,你可以通过”Set Default” 来改成简体中文为默认。

alt text

下面这两个是 选中Localizable.xcstrings 的界面,相对来说还是比较明了的。

alt text

这里选择 English 为默认语言,当我们切换到中文简体的时候,列表项多了一个 “Default Localization(en)“,这样我们可以对比一下当前语言的翻译和默认语言的比对。

alt text

翻译状态

每一个翻译字符串都有一个 State 字段,表示当前的翻译状态,具体的有以下几种:

alt text

新加的 Key 肯定是 New 了,当你对某个 Key 改动了就变成NEEDS REVIEW ,当左右语言都翻译完了就是 ✅。

这里特别说明一下 STALE 状态的,表示你添加了 Key,但是并没有在代码里调用,有点废弃(Deprecated)的意思。当我们想要清理一下废弃资源的时候,这个状态就很好用。

在使用 Xcode 15 的过程中,我删除了某些 Key,但是没有在代码里删除对应的代码,因为是字符串,编译不会报错,但是 Xcode 15 自动给我在 String Catalogs 里面添加了对应的 Key,也就是说你也可以在代码里先写 Key,Build 之后在 String Catalogs 里面编辑。哇塞,还有一个反向的操作,疯狂点赞!

这有点类似于单元测试的状态。

复数问题

当 Key 里面包含数量的参数时,要注意处理英语国家的复数问题,比如 one day 和 two days,如果你的项目要求比较严格的话,按照 Xcode 15 以前的话,strings 格式的需要添加两个一样 Key 来处理复数问题,或者使用 stringsdict 这么来处理:

alt text

这个功能在 Xcode 15 就非常简单了,对需要处理复数的 Key,右键选择”Vary By Plural”

alert text

Xcode 会帮你自动细分成两种:One,Other。

alert text

还有一个情况是在以前 .strings 基本上没法处理的问题,一句话里面包含 2 个或者 2 个以上的数量参数,这时候要处理复数就要抓头了,方法也是非常啰嗦,不够优雅,只能使用.stringsdict。好在 Xcode 15 也帮我们考虑了这种情况,它可以 Vary By Plural 两个参数以上。

假如我们有一句话是 There are %lld boys and %lld girls in this class,通过下面图示的方法两次 Vary 了。

alert text

结果就会有 @arg1@arg2 两个参数,然后我们将要处理复数的 “boys” 和 “girls” 挪到下面的参数,并处理对应的 复数 s问题。

alert text

Xcode 15 还支持自定义 @arg1@arg2 的名称,比如你可以改成更好阅读的 @boys@girls 。但是这个最好不要尝试,因为我遇到了几次崩溃,这一块的还有待完善。

老项目迁移

老的项目可以通过点击”Edit”-> “Convert” -> “To String Catalogs” ,界面出来之后会让你选择 Target,然后选择下一步选择需要转换的文件,包括Info.plistLocalizable.strings

假如目前有一个老项目,支持简体、繁体、英语这 3 种语言,通过转换之后会生成两个文件:InfoPlist.xcstringsLocalizable.xcstrings,在”Base.lproj”、“zh-Hans.lproj”、“zh-Hant.lproj”等旧的”.strings”文件会被删除。

其他功能

更多详细内容请参考: Discover String Catalogs (opens in a new window)

遗憾

总体来说,我是对 String Catalogs 是非常满意的,稍微遗憾的就是没有像 Assets 一样可以 Generate Code,自动生成一些没有参数的常量,或者生成带参数的函数。

如果是 Xcode 14,我建议使用 Localization Editor (opens in a new window) 来编辑翻译字符串,主界面如下图,将所有语言平铺在一起,也有注释功能,所有编辑操作实时同步到 .strings文件。

alert text

注意一下,看我的 Key 是设置成”auth.btn.login”这样的,因为我借助上面提到过的 SwiftGen (opens in a new window) ,会帮我自动生成代码,还可以自定义函数名称,这样可以做到动态加载不同语言的翻译效果。实际调用时的代码如下:

// MARK: - SwiftGen
// 缩写简化
typealias L = L10n.Localizable
// 自定义查询函数
func customLookup(_ key: String, _ table: String, _ fallback: String) -> String {
L10nManager.shared.get(key) ?? ""
}
// 下面是实际调用的代码
signUpButton.setTitle(L.Auth.Login.registerNewAccount, for: .normal)
forgetPasswordButton.setTitle(L.Auth.Btn.forgetPsd, for: .normal)
passwordTextField.placeholder = L.Auth.Password.placeholder

SwiftGen 对没有参数的会生成一个 enum 的 static var,对于中间的点分割部分,会依次生成一个内部 enum,达到命名空间的效果。

/// 请输入密码
internal static var placeholder: String { return L10n.tr("Localizable", "auth.password.placeholder", fallback: "Enter password") }

主要是更新了一个实时预览的功能,作用不大。

alert text

这个宏就强大了,值得单开一篇文章来详细说一说,比如有以下一些好用到爆炸的内置宏

但是没啥用,因为不向后兼容,只支持 iOS 17😭。

预览在 SwiftUI 是很方便的,一边写代码一边预览效果,DSL 的 UI 就是这么便捷。 但是想要让 UIView 和 UIViewController 也有预览功能,以前只能使用 storyboard 或者 xib 了。但是对那些代码党来说,这就很难受了,不运行项目是看不到效果的。所以,InjectionIII (opens in a new window) 这个工具诞生了,通过注入 framework 的形式,修改运行期代码。但这个工具需要侵入代码,不是特别好,稳定性也不够。

Xcode 15 借助 Swift macro 功能,新增了一个 #Preview 宏,可以支持 UIKit 的预览了

#Preview {
// LoginViewController 包含 2 个 UITextField 和 1 个 Button
// 三个子View都是距View左右边界 30 pt。
let controller = LoginViewController()
return controller
}

预览结果不理想,约束不生效。另外这个功能只支持 iOS 17,因为是 macro 实现的。

alert text

但是我们可以借助 SwiftUI 的预览功能,将 UIView 或者 UIViewController 用 SwiftUI 的 View 封装一层。下面简单写一个方法:

import SwiftUI
import UIKit
// 创建一个SwiftUI的容器View,将UIViewControler包含在内
struct PreviewContainer<T: UIViewController>: UIViewControllerRepresentable {
let viewController: T
init(_ viewControllerBuilder: @escaping () -> T) {
viewController = viewControllerBuilder()
}
// MARK: - UIViewControllerRepresentable
func makeUIViewController(context: Context) -> T {
return viewController
}
func updateUIViewController(_ uiViewController: T, context: Context) {}
}

然后就可以像用 SwiftUI 的预览功能一样了:

struct ViewController_Previews: PreviewProvider {
static var previews: some View {
PreviewContainer {
let controller = LoginViewController()
return controller
}
}
}

alert text

采用这种方式,就不会有约束失效的问题了,而且还可以支持到 iOS 13,完美!

Xcode 15 新增的书签功能就是给代码做个标记,可以快速定位你经常需要访问的代码行、源文件,或者特别标记的东西,比如 TODO、FIXME 等。

书签界面位于左侧导航视图的第三个位置,这里的位置可用快捷键Commmand + 数字 切换。

alert text

具体的功能有以下这些:

要把书签功能用好,一定要学习下面这几个标记符号,因为这算是行业共识了,无论用 VS Code 或者 Android Studio,都能识别到这些符号,并进行统计。

如果是 XCode 14,除了上面这些符号标记,我一般还会配合用断点来做一些书签功能。

书签功能是用户层级的,有一个bookmarks.plist文件来记录书签,文件位于下面这个地址,但最好不要加入到版本管理里面。类似的还有断点文件 Breakpoints_v2.xcbkptlist,可以直接将xcuserdata/加入到 .gitignore 里面过滤掉

Terminal window
# xxx 为你的系统用户名
/Users/xxx/Xcode15Demo/Xcode15Demo.xcodeproj/project.xcworkspace/xcuserdata/xxx.xcuserdatad/Bookmarks/bookmarks.plist

Xcode 15 内置的版本管理工具终于可用了,真的不是很喜欢在 Source Tree 或者 Terminal 之间来回切换界面了。

下面就是源码控制导航界面,快捷键 Command + 2

alert text

“Changes” 视图真的翻天覆地的变化了,简单来说就是跟 Source Tree 差不多了,只渲染改动代码行的上下文,多余的隐藏。像 Xcode 14 一样点击文件,居然把整个文件内容渲染出来,真是太傻吊了。而且还可以拖动代码行数上下两个小图标,显示更多内容,或者点击右上角的扩展图标,显示全部内容。

另外,Xcode 14 的 Source Control 菜单升级为 Xcode 15 的 Integrate 菜单,如下图所示:

alert text

大概有 2 个主要变化:

主要变化如下:

本章节重点介绍 OSLog ,但是 OSLog 是 iOS 15+ 的 API,所以有点鸡肋。

Logger 是 iOS 14 + 的 API,相对好一点。

关于 Swift Logging 的整个历史变化,可以看 Peter Steinberger 文章 Logging in Swift (opens in a new window)

下面我们简单过一下 OSLog 在 Xcode 15 的变化,先看官方的例子:

import OSLog
let logger = Logger(subsystem: "BackyardBirdsData", category: "Account")
func login(password: String) -> Error? {
var error: Error? = nil
logger.info("Logging in user '\(username)'...")
// ...
if let error {
logger.error("User '\(username)' failed to log in. Error: \(error)")
} else {
loggedIn = true
logger.notice("User '\(username)' logged in successfully.")
}
return error
}

整个变化都体现在 Console 的 UI 上,增加了 Category 分类、日志类型的过滤、背景色、跳转到代码行、隐私数据处理。

image-20230612114724021

SwiftLog

Apple 有在 github 开源一个 SwiftLog (opens in a new window) ,这是一个前端框架,提供了日常开发中常用的上层接口,但是需要自己依据 LogHandler协议实现后端,比如打印在 Console ,保存在文件,还是发送到日志服务器等。

有人以 OSLog 作为后端,实现了一个 LoggingOSLog (opens in a new window) 开源库,底层日志打印使用 os_log ,但是官方并不是很推荐这样使用,因为失去了静态字符串的性能,全是动态的字符串,以及隐私数据方面完成暴露了。

CocoaLumberjack

我相信大部分 iOS 开发者的日志工具还是 CocoaLumberjack (opens in a new window),没错,用它就够了。

Updates in Xcode Cloud distribution

Xcode Cloud 只能用于外网的 git 地址,对于我们公司这种局域网部署的 gitlab 无效,所以公司的项目都没有尝试过。

但是我在一些 Side Projects 上使用了一下,感觉还不是太给力,遂放弃。

Signature verification and privacy manifests

本章节主要讨论 XCFramework 的签名有效性和隐私清单。

XCFramework 的签名有两种,一种是使用自己的证书重新签名,叫做”self-signed”,Xcode 15 会比较 XCFramework 的 SHA-256 值,跟刚加入到工程的时候是否一致。另一种是 XCFramework 开发者自己的证书签名,苹果会校验证书的有效性,比如对方的 Apple Developer Program 过期了,或者他的证书被 revoke 了。

Privacy Manifest 隐私清单,就是把 App Store Connect 网页上的隐私设置项搬到 Xcode 工程里面,有一个 Plist 文件格式的 PrivacyInfo.xcprivacy 配置文件,里面需要列出各个隐私标签的使用情况,以及是否允许某些域名访问隐私 API。

alert text

到 2024 的 4 月份左右,App 开发者 和 SDK 开发者都要适配这个功能,无论是更新的 App 还是新上架的 App 都要添加隐私清单文件。

alert text

参考资料:

What’s new in Xcode distribution

Archive 之后多了几个新的选项:

alert text

所有这些发布选项都是流水线操作的,无需先过去一样手动选择证书,勾选,下一步等手动档。点击”Distribute”按钮,就是自动档的 D 档,自动签名、包含符号表、自动增加 Build 版本号、去除 Swift 的符号全包了。

Organizer 界面增加了一个 Feedback 选项,其实就是 TestFlight 网页的内容搬到这里来了,测试团队截图的 APP 内容就显示在这里。

alert text

Others

Xcode 15 还有些小改进,限于时长因素在视频里没有体现。

Quick Action

新增了一个类似 VS Code 的 Command Palette 命令,快捷键 Command + Shift + A,会打开一个搜索框,可以搜索任何菜单操作。这个功能的好处是菜单快捷键太多了,实在记不过来。不过我会改掉这个快捷键,变成和 VS Code 一样的 F11,这样按一下就可以了,更快捷。

alert text

Format to multiple lines

这个功能可以把多参数的函数自动格式化为每行一个参数,提高可读性。

alert text

比如,UIKit 的一些函数回调真的名称又臭又长:

func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
//
}

格式化之后变成下面这样:

func collectionView(
_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath
) {
//
}