在 iOS 开发中,将配置类信息与代码混合在一起是很常见的做法,虽然这种方法在开发过程中很方便,但也会带来一些潜在的问题。尽管 iOS 社区提供了 Alamofire 等各种出色的网络工具包,但这种问题在网络层中尤为突出。不过,有多种方法可以在更高层次上有效管理 URL 和 Endpoint,从而减轻其中的一些挑战。
swift
AF.request("https://bff.cyou/")
AF.request("https://bff.cyou/post", method: .post)
AF.request("https://bff.cyou/put", method: .put)
AF.request("https://bff.cyou/delete", method: .delete)
例如,通过 extensions 进行管理。那有没有更加优雅的方式呢?
swift
extension URL {
static var recommendations: URL {
URL(string: "https://ilove.pet/recommendations")!
}
static func article(withID id: Article.ID) -> URL {
URL(string: "https://ilove.pet/articles/\(id)")!
}
}
控制反转(IoC)是一种设计模式,在这种模式下,计算机程序中自定义编写的部分从通用框架接收控制流。Spring 框架在 Java 编程语言中引入这一概念以及依赖注入(Dependency Injection)方面发挥了关键作用。这种标准化大大改善了 Java 中大型项目的管理。在 iOS 开发领域,使用 Xcode 通过 plist 配置文件管理 URL 的做法也符合控制反转 (IoC) 的核心原则。
1. 使用 yaml 描述配置
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>baseURL</key>
<string>https://api.bff.cyou</string>
<key>services</key>
<array>
<dict>
<key>name</key>
<string>login</string>
<key>path</key>
<string>/login</string>
<key>description</key>
<string>login</string>
<key>bypass</key>
<true/>
<key>method</key>
<string>POST</string>
</dict>
<dict>
<key>name</key>
<string>logout</string>
<key>path</key>
<string>/logout</string>
<key>description</key>
<string>logout</string>
<key>method</key>
<string>POST</string>
</dict>
</array>
</dict>
</plist>
以上是一个通过 Property List 定义的 endpoint,可以看到相当的繁琐,我们尝试把它变成 yaml 试试。
yaml
baseURL: https://api.bff.cyou
services:
- { name: login, path: /login, description: login, bypass: true }
- { name: logout, path: /logout, description: logout }
可以看到,通过 yaml 描述的网络配置文件相当的简洁,便于维护。在 Swift 中可以通过 Yams 这个第三方 framework 解析 yaml 文件。
swift
enum ConfigError: Error {
case NotFound(String)
}
// Use for parse config contents with yaml format.
struct Config: Codable, Equatable {
struct Environment: Codable, Equatable {
var env: String
var url: String
}
enum Method: String, Codable, Equatable {
case GET = "GET"
case POST = "POST"
case PUT = "PUT"
case DELETE = "DELETE"
}
struct Endpoint: Codable, Equatable {
var name: String
var path: String
var description: String?
var method: Method?
var baseAPI: String?
var bypass: Bool?
}
}
class ConfigManager {
static let shared = ConfigManager()
fileprivate let configName = "config.yml"
var config: Config!
init() {
do {
self.config = try loadConfig()
} catch {
fatalError("Can not load config file.")
}
}
private func loadConfig() throws -> Config {
if let path = Bundle.main.path(forResource: configName, ofType: nil) {
let contents = try String(contentsOfFile: path, encoding: .utf8)
let config = try YAMLDecoder().decode(Config.self, from: contents)
return config
} else {
throw ConfigError.NotFound("Can not find config file.")
}
}
}
2. 网络核心逻辑的处理
通过配置的信息,我们可以写一个简单的网络请求逻辑,只需要传入 endpoint 的路径名称即可,不需要再额外维护。另外,默认情况下的请求 host 也可以直接从配置读取。
swift
// Some basic definition has been omitted, complete codes can be found at ...
public class NetworkCore {
public static let shared = NetworkCore()
var baseURL = ""
private(set) var taskQueue = NSMutableArray()
lazy var sessionManager: Alamofire.Session = Alamofire.Session(configuration: URLSessionConfiguration.default, startRequestsImmediately: false)
private init() {
let configManager = ConfigManager.shared
self.baseURL = configManager.config.baseURL
}
/**
Send request.
Usage exapmle: NetworkCore.shared.request(api: ServiceName.Login, complete: nil)
*/
@discardableResult
func request<T, K>(api: T, parameters: K = Alamofire.Empty?.none, headers: HTTPHeaders = HTTPHeaders(), complete: @escaping (DataResponse<Any, Error>) -> Void) -> DataRequest where T: APICovertable, K: Encodable {
let apiItem: APIItem = api.convert() as! APIItem
let url: URLConvertible = self.baseURL + apiItem.path
// Set http headers, token, etc.
let request = self.sessionManager.request(url, method: apiItem.method, parameters: parameters, headers: headers)
request.validate().responseData { dataResponse in
// deal response
}.resume()
return request
}
}
直接运行这个代码会报错,因为我们还没定义ServiceName
呢,如果手工创建这个数据结构,看起来会是这样的。
swift
/// ServiceName is a enum for all services in this project.
public enum ServiceName: String, Codable, Equatable {
/// Login
case Login = "login"
/// Logout
case Logout = "logout"
}
可以看到这个 ServiceName
的枚举和yaml
中配置完全一致!如果可以自动化的生成这个数据结构就好了。
3. 完成这个拼图!
如果想要自动化的完成这个过程,我们需要使用 Golang 先写一个命令行工具,以实现以下的工作流。
ioc-script
主要的代码片段:
golang
func renderTemplate(config *ConfigModule, out string) error {
tc := res.Template
timeSlot := "#CREATE_TIME#"
contentSlot := "#CONTENT#"
now := time.Now().Format("2006/01/02 15:04:05")
tc = strings.Replace(tc, timeSlot, now, -1)
var content string
for _, serv := range config.Services {
if serv.Description != "" {
content += "\t/// " + serv.Description + "\n"
} else {
content += "\n"
}
content += fmt.Sprintf("\tcase %s = \"%s\"\n", capitalize(serv.Name), serv.Name)
}
tc = strings.Replace(tc, contentSlot, content, -1)
if err := os.WriteFile(out, []byte(tc), 0755); err != nil {
return fmt.Errorf("write file %s error. %s", out, err)
}
return nil
}
这个 golang 脚本的功能是读取项目中的 config.yml,使用文件内的配置替换预制的脚本,并将包含 ServiceName
的 swift 文件写入指定的路径。
将脚本编译为二进制文件 ioc-script
,放到项目目录中。由于 Swift 编译器要求在编译代码时,Swift 文件不能发生变更,所以有2个时机可以执行这个脚本。
在 pod install
的时候执行,如果你使用了Cocoapods
作为包管理器。如以下 Podfile 的一个示例。
ruby
post_install do |installer|
puts "根据配置生成Service.swift"
system("./scripts/ioc-script -c './ioc-demo/config.yml' -o './ioc-demo/network/Services.swift'")
puts "生成Service.swift完成!"
end
**新建一个 Aggregate
的 Target 执行,如果你使用了 SPM 、 Carthage 或者其他情况。**这个 Target 必须早于项目的编译阶段。在新建的 Target 中,添加一个 Run Script
阶段,执行以下的命令行,其中路径需要根据你项目的具体情况进行修改。
shell
./ioc-script/dist/ioc-script -c "./$PROJECT_NAME/config.yml" -o "./$PROJECT_NAME/network/Services.swift"
你需要单独运行一次 ioc-script
这个 Target 以生成一次 ServiceName,方便编码过程。之后,当你构建项目时,这个脚本会确保每次都根据 config.yml 中的内容更新这个类。
在示例的项目中,主要的截图如下。


