[iOS]貝聊 IAP 實戰之見坑填坑
注意:文章中討論的 IAP 是指使用蘋果內購購買消耗性的專案。
這次為大家帶來我司 IAP 的實現過程詳解,鑑於支付功能的重要性以及複雜性,文章會很長,而且支付驗證的細節也關係重大,所以這個主題會包含三篇。
第一篇:[iOS]貝聊 IAP 實戰之滿地是坑,這一篇是支付基礎知識的講解,主要會詳細介紹 IAP,同時也會對比支付寶和微信支付,從而引出 IAP 的坑和注意點。
第二篇:[iOS]貝聊 IAP 實戰之見坑填坑,這一篇是高潮性的一篇,主要針對第一篇文章中分析出的 IAP 的問題進行具體解決。
第三篇:[iOS]貝聊 IAP 實戰之訂單繫結,這一篇是關鍵性的一篇,主要講述作者探索將自己伺服器生成的訂單號繫結到 IAP 上的過程。
不用擔心,我從來不會只講原理不留原始碼,我已經將我司的原始碼整理出來,你使用時只需要拽到工程中就可以了,下面開始我們的內容 。
上一篇的分析了 IAP 存在的問題,有九個點。如果你不知道是哪九個點,建議你先去看一下上一篇文章。現在我們根據上一篇總結的問題一個一個來對應解決。
作者寫了一個給 iPhone X 去掉劉海的 APP,而且其他 iPhone 也可以玩,有興趣的話去 App Store 看看。點選前往。
01.越獄的問題
關於越獄導致的問題,總是充滿了不確定性,每個人都不一樣,但是都是受到了攻擊導致的。所以,我們採取的方式簡單粗暴,越獄使用者一律不允許使用 IAP 服務。這裡我也建議你這麼做。我的原始碼中有一個工具類用來檢測使用者是否越獄,類名是 BLJailbreakDetectTool
,裡面只有一個方法:
/**
* 檢查當前裝置是否已經越獄。
*/
+ (BOOL)detectCurrentDeviceIsJailbroken;
如果你不想使用我封裝的方法,也可以使用友盟統計裡有一個方法,如果你的專案接入了友盟統計,你 #import <UMMobClick/MobClick.h>
,裡面有個類方法:
/**
* 判斷裝置是否越獄,依據是否存在apt和Cydia.app
*/
+ (BOOL)isJailbroken;
02.交易訂單的儲存
上一篇文章說到,蘋果只會在交易成功以後通過 - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions
通知我們交易結果,而且一個 APP 生命週期只通知一次,所以我們萬萬不能依賴蘋果的這個方法來驅動收據的查詢。我們要做的是,首先一旦蘋果通知我們交易成功,我們就要將交易資料自己存起來。然後再說然後,這樣一來我們就可以擺脫蘋果通知交易結果一個生命週期只通知一次的噩夢。
那這麼敏感的交易收據,我們存在哪裡呢?存資料庫?存 UserDefault
?使用者一解除安裝 APP 就毛都沒有了。這樣的東西,只有一個地方存最合適,那就是 keychain
。keychain
的特點就是第一安全;第二,繫結 APP ID,不會丟,永遠不會丟,解除安裝 APP 以後重灌,仍然能從 keychain
裡恢復之前的資料。
好,我們現在開始設計我們的儲存工具。在開始之前,我們要使用一個第三方框架 UICKeyChainStore,因為 keychain
是 C 介面,很難用,這個框架對其做了物件導向的封裝。我們現在就基於這個框架進行封裝。
#import <UICKeyChainStore/UICKeyChainStore.h>
#import "BLWalletCompat.h"
NS_ASSUME_NONNULL_BEGIN
@class BLPaymentTransactionModel;
@protocol BLWalletTransactionModelsSaveProtocol<NSObject>
@optional
/**
* 儲存交易模型.
*
* @param models 交易模型. @see `BLPaymentTransactionModel`
* @param userid 使用者 id.
*/
- (void)bl_savePaymentTransactionModels:(NSArray<BLPaymentTransactionModel *> *)models
forUser:(NSString *)userid;
/**
* 刪除指定 `transactionIdentifier` 的交易模型.
*
* @param transactionIdentifier 交易模型唯一標識.
* @param userid 使用者 id.
*
* @return 是否刪除成功. 失敗的原因可能是因為標識無效(已儲存資料中沒有指定的標識的資料).
*/
- (BOOL)bl_deletePaymentTransactionModelWithTransactionIdentifier:(NSString *)transactionIdentifier
forUser:(NSString *)userid;
/**
* 刪除所有的 `transactionIdentifier` 交易模型.
*
* @param userid 使用者 id.
*/
- (void)bl_deleteAllPaymentTransactionModelsIfNeedForUser:(NSString *)userid;
/**
* 獲取所有交易模型, 並排序.
*
* @return models 交易模型. @see `BLPaymentTransactionModel`
* @param userid 使用者 id.
*/
- (NSArray<BLPaymentTransactionModel *> * _Nullable)bl_fetchAllPaymentTransactionModelsSortedArrayUsingComparator:(NSComparator NS_NOESCAPE _Nullable)cmptr
forUser:(NSString *)userid
error:(NSError * __nullable __autoreleasing * __nullable)error;
/**
* 獲取所有交易模型.
*
* @param userid 使用者 id.
*
* @return models 交易模型. @see `BLPaymentTransactionModel`
*/
- (NSArray<BLPaymentTransactionModel *> * _Nullable)bl_fetchAllPaymentTransactionModelsForUser:(NSString *)userid
error:(NSError * __nullable __autoreleasing * __nullable)error;
/**
* 改變某筆交易的驗證次數.
*
* @param transactionIdentifier 交易模型唯一標識.
* @param modelVerifyCount 交易驗證次數.
* @param userid 使用者 id.
*/
- (void)bl_updatePaymentTransactionModelStateWithTransactionIdentifier:(NSString *)transactionIdentifier
modelVerifyCount:(NSUInteger)modelVerifyCount
forUser:(NSString *)userid;
/**
* 儲存某筆交易的訂單號和訂單價格以及 md5 值.
*
* @param transactionIdentifier 交易模型唯一標識.
* @param orderNo 訂單號.
* @param priceTagString 訂單價格.
* @param md5 交易收據是否有變動的標識.
* @param userid 使用者 id.
*/
- (void)bl_savePaymentTransactionModelWithTransactionIdentifier:(NSString *)transactionIdentifier
orderNo:(NSString *)orderNo
priceTagString:(NSString *)priceTagString
md5:(NSString *)md5
forUser:(NSString *)userid;
@end
/**
* 儲存結構為: dict - set - model.
*
* 第一層 data, 是字典的歸檔資料.
* 第二層字典, 以 userid 為 key, set 的歸檔 data.
* 第二層集合, 是所有 model 的歸檔資料.
*/
@interface BLWalletKeyChainStore : UICKeyChainStore<BLWalletTransactionModelsSaveProtocol>
+ (BLWalletKeyChainStore *)keyChainStoreWithService:(NSString *_Nullable)service;
@end
NS_ASSUME_NONNULL_END
我們要儲存的物件是 BLPaymentTransactionModel
,這個物件是一個模型,標頭檔案如下:
#import <Foundation/Foundation.h>
#import "BLWalletCompat.h"
NS_ASSUME_NONNULL_BEGIN
@interface BLPaymentTransactionModel : NSObject<NSCoding>
#pragma mark - Properties
/**
* 事務 id.
*/
@property(nonatomic, copy, nonnull, readonly) NSString *transactionIdentifier;
/**
* 交易時間(新增到交易佇列時的時間).
*/
@property(nonatomic, strong, readonly) NSDate *transactionDate;
/**
* 商品 id.
*/
@property(nonatomic, copy, readonly) NSString *productIdentifier;
/**
* 後臺配置的訂單號.
*/
@property(nonatomic, copy, nullable) NSString *orderNo;
/**
* 價格字元.
*/
@property(nonatomic, copy, nullable) NSString *priceTagString;
/**
* 交易收據是否有變動的標識.
*/
@property(nonatomic, copy, nullable) NSString *md5;
/*
* 任務被驗證的次數.
* 初始狀態為 0,從未和後臺驗證過.
* 當次數大於 1 時, 至少和後臺驗證過一次,並且未能驗證當前交易的狀態.
*/
@property(nonatomic, assign) NSUInteger modelVerifyCount;
#pragma mark - Method
/**
* 初始化方法(沒有收據的).
*
* @warning: 所有資料都必須有值, 否則會報錯, 並返回 nil.
*
* @param productIdentifier 商品 id.
* @param transactionIdentifier 事務 id.
* @param transactionDate 交易時間(新增到交易佇列時的時間).
*/
- (instancetype)initWithProductIdentifier:(NSString *)productIdentifier
transactionIdentifier:(NSString *)transactionIdentifier
transactionDate:(NSDate *)transactionDate;
@end
NS_ASSUME_NONNULL_END
就是一些交易的關鍵資訊。我們在這個物件實現歸檔和解檔的方法以後,就可以將這個物件歸檔成為一段 data
,也可以從一段 data
中解檔出這個物件。同時,我們需要實現這個物件的 -isEqual:
方法,因為,因為我們在進行物件判等的時候,要進行一些關鍵資訊的比對,來確定兩個交易是否是同一筆交易。程式碼太多了,我就不貼上了,細節還需要您自己下載程式碼進去看。
現在回到 keyChain
上來。每個 BLPaymentTransactionModel
物件歸檔成一個 NSData
,多個 data
組成一個集合,再將這個集合歸檔,然後儲存在一個以 userid
為 key 的字典中,然後再對字典進行歸檔,然後再儲存到 keyChain
中。
請記住這個資料歸檔的層級,要不然,實現檔案裡看起來有點懵。
03.驗證佇列
到現在為止我們可以對交易資料進行儲存了,也就是說,一旦 IAP 通知我們有新的成功的交易,我們立馬把這筆交易相關的資料轉換成為一個交易模型,然後把這個模型歸檔存到 keyChain
,這樣我們就能將驗證資料的邏輯獨立出來了,而不用依賴 IAP 的回撥。
現在我們開始考慮如何根據已有的資料來上傳到我們自己的伺服器,從而驅動我們的伺服器向蘋果伺服器的查詢,如下圖所示。
我們可以設計一個佇列,佇列裡有當前需要查詢的交易 model
,然後將 model
組裝成為一個 task
,然後在這個 task
中向我們的伺服器發起請求,根據伺服器返回結果再發起下一次請求,就是上圖的驅動方式 5,這樣形成一個閉環,直到這個佇列中所有的模型都被處理完了,那麼佇列就處於休眠狀態。
而第一次驅動佇列執行的有四種情況。
第一種是初始化的時候,發現 keyChain
中還有沒有處理完需要驗證的交易,那麼此時就開始從 keyChain
動態篩選出資料初始化佇列,初始化完以後,就可以開始向伺服器發起驗證請求了,也就是驅動方式 1。至於為什麼說是動態篩選,因為這裡的任務有優先順序,我們等會再說。
第二種驅動任務執行的方式是,當前佇列處於休眠狀態,沒有任務要執行,此時使用者發起購買,就會直接將當前交易放到任務佇列中,開始向伺服器發起驗證請求,也就是驅動方式 2。
第三種是使用者從沒有網路到有網路的時候,會去對 keyChain
做一次檢查,如果有沒有處理完的交易,一樣會向伺服器發起請求,也就是驅動方式 3。
第四種是使用者從後臺進入前臺的時候,會去對 keyChain
做一次檢查,如果有沒有處理完的交易,一樣會向伺服器發起請求,也就是驅動方式 4。
有了上面四種型別的觸發驗證的邏輯以後,我們就能最大程度保證所有的交易都會向伺服器發起驗證請求,而且是永不停止的進行,直到所有的交易都驗證完才會停止。
剛才說從 keyChain
中取資料有一個動態篩選的操作,這是什麼意思呢?首先,我們向伺服器發起的驗證,不一定成功,如果失敗了,我們就要給這個交易模型打上一個標記,下次驗證的時候,應該優先驗證那些沒有被打上標記的交易模型。如果不打標記,可能會出現一直在驗證同一個交易模型,阻塞了其他交易模型的驗證。
// 動態規劃當前應該驗證哪一筆訂單.
- (NSArray<BLPaymentTransactionModel *> *)dynamicPlanNeedVerifyModelsWithAllModels:(NSArray<BLPaymentTransactionModel *> *) allTransationModels {
// 防止出現: 第一個失敗的訂單一直在驗證, 排隊的訂單得不到驗證.
NSMutableArray<BLPaymentTransactionModel *> *transactionModelsNeverVerify = [NSMutableArray array];
NSMutableArray<BLPaymentTransactionModel *> *transactionModelsRetry = [NSMutableArray array];
for (BLPaymentTransactionModel *model in allTransationModels) {
if (model.modelVerifyCount == 0) {
[transactionModelsNeverVerify addObject:model];
}
else {
[transactionModelsRetry addObject:model];
}
}
// 從未驗證過的訂單, 優先驗證.
if (transactionModelsNeverVerify.count) {
return transactionModelsNeverVerify.copy;
}
// 驗證次數少的排前面.
[transactionModelsRetry sortUsingComparator:^NSComparisonResult(BLPaymentTransactionModel * obj1, BLPaymentTransactionModel * obj2) {
return obj1.modelVerifyCount < obj2.modelVerifyCount;
}];
return transactionModelsRetry.copy;
}
04.壓入新交易
上面驗證佇列裡我還有壓入情景沒有解釋,壓入情景有三種情況。
第一種是出現意外,就是初始化的時候,如果出現使用者剛好交易完,但是 IAP 沒有通知我們交易完成的情況,那麼此時再去 IAP 的交易佇列裡檢查一遍,如果有沒有被持久化到 keyChain
的,就直接壓入 keyChain
中進行持久化,一旦進入 keyChain
中,那麼這筆交易就能被正確處理,這種情況在測試環境下經常出現。
第二種是正常交易,IAP 通知交易完成,此時將交易資料壓入 keyChain
中。
第三種和第一種類似,使用者從後臺進入前臺的時候,也會去檢查一遍沙盒中有沒有沒有持久化的交易,一旦有,就把這些交易壓入 keyChain
中。
上面三個壓入情景,能最大程度上保證我們的持久化資料能和使用者真實的交易同步,從而預防蘋果出現交易成功卻沒有通知我們而導致的 bug。
05.專案結構總結
到現在為止,我們的結構已經有了大體了,現在我們來總結一下我們現在的專案結構。
BLPaymentManager
是交易管理者,負責和 IAP 通訊,包括商品查詢和購買功能,也是交易狀態的監聽者,對接沙盒中收據資料的獲取和更新,是我們整個支付的入口。它是一個單例,我們的驗證佇列是掛在它身上的。每當有新的交易進來的時候(不管是什麼情景進來的),它都會把這筆交易丟給 BLPaymentVerifyManager
,讓 BLPaymentVerifyManager
負責去驗證這筆交易是否有效。最後,BLPaymentVerifyManager
也會和 BLPaymentManager
通訊,告訴 BLPaymentManager
某筆交易的狀態,讓 BLPaymentManager
處理掉指定的交易。
BLPaymentVerifyManager
是驗證交易佇列管理者,它內部有一個需要驗證的交易 task 佇列,它負責管理這些佇列的狀態,並且驅動這些任務的執行,保證每筆交易驗證的先後循序。它的內部有一個 keyChain
,它的佇列中的任務都是從 keyChain
中初始化過來的。同時它也管理著keyChain
中的資料,對keyChain
進行增刪改查等操作,維護keyChain
的狀態。同時也和 BLPaymentManager
通訊,更新交易的狀態(finish 某筆交易)。
keyChain
不用說了,負責交易資料的持久化,提供增刪改查等介面給它的管理者使用。
BLPaymentVerifyTask
負責和伺服器通訊,並且將通訊結果回撥出來給 BLPaymentVerifyManager
,驅動下一個驗證操作。
06.收據不同步處理
有同行反饋說,IAP
有 bug
,這個 bug
就是明明通知交易已經成功了,但是去沙盒中取收據時,發現收據為空,這個問題也是要具體應對的。
現在做了以下的處理,每次和後臺通訊的結果歸為三類,第一類,收據有效,驗證通過;第二類,收據無效,驗證失敗;第三類,發生錯誤,需要重新驗證。每個 task 回來都是隻有可能是這三種情況的一種,然後 task 的回撥會給佇列管理者,佇列管理者會把回撥傳出去給交易管理者,此時交易管理者在下面的代理方法中更新最新的收據,並把新收據重新傳給佇列管理者,佇列管理者下次發起請求就是使用最新的收據進行驗證操作。
@protocol BLPaymentVerifyTaskDelegate<NSObject>
@required
/**
* 驗證收到結果通知, 驗證收據有效.
*/
- (void)paymentVerifyTaskDidReceiveResponseReceiptValid:(BLPaymentVerifyTask *)task;
/**
* 驗證收到結果通知, 驗證收據無效.
*/
- (void)paymentVerifyTaskDidReceiveResponseReceiptInvalid:(BLPaymentVerifyTask *)task;
/**
* 驗證請求出現錯誤, 需要重新請求.
*/
- (void)paymentVerifyTaskUploadCertificateRequestFailed:(BLPaymentVerifyTask *)task;
@end
07.注意點
從 iOS 7 開始,蘋果的收據不是每筆交易一個收據,而是將所有的交易收據組成一個集合放在沙盒中,然後我們在沙盒中取到的收據是當前所有收據的集合,而且我們也不知道當前收據裡都有哪些訂單,我們的後臺也不知道,只有 IAP 伺服器知道。所以,我們不用管收據裡的資料,只要拿出來懟給後臺,後臺再懟給蘋果就可以了。
對於我們提交給後臺的收據,後臺可能會做過期的標記。但是後臺要判斷當前的這個收據是否之前已經上傳過了,這時我們可以做一個 MD5,我們把 MD5 的結果一起上傳給伺服器。
專案裡做了很多報警的處理,比方說我們把收據存到
keyChain
中,儲存完成以後,要做一次檢查,檢查這個資料確實是存進去了,如果沒有,那此時應該報警,並將報警資訊上傳到我們的伺服器,以防出現意外。又比方說,IAP 通知我們交易完成,我們就會去取收據,如果此時收據為空,那絕對出問題了,此時應該報警,並將報警資訊上傳(專案裡已經對這種情況進行了容錯)。還有比如某筆交易驗證了幾十次,還是未能驗證,那此時應該設定一個驗證次數的報警閾值,比方說十次,如果超過十次就報警。在持久化到
keyChain
時,資料是繫結使用者userid
的,這一點也是至關重要,要不然會出現 A 使用者的交易在 B 使用者那裡驗證。對於已經失敗過的驗證請求,每兩次請求之間的時間步長也是應該考慮的。這裡採用的比較簡單的方式,只要是已經和後臺驗證過並且失敗過的交易, 兩次請求之間的時間間隔是
失敗的次數 * BLPaymentVerifyUploadReceiptDataIntervalDelta
。同時也對步長的最大值做了限制,防止步長越來越大,使用者體驗差。還有一些細節,下面兩個方法一定要在按照要求呼叫,否則後果很嚴重。下面的第二個方法,如果使用者已經等錄,重新啟動的時候也要呼叫一次。
/**
* 登出當前支付管理者.
*
* @warning ⚠️ 在使用者退出登入時呼叫.
*/
- (void)logoutPaymentManager;
/**
* 開始支付事務監聽, 並且開始支付憑證驗證佇列.
*
* @warning ⚠️ 請在使用者登入時和使用者重新啟動 APP 時呼叫.
*
* @param userid 使用者 ID.
*/
- (void)startTransactionObservingAndPaymentTransactionVerifingWithUserID:(NSString *)userid;
- 還有一個問題,如果使用者當前還有未得到驗證的交易,那麼此時他退出登入,我們應該給個 UI 上的提示。通過下面這個方法去拿使用者當前是否有未得到驗證的交易。
/**
* 是否所有的待驗證任務都完成了.
*
* @warning error ⚠️ 退出前的警告資訊(比如使用者有尚未得到驗證的訂單).
*/
- (BOOL)didNeedVerifyQueueClearedForCurrentUser;
還有對於支付是序列還是並行的選擇。序列的意思是如果使用者當前有未完成的交易,那麼就不允許進行購買。並行的意思是,當前使用者有未完成的交易,仍然可以進行購買。我提供的原始碼是支援並行的,因為當時設計的時候就考慮到這個問題了。事實上,蘋果對同一個交易標識的產品的購買是序列的,就是你當前有未付款成功的商品 A,當你再次購買這個商品 A 的時候,是不能購買成功的。我們最後兼顧後臺的邏輯,為了讓後臺同事更加方便,我們採取了序列的方式。採用序列就會帶來一個邏輯漏洞就是,假如某個使用者他購買以後出現異常,導致無法使用正常的方式充錢並且
finish
某筆交易,最後通過和我們客服聯絡的方式手動充錢,那麼他的鑰匙鏈就一直有一筆未完成的交易,由於我們的購買時序列的,這樣會導致這個使用者再也沒法購買產品。這種情況也是需要警惕的,此時只需要和後端同時約定一下,再次驗證這筆訂單的時候返回一個錯誤碼,把這筆訂單特別的finish
掉就好了。還有一個 IAP 的
bug
,就是 IAP 通知交易完成,然後我們把交易資料存起來去後臺驗證,驗證成功以後,回到 APP 使用transactionIndetify
從 IAP 未完成交易列表中取出對應的交易,將這比交易finish
掉,當 IAP 出現bug
的時候,這個交易找不到,整個未完成交易列表都為空。而且復現也很簡單,只要在弱網下交易成功立即殺掉 APP 就可以復現。所以我們必須應對這個問題。應對的策略就是給我們儲存的資料加一個狀態,一旦出現驗證成功回來finish
的時候找不到對應的交易,就先給儲存資料加一個flag
,標識這筆訂單已經驗證過了,只是還沒有找到對應的 IAP 交易進行finish
,所以以後每次從未驗證交易裡取資料的時候,都需要將有這個flag
的交易對比一下,如果出現已經驗證過的交易,就直接將那一筆交易finish
掉。
08.還有哪些問題?
到現在為止,第一篇上提及的八個問題,有七個在這一篇文章中都有對應的解決方案。由於篇幅原因,我就不大段大段的貼程式碼了,具體實踐,肯定要看原始碼的,並且我寫了鉅細無比的註釋,保證每個人都能看懂。
但是真的就沒有問題了嗎?不是的,現在已知的問題還有兩個。
- 沒驗證完, 使用者更換了
APP ID
, 導致keychain
被更改。 - 訂單沒有拿到收據, 此時使用者更換了手機, 那麼此時收據肯定是拿不到的。
- ......
第一個問題,看起來要雞蛋放在兩個籃子裡,比方說,資料要同時持久化到 keyChain
和沙盒中。但是這次沒有做,接下來看情況,如果確實有這種問題,可能會這麼做。
第二個問題,是蘋果 IAP 設計上的一個大的缺陷,看似無解,出現這種情況,也就是使用者千方百計要阻止交易成功,那隻能他把蘋果的訂單郵件發給我們,我們手動給他加錢。
其他還有問題的話,請各位在評論區補充,一起討論,謝謝你的閱讀!!
我的文章集合
下面這個連結是我所有文章的一個集合目錄。這些文章凡是涉及實現的,每篇文章中都有 Github 地址,Github 上都有原始碼。
你還可以關注我自己維護的簡書專題 iOS開發心得。這個專題的文章都是實打實的乾貨。如果你有問題,除了在文章最後留言,還可以在微博 @盼盼_HKbuy上給我留言,以及訪問我的 Github。
贊助
你這一讚助,我寫的就更來勁了!
微信贊助掃碼
支付寶贊助掃碼
相關文章
- [貝聊科技]貝聊 IAP 實戰之見坑填坑
- [貝聊科技]貝聊 IAP 實戰之滿地是坑
- [貝聊科技]貝聊 IAP 實戰之訂單繫結
- iOS初學之填坑總結iOS
- 填坑Ⅱ
- kubernetes實戰篇之helm填坑與基本命令
- 小程式踩坑填坑
- 貝聊ELK實戰
- Springboot+Neo4j+實戰&填坑Spring Boot
- 小程式填坑實錄
- streamparse 填坑
- [貝聊科技]貝聊 iPhone X 適配實戰iPhone
- Java填坑系列之LinkedListJava
- ReactNative填坑之旅–與Native通訊之iOS篇ReactiOS
- Flutter 填坑整理Flutter
- vim 填坑之路
- Hibernate填坑
- Elasticsearch 填坑記Elasticsearch
- vue微信填坑Vue
- javascript 填坑史JavaScript
- React 填“坑”記React
- Date填坑記
- [填坑手冊]小程式新版訂閱訊息+雲開發實戰與跳坑
- Flutter完整開發實戰詳解(八、 實用技巧與填坑)Flutter
- 小程式的填坑小技巧之CanvasCanvas
- 小程式專案之填坑小記
- 微信小程式之逆地址解析填坑微信小程式
- kubernetes實戰之consul簡單測試環境搭建及填坑
- iOS之UIWebView的坑iOSUIWebView
- Flutter for web 最新填坑FlutterWeb
- 小程式花式填坑
- 05-待填坑...
- compilephpwithopensslonmacosxerror填坑CompilePHPMacError
- Tungsten Fabric實戰:對接vMX虛擬路由平臺填坑路由
- Flutter完整開發實戰詳解(三、打包與填坑篇)Flutter
- vue2.0 transition — demo實踐填坑Vue
- 【git實際應用填坑解決】Git
- 支付開發填坑記之支付寶