前言
眾所周知,現在App的競爭已經到了使用者體驗為王,質量為上的白熱化階段。使用者們都是很挑剔的。如果一個公司的推廣團隊好不容易砸了重金推廣了一個APP,好不容易有了一些使用者,由於一次線上的bug導致一批的使用者在使用中紛紛出現閃退bug,輕則,很可能前期推廣砸的錢都白費了,重則,口碑不好,未來也提升不起使用者量來了。靜下心來分析一下問題的原因,無外乎就是質量沒有過關就上線了。除去主觀的一些因素,很大部分的客觀因素我覺得可以被我們防範的。根據大神們提出的一套開發規範建議,CI + FDD,就可以幫助我們極大程度的解決客觀因素。本文接下來主要討論 Continuous Integration 持續整合(簡稱CI)
目錄
- 1.為什麼我們需要持續整合
- 2.持續化整合工具——Jenkins
- 3.iOS自動化打包命令——xcodebuild + xcrun 和 fastlane – gym 命令
- 4.打包完成自動化上傳 fir / 蒲公英 第三方平臺
- 5.完整的持續整合流程
- 6.Jenkins + Docker
一. 為什麼我們需要持續整合
談到為什麼需要的問題,我們就需要從什麼是來說起。那什麼是持續整合呢。
持續整合是一種軟體開發實踐:許多團隊頻繁地整合他們的工作,每位成員通常進行日常整合,進而每天會有多種整合。每個整合會由自動的構建(包括測試)來儘可能快地檢測錯誤。許多團隊發現這種方法可以顯著的減少整合問題並且可以使團隊開發更加快捷。
CI是一種開發實踐。實踐應該包含3個基本模組,一個可以自動構建的過程,自動編譯程式碼,可以自動分發,部署和測試。一個程式碼倉庫,SVN或者Git。最後一個是一個持續整合的伺服器。通過持續整合,可以讓我們通過自動化等手段高頻率地去獲取產品反饋並響應反饋的過程。
那麼持續整合能給我們帶來些什麼好處呢?這裡推薦一篇文章,文章中把Continuous integration (CI) and test-driven development (TDD)分成了12個步驟。然而帶來的好處成倍增加,有24點好處。
我來說說用了CI以後帶來的一些深有體會的優點。
1. 縮減開發週期,快速迭代版本
每個版本開始都會估算好開發週期,但是總會因為各種事情而延期。這其中包括了一些客觀因素。由於產品線增多,迭代速度越來越快,給測試帶來的壓力也越來越大。如果測試都在開發完全開發完成之後再來測試,那就會影響很長一段時間。這時候由於整合晚就會嚴重拖慢專案節奏。如果能儘早的持續整合,儘快進入上圖的12步驟的迭代環中,就可以儘早的暴露出問題,提早解決,儘量在規定時間內完成任務。
2. 自動化流水線操作帶來的高效
其實打包對於開發人員來說是一件很耗時,而且沒有很大技術含量的工作。如果開發人員一多,相互改的程式碼衝突的機率就越大,加上沒有產線管理機制,程式碼倉庫的程式碼質量很難保證。團隊裡面會花一些時間來解決衝突,解決完了衝突還需要自己手動打包。這個時候如果證照又不對,又要耽誤好長時間。這些時間其實可以用持續整合來節約起來的。一天兩天看著不多,但是按照年的單位來計算,可以節約很多時間!
3. 隨時可部署
有了持續整合以後,我們可以以天為單位來打包,這種高頻率的整合帶來的最大的優點就是可以隨時部署上線。這樣就不會導致快要上線,到處是漏洞,到處是bug,手忙腳亂弄完以後還不能部署,嚴重影響上線時間。
4. 極大程度避免低階錯誤
我們可以犯錯誤,但是犯低階錯誤就很不應該。這裡指的低階錯誤包括以下幾點:編譯錯誤,安裝問題,介面問題,效能問題。
以天為單位的持續整合,可以很快發現編譯問題,自動打包直接無法通過。打完包以後,測試掃碼無法安裝,這種問題也會立即被暴露出來。介面問題和效能問題就有自動化測試指令碼來發現。這些低階問題由持續整合來暴露展現出來,提醒我們避免低階錯誤。
二. 持續化整合工具——Jenkins
Jenkins 是一個開源專案,提供了一種易於使用的持續整合系統,使開發者從繁雜的整合中解脫出來,專注於更為重要的業務邏輯實現上。同時 Jenkins 能實施監控整合中存在的錯誤,提供詳細的日誌檔案和提醒功能,還能用圖表的形式形象地展示專案構建的趨勢和穩定性。
根據官方定義,Jenkins有以下的用途:
- 構建專案
- 跑測試用例檢測bug
- 靜態程式碼檢測
- 部署
關於這4點,實際使用中還是比較方便的:
1.構建專案自動化打包可以省去開發人員好多時間,重要的是,Jenkins為我們維護了一套高質量可用的程式碼,而且保證了一個純淨的環境。我們經常會出現由於本地配置出錯而導致打包失敗的情況。現在Jenkins就是一個公平的評判者,它無法正確的編譯出ipa,那就是有編譯錯誤或者配置問題。開發人員沒必要去爭論本地是可以執行的,拉取了誰誰誰的程式碼以後就不能執行了。共同維護Jenkins的正常編譯,因為Jenkins的編譯環境比我們本地簡單的多,它是最純淨無汙染的編譯環境。開發者就只用專注於編碼。這是給開發者帶來的便利。
2.這個可以用來自動化測試。在本地生成大批的測試用例。每天利用伺服器不斷的跑這些用例。每天每個介面都跑一遍。看上去沒必要,但是實際上今天執行正常的系統,很可能由於今天的程式碼改動,明天就出現問題了。有了Jenkins可以以天為單位的進行迴歸測試,程式碼只要有改動,Jenkins就把所有的迴歸測試的用例全部都跑一遍。在專案工期緊張的情況下,很多情況測試都不是很重視迴歸測試,畢竟很可能測一遍之後是徒勞的“無用功”。然而由於迴歸測試不及時,就導致到最後發版的時候系統不可用了,這時候回頭查詢原因是比較耗時的,檢視提交記錄,看到上百條提交記錄,排查起來也是頭疼的事情。以天為單位的迴歸測試能立即發現問題。測試人員每天可以專注按單元測試,一週手動一次迴歸測試。這是給測試者帶來的便利。
3.這個是靜態程式碼分析,可以檢測出很多程式碼的問題,比如潛在的記憶體洩露的問題。由於Jenkins所在環境的純淨,還是可以發現一些我們本地複雜環境無法發現的問題,進一步的提高程式碼質量。這是給質檢帶來的便利。
4.隨時部署。Jenkins在打包完成之後可以設定之後的操作,這個時候往往就是提交app到跑測試用例的系統,或者部署到內測平臺生成二維碼。部署中不能安裝等一些低階問題隨之立即暴露。測試人員也只需要掃一下二維碼即可安裝,很方便。這也算是給測試帶來的便利。
以下的例子以2016-07-24 22:35的Weekly Release 2.15的版本為例。
我們來開始安裝Jenkins。從官網https://jenkins.io/ 上下載最新的pkg安裝包。
也可以下載jenkins.war, 然後執行Java -jar jenkins.war,進行安裝。
安裝完成之後,Safari可能會自動開啟,如果沒有自動開啟,開啟瀏覽器,輸入http://localhost:8080
這個時候可能會報一個錯誤。如果出現了這面的問題。出現這個問題的原因就是Java環境有問題,重新Java環境即可。
這個時候如果你重啟電腦會發現Jenkins給你新增了一個使用者,名字就叫Jenkins,不過這個時候你不知道密碼。你可能會去試密碼,肯定是是不對的,因為初始密碼很複雜。這個時候正確做法是開啟http://localhost:8080 會出現下圖的重設初始密碼的介面。
按照提示,找到/Users/Shared/Jenkins/Home/ 這個目錄下,這個目錄雖然是共享目錄,但是有許可權的,非Jenkins使用者/secrets/目錄是沒有讀寫許可權的。
開啟initialAdminPassword檔案,複製出密碼,就可以填到網頁上去重置密碼了。如下圖
一路安裝過來,輸入使用者名稱,密碼,郵件這些,就算安裝完成了。
還是繼續登入localhost:8080 ,選擇“系統管理”——“管理外掛”,我們要先安裝一些輔助外掛。
安裝GitLab外掛
因為我們用的是GitLab來管理原始碼,Jenkins本身並沒有自帶GitLab外掛,所以我們需要依次選擇 系統管理->管理外掛,在“可選外掛”中選中“GitLab Plugin”和“Gitlab Hook Plugin”這兩項,然後安裝。
安裝Xcode外掛
同安裝GitLab外掛的步驟一樣,我們依次選擇系統管理->管理外掛,在“可選外掛”中選中“Xcode integration”安裝。
安裝完了這個,我們就可以配置一個構建專案了。
點選新建好的專案,進來配置一下General引數。
這裡可以設定包的保留天數還有天數。
接著設定原始碼管理。
由於現在我用到的是GitLab,先配置SSH Key,在Jenkins的證照管理中新增SSH。在Jenkins管理頁面,選擇“Credentials”,然後選擇“Global credentials (unrestricted)”,點選“Add Credentials”,如下圖所示,我們填寫自己的SSH資訊,然後點選“Save”,這樣就把SSH新增到Jenkins的全域性域中去了。
如果正常的配置正確的話,是不會出現下圖中的那段紅色的警告。如果有下圖的提示,就說明Jenkins還沒有連通GitLab或者SVN,那就請再檢查SSH Key是否配置正確。
構建觸發器設定這裡是設定自動化測試的地方。這裡涉及的內容很多,暫時我也沒有深入研究,這裡暫時先不設定。有自動化測試需求的可以好好研究研究這裡的設定。
不過這裡有兩個配置還是需要是配置的
Poll SCM (poll source code management) 輪詢原始碼管理
需要設定原始碼的路徑才能起到輪詢的效果。一般設定為類似結果: 0/5 每5分鐘輪詢一次
Build periodically (定時build)
一般設定為類似: 00 20 * 每天 20點執行定時build 。當然兩者的設定都是一樣可以通用的。
格式是這樣的
分鐘(0-59) 小時(0-23) 日期(1-31) 月(1-12) 周幾(0-7,0和7都是週日) 更加詳細的設定看這裡
構建環境設定
iOS打包需要簽名檔案和證照,所以這部分我們勾選“Keychains and Code Signing Identities”和“Mobile Provisioning Profiles”。
這裡我們又需要用到Jenkins的外掛,在系統管理頁面,選擇“Keychains and Provisioning Profiles Management”。
進入Keychains and Provisioning Profiles Management頁面,點選“瀏覽”按鈕,分別上傳自己的keychain和證照。上傳成功後,我們再為keychain指明簽名檔案的名稱。點選“Add Code Signing Identity”,最後新增成功後如下圖所示:
注意:我第一次匯入證照和Provisioning Profiles檔案,就遇到了一點小“坑”,我當時以為是需要證照,但是這裡需要的Keychain,並不是cer證照檔案。這個Keychain其實在/Users/管理員使用者名稱/Library/keychains/login.keychain,當把這個Keychain設定好了之後,Jenkins會把這個Keychain拷貝到/Users/Shared/Jenkins/Library/keychains這裡,(Library是隱藏檔案)。Provisioning Profiles檔案也直接拷貝到/Users/Shared/Jenkins/Library/MobileDevice檔案目錄下。
這樣Adhoc證照和簽名檔案就在Jenkins中配置好了,接下來我們只需要在item設定中指定相關檔案即可。
回到我們新建的item,找到構建環境,按下圖選好自己的相關證照和簽名檔案。
接下來在進行構建的設定
我們這裡選擇執行一段打包指令碼。指令碼在下一章節詳細的講解。
構建後操作
這裡我們選擇Execute a set of scripts,這裡也是一個指令碼,這個指令碼用來上傳自動打包好的ipa檔案。指令碼在第四章節有詳細的講解。
至此,我們的Jenkins設定就全部完成了。點選構建,就會開始構建專案了。
構建一次,各個顏色代表的意義如下:
天氣的晴雨表代表了專案的質量,這也是Jenkins的一個特色。
如果構建失敗了,可以去檢視Console Output可以檢視log日誌。
三. iOS自動化打包命令——xcodebuild + xcrun 和 fastlane – gym 命令
在日常開發中,打包是最後上線不可缺少的環節,如果需要把工程打包成 ipa 檔案,通常的做法就是在 Xcode 裡點選 「Product -> Archive」,當整個工程 archive 後,然後在自動彈出的 「Organizer」 中進行選擇,根據需要匯出 ad hoc,enterprise 型別的 ipa 包。雖然Xcode已經可以很完美的做到打包的事情,但是還是需要我們手動點選5,6下。加上我們現在需要持續整合,用打包命令自動化執行就順其自然的需要了。
1. xcodebuild + xcrun命令
Xcode為我們開發者提供了一套構建打包的命令,就是xcodebuild
和xcrun命令。xcodebuild把我們指定的專案打包成.app檔案,xcrun將指定的.app檔案轉換為對應的.ipa檔案。
具體的文件如下, xcodebuild官方文件、xcrun官方文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
NAME xcodebuild – build Xcode projects and workspaces SYNOPSIS 1. xcodebuild [-project name.xcodeproj] [[-target targetname] … | -alltargets] [-configuration configurationname] [-sdk [sdkfullpath | sdkname]] [action …] [buildsetting=value …] [-userdefault=value …] 2. xcodebuild [-project name.xcodeproj] -scheme schemename [[-destination destinationspecifier] …] [-destination-timeout value] [-configuration configurationname] [-sdk [sdkfullpath | sdkname]] [action …] [buildsetting=value …] [-userdefault=value …] 3. xcodebuild -workspace name.xcworkspace -scheme schemename [[-destination destinationspecifier] …] [-destination-timeout value] [-configuration configurationname] [-sdk [sdkfullpath | sdkname]] [action …] [buildsetting=value …] [-userdefault=value …] 4. xcodebuild -version [-sdk [sdkfullpath | sdkname]] [infoitem] 5. xcodebuild -showsdks 6. xcodebuild -showBuildSettings [-project name.xcodeproj | [-workspace name.xcworkspace -scheme schemename]] 7. xcodebuild -list [-project name.xcodeproj | -workspace name.xcworkspace] 8. xcodebuild -exportArchive -archivePath xcarchivepath -exportPath destinationpath -exportOptionsPlist path 9. xcodebuild -exportLocalizations -project name.xcodeproj -localizationPath path [[-exportLanguage language] …] 10. xcodebuild -importLocalizations -project name.xcodeproj -localizationPath path |
上面10個命令最主要的還是前3個。
接下來來說明一下引數:
-project -workspace:這兩個對應的就是專案的名字。如果有多個工程,這裡又沒有指定,則預設為第一個工程。
-target:打包對應的targets,如果沒有指定這預設第一個。
-configuration:如果沒有修改這個配置,預設就是Debug和Release這兩個版本,沒有指定預設為Release版本。
-buildsetting=value …:使用此命令去修改工程的配置。
-scheme:指定打包的scheme。
上面這些是最最基本的命令。
上面10個命令的第一個和第二個裡面的引數,其中 -target
和 -configuration 引數可以使用 xcodebuild -list
獲得,-sdk 引數可由 xcodebuild -showsdks
獲得,[buildsetting=value …] 用來覆蓋工程中已有的配置。可覆蓋的引數參考官方文件 Xcode Build Setting Reference。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
build Build the target in the build root (SYMROOT). This is the default action, and is used if no action is given. analyze Build and analyze a target or scheme from the build root (SYMROOT). This requires specifying a scheme. archive Archive a scheme from the build root (SYMROOT). This requires specifying a scheme. test Test a scheme from the build root (SYMROOT). This requires specifying a scheme and optionally a destination. installsrc Copy the source of the project to the source root (SRCROOT). install Build the target and install it into the target’s installation directory in the distribution root (DSTROOT). clean Remove build products and intermediate files from the build root (SYMROOT). |
上面第3個命令就是專門用來打帶有Cocopods的專案,因為這個時候專案工程檔案不再是xcodeproj了,而是變成了xcworkspace了。
再來說說xcrun命令。
1 2 3 4 5 6 7 8 9 10 11 |
Usage: PackageApplication [-s signature] application [-o output_directory] [-verbose] [-plugin plugin] || -man || -help Options: [-s signature]: certificate name to resign application before packaging [-o output_directory]: specify output filename [-plugin plugin]: specify an optional plugin -help: brief help message -man: full documentation -v[erbose]: provide details during operation |
引數不多,使用方法也很簡單,xcrun -sdk iphoneos -v PackageApplication + 上述一些引數。
引數都瞭解之後,我們就來看看該如何用了。下面這個是使用了xcodebuild + xcrun命令寫的自動化打包指令碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
# 工程名 APP_NAME="YourProjectName" # 證照 CODE_SIGN_DISTRIBUTION="iPhone Distribution: Shanghai ******* Co., Ltd." # info.plist路徑 project_infoplist_path="./${APP_NAME}/Info.plist" #取版本號 bundleShortVersion=$(/usr/libexec/PlistBuddy -c "print CFBundleShortVersionString" "${project_infoplist_path}") #取build值 bundleVersion=$(/usr/libexec/PlistBuddy -c "print CFBundleVersion" "${project_infoplist_path}") DATE="$(date +%Y%m%d)" IPANAME="${APP_NAME}_V${bundleShortVersion}_${DATE}.ipa" #要上傳的ipa檔案路徑 IPA_PATH="$HOME/${IPANAME}" echo ${IPA_PATH} echo "${IPA_PATH}">> text.txt //下面2行是沒有Cocopods的用法 echo "=================clean=================" xcodebuild -target "${APP_NAME}" -configuration 'Release' clean echo "+++++++++++++++++build+++++++++++++++++" xcodebuild -target "${APP_NAME}" -sdk iphoneos -configuration 'Release' CODE_SIGN_IDENTITY="${CODE_SIGN_DISTRIBUTION}" SYMROOT='$(PWD)' //下面2行是整合有Cocopods的用法 echo "=================clean=================" xcodebuild -workspace "${APP_NAME}.xcworkspace" -scheme "${APP_NAME}" -configuration 'Release' clean echo "+++++++++++++++++build+++++++++++++++++" xcodebuild -workspace "${APP_NAME}.xcworkspace" -scheme "${APP_NAME}" -sdk iphoneos -configuration 'Release' CODE_SIGN_IDENTITY="${CODE_SIGN_DISTRIBUTION}" SYMROOT='$(PWD)' xcrun -sdk iphoneos PackageApplication "./Release-iphoneos/${APP_NAME}.app" -o ~/"${IPANAME}" |
2. gym 命令
說到gym,就要先說一下fastlane。
fastlane是一套自動化打包的工具集,用 Ruby 寫的,用於 iOS 和 Android 的自動化打包和釋出等工作。gym是其中的打包命令。
fastlane 的官網看這裡, fastlane 的 github 看這裡
要想使用gym,先要安裝fastlane。
1 |
sudo gem install fastlane --verbose |
fastlane包含了我們日常編碼之後要上線時候進行操作的所有命令。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
deliver:上傳螢幕截圖、二進位制程式資料和應用程式到AppStore snapshot:自動擷取你的程式在每個裝置上的圖片 frameit:應用截圖外新增裝置框架 pem:可以自動化地生成和更新應用推送通知描述檔案 sigh:生成下載開發商店的配置檔案 produce:利用命令列在iTunes Connect建立一個新的iOS app cert:自動建立iOS證照 pilot:最好的在終端管理測試和建立的檔案 boarding:很容易的方式邀請beta測試 gym:建立新的釋出的版本,打包 match:使用git同步你成員間的開發者證照和檔案配置 scan:在iOS和Mac app上執行測試用例 整個釋出過程可以用fastlane描述成下面這樣 lane :appstore do increment_build_number cocoapods xctool snapshot sigh deliver frameit sh "./customScript.sh" slack end |
Ps:這裡可能大家還會聽過一個命令叫 xctool
xctool是官方xcodebuild命令的一個增強實現,輸出的內容比xcodebuild直觀可讀得多。通過brew即可安裝。
1 |
brew install xctool |
使用gym自動化打包,指令碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
#計時 SECONDS=0 #假設指令碼放置在與專案相同的路徑下 project_path=$(pwd) #取當前時間字串新增到檔案結尾 now=$(date +"%Y_%m_%d_%H_%M_%S") #指定專案的scheme名稱 scheme="DemoScheme" #指定要打包的配置名 configuration="Adhoc" #指定打包所使用的輸出方式,目前支援app-store, package, ad-hoc, enterprise, development, 和developer-id,即xcodebuild的method引數 export_method='ad-hoc' #指定專案地址 workspace_path="$project_path/Demo.xcworkspace" #指定輸出路徑 output_path="/Users/your_username/Documents/" #指定輸出歸檔檔案地址 archive_path="$output_path/Demo_${now}.xcarchive" #指定輸出ipa地址 ipa_path="$output_path/Demo_${now}.ipa" #指定輸出ipa名稱 ipa_name="Demo_${now}.ipa" #獲取執行命令時的commit message commit_msg="$1" #輸出設定的變數值 echo "===workspace path: ${workspace_path}===" echo "===archive path: ${archive_path}===" echo "===ipa path: ${ipa_path}===" echo "===export method: ${export_method}===" echo "===commit msg: $1===" #先清空前一次build gym --workspace ${workspace_path} --scheme ${scheme} --clean --configuration ${configuration} --archive_path ${archive_path} --export_method ${export_method} --output_directory ${output_path} --output_name ${ipa_name} #輸出總用時 echo "===Finished. Total time: ${SECONDS}s===" |
四. 打包完成自動化上傳 fir / 蒲公英 第三方平臺
要上傳到 fir / 蒲公英 第三方平臺,都需要註冊一個賬號,獲得token,之後才能進行指令碼化操作。
1. 自動化上傳fir
安裝fir-clifir的命令列工具
需要先裝好ruby再執行
1 |
gem install fir-cli |
1 2 |
#上傳到fir fir publish ${ipa_path} -T fir_token -c "${commit_msg}" |
2.自動化上傳蒲公英
1 2 3 4 5 6 7 8 9 10 11 12 |
#蒲公英上的User Key uKey="7381f97070*****c01fae439fb8b24e" #蒲公英上的API Key apiKey="0b27b5c145*****718508f2ad0409ef4" #要上傳的ipa檔案路徑 IPA_PATH=$(cat text.txt) rm -rf text.txt #執行上傳至蒲公英的命令 echo "++++++++++++++upload+++++++++++++" curl -F "file=@${IPA_PATH}" -F "uKey=${uKey}" -F "_api_key=${apiKey}" http://www.pgyer.com/apiv1/app/upload |
五. 完整的持續整合流程
經過上面的持續化整合,現在我們就擁有了如下完整持續整合的流程
六. Jenkins + Docker
關於Jenkins的部署,其實是分以下兩種:
單節點(Master)部署
這種部署適用於大多數專案,其構建任務較輕,數量較少,單個節點就足以滿足日常開發所需。
多節點(Master-Slave)部署
通常規模較大,程式碼提交頻繁(意味著構建頻繁),自動化測試壓力較大的專案都會採取這種部署結構。在這種部署結構下,Master通常只充當管理者的角色,負責任務的排程,slave節點的管理,任務狀態的收集等工作,而具體的構建任務則會分配給slave節點。一個Master節點理論上可以管理的slave節點數是沒有上限的,但通常隨著數量的增加,其效能以及穩定性就會有不同程度的下降,具體的影響則因Master硬體效能的高低而不同。
但是多節點部署又會有一些缺陷,當測試用例變得海量以後,會造成一些問題,於是有人設計出了下面這種部署結構,Jenkins + Docker
由於筆者現在的專案還處於單節點(Master)部署,關於多節點(Master-Slave)部署也沒有實踐經驗,改進版本的Docker更是沒有接觸過,但是如果有這種海量測試用例,高壓力的大量複雜的迴歸測試的需求的,那推薦大家看這篇文章。
最後
以上就是我關於Jenkins持續整合的一次實踐經驗。分享給大家,如果裡面有什麼錯誤,歡迎大家多多指教。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式