Swift 程式語言

生產力再提升:利用 Swift Package Manager 製作自動化開發者工具

生產力再提升:利用 Swift Package Manager 製作自動化開發者工具
生產力再提升:利用 Swift Package Manager 製作自動化開發者工具
In: Swift 程式語言, Xcode

Command Line Tool,通常指的就是能在 terminal 下指令執行的程式,身為一個利用 mac 做開發的工程師,一定非常熟悉,像是原始碼管理工具 git、自動化工具 fastlane、或是套件管理工具 homebrew,都可以算是 Command Line Tool 的一種。雖然現在有非常多 open source 的第三方工具可供使用,但並不是所有工具都能夠滿足我們在開發上的需求。舉例來說,如果我希望在建置我的 iOS 專案之前,都先連上內部 server 下載 logo 更新;或者我想要在每次建置成功後,把 log 複製一份上傳到內部 server 存放,這些都可能沒有對應的第三方工具可以使用。

因為每個人的工作習慣都不太一樣,所以如果想要最大化我們的工作效率,就應盡量減少重覆、手動、繁瑣的工作,那我們就需要了解如何製作自己的 Command Line Tool 了。Command Line Tool 就像是掃地機器人,能夠重覆地做單一、不需要決策的工作。像掃地這種事就都交給機器人就好,我們可以把省下來的時間拿去看 Netflix,是不是很棒(這裡真的是在講生產力嗎?)。

主流的 Command Line Tool,通常都會使用 ruby、perl、或 python 等等語言撰寫,但身為一個 Swift 工程師,當然要用 Swift 來撰寫屬於我們的 Command Line Tool!好的,撇除掉個人情感因素, Swift 的輕量化、可編譯成 Binary 、還有優異的例外處理等特性,使它非常適合拿來撰寫 mac 上的 Command Line Tool。而且 Swift 現在提供了一個非常好用的工具:Swift Package Manager,幫助你快速地上手、開發一個 Command Line Tool。接下來,這篇文章會以一個實際開發會遇到的狀況做範例,介紹甚麼是 Swift Package Manager,還有怎樣用 Swift Package Manager 來開發與管理 Command Line Tool。

大綱

  • 甚麼是 Swift Package Manager
  • 如何設定一個 Swift Package Manager 的專案
  • 如何讓 Swift Package Manager 的專案跟 Xcode 整合
  • 利用 Swift Package Manager 製作一個開發者工具
  • 如何建置、測試、自動化開發者工具

SwiftGrabber:同步資料的機械手臂

想像我們現在要開發一個需要跟 server 要資料的 app (rest api),最簡單的做法就是把 app 直接接上 server,寫好 network layer、parser、decoder 等,然後執行 app,讓 app 連上 server 抓資料測試看看成不成功。如果中間有地方沒寫好,就再修改一下、執行、再連上 server 測試…… 在這邊有沒有發現一個問題?我們每次測試都需要連上 server!如果我們在沒有網路的地方,或者網路非常慢,我們是不是就完全無法開發了(先不管連不上 google 也差不多等於無法開發這件事)?

我們希望開發不要受限於網路,所以我們手動打開 Postman 或 Chrome,把 JSON response 原封不動地抓下來,存到電腦裡,未來 app 會直接去電腦裡讀取這個檔案,這樣我們就不用一直連到 server 抓資料了。不過現在我們又遇到一個問題,我們的這個 API 規格因為老闆崇尚 agile 開發,所以每天都在更改;也就是說,身為 app 開發者的我們,每天一打開電腦,就要打開 Postman 或 Chrome,手動下載資料,再開始開發。這個步驟可能只需要5分鐘,但一年下來,也是要損失 250 天 * 5 = 1250 分鐘,也就是少看了七部電影😱!更可怕的是,那天如果忘了同步資料,你接下來一天就是在跟舊的 API 奮鬥,你一天的心血就白費了。

當然真實的開發環境不會一整年都在改同一個 API。不過一整年都在跟各種 Rest API 打交道是可以確定的。所以現在,我們要來製作一個能夠自動幫我們上網下載各個 API response、並且存成 JSON 檔,供 app 測試用的工具: SwiftGrabber🤳。

我們預期它的使用方式如下:

swiftGrabber -u <Server網址> -o <輸出路徑>

