在 iOS 开发中,将装备类信息与代码混合在一起是很常见的做法,虽然这种办法在开发过程中很便利,但也会带来一些潜在的问题。虽然 iOS 社区提供了 Alamofire 等各种超卓的网络东西包,但这种问题在网络层中尤为杰出。不过,有多种办法能够在更高层次上有用办理 URL 和 Endpoint,然后减轻其间的一些应战。

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 进行办理。那有没有愈加高雅的方式呢?

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 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 试试。

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 文件。

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 也能够直接从装备读取。

// 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呢,假如手工创建这个数据结构,看起来会是这样的。

/// 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 先写一个命令行东西,以完成以下的工作流。

flowchart LR
	start{start} --> step1[1. 经过装备生成 ServiceName] --> step2[2. 编译项目] --> ed{end} 

ioc-script 首要的代码片段:

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 的一个示例。

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 阶段,履行以下的命令行,其间途径需求依据你项目的具体状况进行修正。

./ioc-script/dist/ioc-script -c "./$PROJECT_NAME/config.yml" -o "./$PROJECT_NAME/network/Services.swift"

你需求单独运转一次 ioc-script 这个 Target 以生成一次 ServiceName,便利编码过程。之后,当你构建项目时,这个脚本会保证每次都依据 config.yml 中的内容更新这个类。

在示例的项目中,首要的截图如下。

运用 Golang 完成 iOS 网络层的 IoC

运用 Golang 完成 iOS 网络层的 IoC

4. 一些问题

Intel 和 Apple silicon CPU问题

默认状况下,golang 总会依据当前的 CPU 架构编译二进制文件,假如你的团队中同时有 Intel 和 Apple silicon 的设备,这个脚本可能在不同的 CPU 架构下不能正常工作。

解决计划其实很简单,就想在 iOS SDK 开发过程中,我们运用 lipo 将不同架构的 framework 进行合并,现在也能够运用这个命令将 golang 编译发生的二进制文件进行合并操作,只需经过以下的 Makefile 脚本对 golang 代码进行编译。编译发生的 fat binary 即可同时支撑 Intel 和 Apple silicon 两种 CPU 架构。

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 相关信息。

顺次履行以下步骤:

  1. 在 macOS 系统设置,隐私与安全性中,打开关于 Xcode 的 彻底磁盘拜访权限
  2. 在 Xcode 中,在 Project 的 Build Setting 中搜索 ENABLE_USER_SCRIPT_SANDBOXING,修正为 NO

运用 Golang 完成 iOS 网络层的 IoC

5. 总结

经过以上的代码和装备,我们完成了一个根据 yaml 装备的 iOS 网络层 IoC。本文只是提供了基础的思路和演示代码,实际上在运用的时分,能够根据此计划做更多的延伸,比如能够经过愈加复杂的装备办理系统生成装备文件、增加更多的网络层定义与约束等等。

本文选用 golang 用于命令行东西的构建的考虑是,

  1. 直接选用 Shell 语句其实也能够完结文中的逻辑,可是缺乏扩展性,Shell 代码在规模扩大时难以办理。
  2. golang 选用内嵌运转环境的二进制文件布置,不需求额定的运转环境装备,适合团队共享。

本文选用的源码能够在 这里 找到。