4. 一些问题
Intel 和 Apple silicon CPU问题
默认情况下,golang 总会根据当前的 CPU 架构编译二进制文件,如果你的团队中同时有 Intel 和 Apple silicon 的设备,这个脚本可能在不同的 CPU 架构下不能正常工作。
解决方案其实很简单,就想在 iOS SDK 开发过程中,我们使用 lipo 将不同架构的 framework 进行合并,现在也可以使用这个命令将 golang 编译产生的二进制文件进行合并操作,只需通过以下的 Makefile 脚本对 golang 代码进行编译。编译产生的 fat binary 即可同时支持 Intel 和 Apple silicon 两种 CPU 架构。
makefile
GO ?= $(shell command -v go 2> /dev/null)
GOPATH ?= $(shell go env GOPATH)
GO_BUILD_FLAGS ?=
BINARY_NAME ?= "ioc-script"
LIPO ?= $(shell command -v lipo 2> /dev/null)
.PHONY: dist
dist:
rm -rf dist
mkdir -p dist
@echo Build binary for darwin-amd64
GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY_NAME)-darwin-amd64
@echo Build binary for darwin-arm64
GOOS=darwin GOARCH=arm64 $(GO) build -o dist/$(BINARY_NAME)-darwin-arm64
@echo Combine binaries into universal binary
# 通过 lipo 命令将 2 个 CPU 架构的二进制包合成为一个 fat binary
$(LIPO) -create dist/$(BINARY_NAME)-darwin-amd64 dist/$(BINARY_NAME)-darwin-arm64 -output dist/$(BINARY_NAME)
@echo Clen jobs
rm -rf dist/$(BINARY_NAME)-darwin-amd64 dist/$(BINARY_NAME)-darwin-arm64
ENABLE_USER_SCRIPT_SANDBOXING 访问权限问题
在 Xcode 中执行脚本报错,查看日志发现 Sandbox: ioc-script(5543) deny(1) file-read-data xxx/ioc-demo/ioc-script/dist/ioc-script 相关信息。
依次执行以下步骤:
- 在 macOS 系统设置,隐私与安全性中,打开对于 Xcode 的
完全磁盘访问权限
。 - 在 Xcode 中,在 Project 的 Build Setting 中搜索
ENABLE_USER_SCRIPT_SANDBOXING
,修改为NO
。

5. 总结
通过以上的代码和配置,我们实现了一个基于 yaml 配置的 iOS 网络层 IoC。本文只是提供了基础的思路和演示代码,实际上在使用的时候,可以基于此方案做更多的延伸,比如可以通过更加复杂的配置管理系统生成配置文件、增加更多的网络层定义与约束等等。
本文采用 golang 用于命令行工具的构建的考虑是,
- 直接采用 Shell 语句其实也可以完成文中的逻辑,但是缺乏扩展性,Shell 代码在规模扩大时难以管理。
- golang 采用内嵌运行环境的二进制文件部署,不需要额外的运行环境配置,适合团队共享。
本文采用的源码可以在这里找到。