只要把這個指令加到 crontab,或是加到你的 CI 工具裡,讓它每天都執行,就可以確保你的開發用資料都是最新的了!

針對看 code 比看中文快的人,不用擔心,我們已經準備好原始碼了:GitHub – koromiko/SwiftGrabber: Folks, grab it!

如果想更深入了解到底要怎樣製作這樣的工具,歡迎繼續往下看!

去中心化的原始碼管理工具: Swift Package Manager

在 Swift 3.0 發佈的同時,Apple 也同時發表了 Swift Package Manager (簡稱 SPM)。SPM 是一個 Swift 原始碼管理系統,讓你可以方便地管理、發佈 Swift 原始碼,讓想使用你的工具的人可以簡單地下載、編譯,或甚至整合你的工具到既有的 project 裡。

說到 Swift 生態系的原始碼管理系統,大家第一個會想到的一定是 Cocoapod。Cocoapod 是一個高度整合的套件管理系統,它設定簡單,並且有個共用的 source list 負責記錄所有套件的 URL 等資訊,你只要設定好套件名稱,就能把套件完整地整合到你的 project 之中。

Swift Package Manager 1

Cocoapod

於 Cocoapod 不同, SPM 沒有共用的 source list,它是一個去中心化的管理系統,想要使用你的套件的人必需要取得你的 source code,或是 git repository,而 repository 的 URL 等資訊則是被記錄在本機的設定檔 (Package.swift) 裡,每個 project 都有自己的設定檔。

Swift Package Manager 2

Swift Package Manager

另外,SPM 的設定跟整合也稍微複雜一點,你要對架構有基本的了解才能夠上手。但是也因為這樣,SPM 的彈性更高,能應用的層面也更廣。接下來,我們就要來一步一步教你如何設定跟製作 SPM 套件。

設定 SPM 專案

在開始之前,我們要先了解一下 Swift package 的基本架構。一般來說,一個 Swift package 是由一個到多個 Module 組成,一個 Module 就是代表某群能夠獨立運作的程式碼,有著獨立的 namespace。一個非常單純的 Swift package 就是由一個最主要的 module 組成,再複雜一點的 package,可能會把權責再分開成多個 Module,或者引入外部的 Module。

到這邊,我們先動手來建立一個基本的 Swift package:

> mkdir SwiftGrabber
> swift package init --type executable

swift package 就是 SPM 提供的工具集,其中我們現在使用的是方便你建立基本專案的 init,後面的 type 參數指的是我們希望這個專案最終會產生一個執行檔。以上的指令會在資料夾裡產生這些檔案:

SwiftGrabber
├── Package.swift
├── README.md
├── Sources
│ └── SwiftGrabber
│…. └── main.swift
└── Tests

Sources 這個資料夾裡面,放的就是一個個的 Module ,用資料夾分開,目前可以看到我們只有一個 Module: SwiftGrabber。另外你也可以看到,在這個資料夾中,有個 Package.swift ,這是整個專案的設定檔 (manifest),我們要從這邊開始設定專案的運作方式、相依套件等等。

設定 Package.swift

Package.swift 設定檔的結構很簡單,主要只有三個部份:DependencyTargetProduct。這些名詞定義其實跟平常 Xcode 裡面接觸到的定義一樣,如果你已經很熟悉它們,那整個 SPM 設定就不會是問題。接下來我們就來一一介紹它們的功用跟設定方式。

Dependency

Dependency 是指這個專案的外部相依套件,如果你的專案需要引入其它套件(像是 Alamofire 或是 SwiftyJSON),就需要在這邊指定。它的語法如下:

dependencies: [
    .package(url: "https://github.com/Alamofire/Alamofire", from: "1.2.3")
]

上面這段設定是指這個專案需要引入 url 參數指向的專案(這個例子是 Alamofire),並且指定版號從 1.2.3 開始。在這裡版號是指 git repository 上的 tag,更多的版號指定方法可以參考這裡。目前我們的專案不需要外部的套件的支援,所以在這邊就設定成空 array 就好。

Target

Target 大家一定非常熟悉,它就跟一般 Xcode 專案裡的 Target 一樣,是用來設定你的原始碼要被編譯成怎樣的模組。在我們的 SwiftGrabber 中,我們的 target 設定如下:

targets: [
    .target(name: "SwiftGrabber", dependencies: ["libGrabber"]),
    .target(name: "libGrabber", dependencies: []),
    .testTarget(name: "SwiftGrabberTests", dependencies: ["libGrabber", "SwiftGrabber"]),
    ]

這邊總共有三個 target ,對應的是程式的兩個模組,跟一個測試模組。SPM 會針對上面的設定,去 Sources 這個資料夾裡面找到名稱跟 name: 一樣的子資料夾,並且把裡面的程式碼都 compile 成模組供使用。雖然說一個 Swift package 只要有一個模組就能夠運作,但我們仍然希望專案能夠保持結構簡潔,並且部份的 code 還能夠重覆使用,所以我們把 package 設定成兩個模組,負責不一樣的功能:

  • SwiftGrabber:這是我們的入口程式,負責接收 Command Line 的參數 (arguments) 與環境變數 (environmental variable),並且把資料傳遞給對應的物件做處理。
  • libGrabber:這個是跟 Command Line 無關的實作細節,像是網路層、硬體層等等。

這樣的好處除了權責比較明確之外,因為 libGrabber 跟 Command Line 實作是沒有相關的,所以未來還可以將 libGrabber 引入到 iOS/macOS 專案中,當成是一般的第三方套件使用,這樣就能夠大幅減少重覆的 code 了!

我們再回到設定檔裡, dependency 這個參數則是要讓你指定模組所需要的相依套件,像是 SwiftGrabber 會需要呼叫 libGrabber 裡的程式,所以它的 dependency 裡面就有 libGrabber 這個模組。

未來這樣的設定會在你的 Xcode project 裡產生如下這些 target (後面會介紹怎樣產生 Xcode project):

target

Product

Product 是指 build 後的最終產物。在 SPM 裡,能產生的 product 主要有兩種:

  • library:可被引入的 .framework 檔
  • executable:可在 Command Line 被執行的執行檔

它的設定方式如下:

products: [
    .library(name: "libGrabber", targets: ["libGrabber"]),
    .executable(name: "SwiftGrabber", targets: ["SwiftGrabber"])
],

針對每個 product ,你都必須透過 targets: 這個參數指定它們來源模組。而這兩條設定檔會在 Xcode project 裡面產生兩個 product :

product

如果你的這個專案沒有要把 libGrabber 這個 library 分享給別的專案,那你可以把它從 product 裡面移除。相同地,如果你只想做一個 library,而不是 Command Line Tool,那你可以把 executable 移除。

最後我們的 Package.swift 檔案會長這樣:

import PackageDescription

let package = Package(

    name: "SwiftGrabber",

    products: [
        .library(name: "libGrabber", targets: ["libGrabber"]),
        .executable(name: "SwiftGrabber", targets: ["SwiftGrabber"])
    ],
    dependencies: [],
    targets: [
        .target(name: "SwiftGrabber", dependencies: ["libGrabber"]),
        .target(name: "libGrabber", dependencies: []),
        .testTarget(name: "SwiftGrabberTests", dependencies: ["libGrabber", "SwiftGrabber"]),
    ]
)

想了解 Package.swift 相關的更多設定,你可以參考官方的文件:swift-package-manager/PackageDescriptionV4.md at master · apple/swift-package-manager · GitHub

在 Xcode 上面撰寫 Swift package

雖然用 vi 寫 Swift package 很潮而且完全可行,但是身為一個 iOS 工程師,用 trackpad 流暢地滾動 code 絕對是比較優雅的(是這樣嗎?)。當然這些不構成我們想用 Xcode 寫 Swift package 的理由,但是 Xcode 對 SPM 的整合相當不錯,你可以透過 Xcode 加上原本你已經很習慣的快速鍵,在不切換程式的情況下完成建置與測試;而且在撰寫的過程中就可以得到 compiler 的反饋(這可以說是Swift 語法的精髓),所以我非常推薦使用 Xcode 來完成你的 SPM 專案。

SPM 也早就想到這點,所以提供了一個非常好的工具來幫助你自動生成 Xcode project,這個工具會完成以下的動作:

  • 檢查 Package.swift 裡定義了那些 dependency,將 dependency 引入 project 檔
  • 針對 package.swift 定義的 target,在 project 檔裡產生對應的 target
  • 針對 package.swift 定義的 product,在 project 檔裡產生對應的 product

要注意的是第二點 target 這一項,你需要在 Sources 這個資料夾裡面有對應的子資料夾,而且資料夾裡面要有 .swift 檔,才會自動產生 target。因為我們剛剛在 Package.swift 裡加了一個 target libGrabber,所以我們現在需要設定對應的子資料夾。我們的 Sources 資料夾裡原本有這些資料:

├── Sources
│ └── SwiftGrabber
│…. └── main.swift

因為我們剛剛定義了 libGrabber 這個 target,所以我們需要再新增一個資料夾,並且放上一個 .swift 檔:

> cd Sources
> mkdir libGrabber 
> touch libGrabber/Network.swift  #新增一個空白檔案

現在我們的資料夾會長得像這樣

├── Sources
│ └── SwiftGrabber
│…. └── main.swift
│ └── libGrabber
│…. └── Network.swift

這樣就正確地對應 Package.swift 裡的 target 了。

另外一個需要注意的地方,就是如果資料夾裡有 main.swift,這個 target 在 Xcode project 裡就會被轉成 Command Line Tool target,如果一開始在 init package 時沒有加上 --type executable,但又希望它在 Xcode 裡面用 Command Line Tool target 的設定,在這邊就要手動加入 main.swift 到對應的資料夾,這樣 SPM 才有辦法判斷該用怎樣的設定。

Target 的 module 沒問題了,那 testTarget 呢?testTarget 定義了跟單元測試相關的 module,原始碼會被放置在 Tests 裡面。所以一樣的,我們也要在 Tests 資料夾裡面,建立資料夾並放上一個空白的 .swift 檔。最後我們的 project 資料夾會長這樣:

├── Sources
│ └── SwiftGrabber
│ └── main.swift
│ └── libGrabber
│ └── Network.swift
├── Tests
│ └── SwiftGrabberTests
│ └── NetworkTests.swift

一切都準備就緒後,就可以來啟動我們的自動化生產程序了🚀:

> swift package generate-xcodeproj

如果出現 generated: ./SwiftGrabber.xcodeproj 就表示成功產生 Xcode project 檔囉!

好的,現在來試射一下我們初步建造好的火箭。打開你的 SwiftGrabber.xcodeproj,在 main.swift 裡面加上:

print("yo yo yo world!")

並且在 Xcode 裡設定好 scheme 跟 device:

scheme_device

恭喜!你可以在 Xcode 的 console 裡面看到你第一個程式的執行結果了!

result

動手製作 SwiftGrabber

現在我們的主架構已經出來了,但是要怎樣開始撰寫我們的開發者工具呢?回顧一下,我們希望這個工具可以做到:

  • 接收從 Command Line 來的參數
  • 跟據參數,連上對應的 server,並且下載 response 存到電腦裡

了解到我們的任務之後,接下來就要一步一步地講解如何完成上面的這些功能。

與系統溝通:Argument 與 Environmental Variable

在撰寫 Command Line Tool 時,最重要的,就是你需要知道如何獲取指令參數 (argument) 和環境變數 (environment variable),這兩樣是 Command Line Tool 最常見的取得參數的方式。而取得的方式也非常簡單:

Argument

取用方式:

let arguments: [String] = CommandLine.arguments

如果在 Command Line 上面下這樣的指令:

swiftGrabber -u https://www.google.com

上面的 arguments 變數就會是這樣的 array:

[... "-u", "https://www.google.com" ...]

Environment Variable

取用方式:

let envVariable: [String: String] = ProcessInfo.processInfo.environment

當你有一個這樣的環境變數:

export ACCESS_TOKEN="1234567890" 

透過上面的程式你就可以得到:

[... "ACCESS_TOKEN": "1234567890" ...]

看起來非常簡單。不過如果我們都是用 Xcode 開發,在 Xcode 裡面用 ⌘+R 執行看結果,而不是透過 Command Line 手動 run 起來的話,要怎麼指定這些參數?我們可以透過 Scheme 的 Run Argument 來做到代入參數的效果!首先,先打開 Scheme 的編輯頁面(注意是 Command Line Tool 的那個 Scheme: SwiftGrabber):

scheme

然後在 Run 這個項目裡,找到 Arguments 這個 tab:

arguments

你就可以在 Arguments Passed On Launch 裡面設定指令參數(注意有順序之分),在 Environment Variable 裡設定環境變數了!這些參數設定會在你 Run 這個 Scheme 的時候被代入,是不是非常方便!

主程式:main.swift

接下來我們要透過 main.swift,大略了解如何組織一個 Command Line Tool。main.swift 是整個 Swift 專案的起始點,一個程式被執行的時候,都會先從這個檔案開始做起。它的原始碼很簡單:

import Foundation
import libGrabber

// 1
let coordinator = GrabberCoordinator()
let commandlineHelper = CommandLineHelper()

do {
    try commandlineHelper.parse()
} catch let e {
    print(e) // 這邊也可以利用[fileHandleWithStandardError](https://developer.apple.com/documentation/foundation/nsfilehandle/1411001-filehandlewithstandarderror)寫入stderror
    exit(1)
}

let configuration = GrabberCoordinator.RequestConfiguration(token: commandlineHelper.parsedParameter.token, url: commandlineHelper.parsedParameter.url)

coordinator.outputPath = commandlineHelper.parsedParameter.outputPath

// 2
coordinator.start(configuration: configuration, success: {
    print("Success")
    exit(0)
}) { (errorMessage) in
    print(errorMessage)
    exit(1)
}

// 3
RunLoop.main.run()

依序來看看這段程式碼做了那些事。

1 ── 一開始這邊我們定義了兩種物件: GrabberCoordinatorCommandLineHelper。GrabberCoordinator 被定義在 libGrabber 這個模組裡,負責協調下載跟儲存 JSON。而 CommandLineHelper 則被定義在另外一個模組 SwiftGrabber 裡,負責解析 Command Line 的參數。這整個區段主要就是解析參數,並且包裝這些參數準備給後面的程式使用。

2 ── GrabberCoordinator 主要負責商業邏輯,也就是讀取參數、下載、儲存,它定義了一個 public function 供外部 (main.swift) 使用:

    // in GrabberCoordinator.swift
    public func start(configuration: RequestConfiguration, success: @escaping () -> Void, failure: @escaping (String) -> Void) {
        let url: String = configuration.url
        let token: String = configuration.token

        let header = ["apiKey": token]
        networkService.fetch(url: url, header: header) { [weak self] data in
            if let data = data {
                let outputPath = self?.outputPath ?? "./"
                self?.storageService.save(data: data, to: outputPath, name: "results")
                self?.success?()
            } else {
                self?.failure?("No response or output path is invalid!")
            }
        }
    }

可以看到這段 code 主要就是取得參數後,協調 networkService 做下載、storageService 做儲存,最後透過 closure 把結果回傳給 main.swift。

3 ── 只要 main.swift 的內容執行完畢,程式就會結束,但是問題是我們的程式需要跟 server 溝通並且等待回應,我們發出的 request 如果是非同步的,因為 request 已經被分發到不同 thread 了,所以執行 main.swift 的 thread 會直接執行到最後並且跳出。在這邊有兩個主要方法可以解決,第一個當然就是發出同步處理的 request,考慮到這個 libGrabber 未來會被其它專案使用,如果我們發同步處理的 request,對於使用者體驗或架構上來說,都不是一個好的選擇,所以我們不會考慮這個做法。第二個方法就是在程式的結尾處,加上一個阻檔的機制,也就是上面程式碼看到的 RunLoop.main.run()。這個 runloop 可以確保你的程式在最後會進入等待的狀態,這樣我們就可以放心地發出非同步的 request,並且等到任務完成或失敗的時候,再通知系統跳出程式(參考 code 裡的 //2)。同時因為這個 runloop 是加在 main.swift 裡面,不會影響 libGrabber 的行為,當然也就不會影響到未來其它專案的使用了。

對於 NetworkService 或是 StorageServer 的實作這邊就不贅述,有興趣的話可以參考原始碼

也來寫點單元測試

得益於 Module 化的結構、和與 Xcode 的高度整合性,SPM 可以很方便地為它寫測試。回想一下,我們的 SwiftGrabber project,總共由兩個 Module 組成,一個是 SwiftGrabber,主要負責 Command Line 相關的工作,另一個 libGrabber 負責底層與商業邏輯的實作。今天我們就以幫 libGrabber 為例,來幫它加上一點簡單的測試。

首先,我們打開 Scheme 的選單,編輯 SwiftGrabber 這個 Scheme。

edit scheme

打開編輯頁面後,點下 Test 的 tab,就會看到跟測試相關的 Scheme 設定。一開始 Command Line Tool 這個 Scheme(SwiftGrabber) 是沒有包含測試 target 的,所以我們點下下方的 ”+” 號,加上一個 test target。在這邊會有這個 test target,其實就是因為我們一開始在 Package.swift 裡的 .testTarget(name:dependencies:) 就有設定,所以 SPM 自動生成的。

test target

以目前 SPM 的版本 (Swift 4) 來說,如果你的 SPM 類型有包含 library,SPM 是會直接把 test target 加到 package 的 scheme 裡 (SwiftGrabber-Package),不過 executable 的話我們就需要自己加上去。

設定完畢後,我們可以直接在 Xcode 按下 ⌘+U,就可以執行單元測試了。

產生可執行檔 executable binary file

最後,等到 code 都撰寫完畢後,就可以來建置我們的可執行檔了。製作的方法也很簡單,就只要在我們的專案目錄裡執行:

swift build -c release --static-swift-stdlib

-c 這個參數是指要用專案的哪一個 configuration。而 --static-swift-stdlib 則是指定把 Swift 標準函式庫做靜態連結,這樣可以避免系統 Swift 版本改變或不存在等相容性的問題。Swift build 後的 binary 檔會被放在 .build/release 這個資料夾裡,我們可以直接把這個資料夾裡的 SwiftGrabber 複製出來,放到某個方便取用的地方,像是 iOS 專案的目錄底下等。未來我們就可以直接下 Command Line 執行我們的程式,從 server 下載 JSON 檔了。

讓電腦幫你工作:定時執行

雖然現在執行檔很方便,隨時都可以執行,但別忘了我們最終的目標,就是讓這個任務定時被執行,所以我們需要一個能夠定時啟動我們的程式的工具:crontab。這是一個 unit-like 系統內建的工具,目的就是定時啟動系統的某個程式。我們需要透過以下指令來設定任務:

crontab -e

這個會用 vi 打開 crontab 的設定檔。假設我們要設定的是每一個小時都會啟動這個任務一次,那麼你可以在 crontab 編輯介面裡面加上一行:

0 * * * * swiftGrabber -u <url> -o <output_path>

前面的 0 * * * * 指的是你想要設定的時間,它的格式是 分 時 日 月 星期* 是指全部可能的時間,所以以我們這樣的設定,指的是時鐘的分指到 ”0” 的時候,就執行後面的 task,也就是每小時跑一次。當然你的執行檔也是可以直接拿到其它排程工具跑,像是 Jenkins 或是 mac 的 Automator,做更複雜的整合,這些都是沒有問題的。

結論

SPM 是 Swift 官方支援的生態系,而且從目前的架構來看,它被切分得很簡單、彈性很高,又不需要太多額外的處理,更重要的是,它也原生支援 linux 系統;這讓它變成一個非常有潛力的原始碼管理系統。當然目前它在設定和設計上還有一些可以改良的地方,不過整體來說,還是一個穩定好用的工具。

雖然 iOS 工程師不像後端工程師或是 devOps 工程師,需要非常深入的系統管理知識,但是了解如何透過一些簡單的工具來改善開發流程,還是非常重要的。如果能夠讓一些瑣事都自動化,生產力自然就會提高,千萬不要小看這些手動的日常瑣事,除了一年後累積下來的時間很驚人之外,還要考慮工程師最討厭的 context switch 的時間,也就是轉換手邊工作後,需要一點時間才能進入真正工作狀態。這些都是在改善自己或是團隊生產力時,必須要考慮的要素。希望在這篇文章拋磚引玉之後,你也能夠幫自己或團隊建立更完整方便的開發生態系!

以上所有的原始碼都可以在這邊找到:GitHub – koromiko/SwiftGrabber: Folks, grab it!

工作環境為 Xcode 9.2 Swift 4.0。

相關資源

這邊列了一些相關的資源,其中推薦大家一定要看一下 WWDC 的影片,大多教學資源對於 SPM 的結構都沒有太清楚的著墨,但 WWDC 的影片卻是很清楚地描述了 SPM 目前架構,甚至包括以後可能會發展的方向,很值得一看!

作者
Huang ShihTing
I’m ShihTing Huang(黃士庭). I brew iOS app, front-end web app, and of course, coffee and beer!
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。