iOS 应用内购买基础教程 swift篇
应用内购买基础教程
原文地址http://www.raywenderlich.com/105365/in-app-purchases-tutorial-getting-started
更新日志:本教程由Ray Fix更新至iOS8和Swift版本。原教程由Ray Wenderlich网站总编辑。
作为一个iOS开发者,最让人兴奋的事情之一是你能选择不同营销方式从应用中获利,包括付费购买,免费广告植入以及应用内购买项目。
其中应用内购买是一个特别有吸引力的选择,原因有如下三点:
- 相对于给定应用购买价格的方式,你能赚到更多的钱。毕竟有些用户愿意花费更多钱来享受额外内容。
- 你可以先免费上架你的应用(这样容易吸引更多用户来下载),倘若用户体验之后非常喜欢,便会购买额外付费内容。
- 一旦你已经实现了应用内购这一功能,便能持续增加额外付费项目进去(无须重新开发一款应用达到赚钱目的)。
你可以结合不同商业模式来实现应用内购买。比如Ray Wenderlich开发的应用Wild Fables,它免费下载,但内置三个额外需要付费的故事包。Battle Map2则是一款付费应用,但同时内置有可选的额外付费内容。
本教程中,你将学习到如何使用应用内购买去解锁应用内置的本地内容。
教程需要你对swift以及iOS编程开发有一定的了解。如果这些对你来说有点陌生,不如看看raywenderlich下的这些教程。
Getting Started
在本教程中,我们将创建一个名为In App Rage的简单应用。应用提供用户购买”暴走漫画”(有时候我们也称之为”F7U12”)的选项。Ray Wenderlich的读者肯定对这种风格的漫画不陌生吧!这些小漫画内容搞笑,主人公经历了从平静到沮丧,最终发飙的幽默情节。
在正式编写代码之前,你需要前往iOS Developer Center以及iTunes Connent为你的App占个”坑”。
首先,登陆iOS Developer Center,进入iOS Apps导航栏下的Identifier选项内容中,选中App IDs标签,点击右上角的+按键,按照下图给出的形式填充表单。
你必须将包标识符(bundle identifier)改成你独有的前缀。常规做法是使用翻转后的域名(译者注:域名为xxx.com 翻转为com.xxx)。当然你也可以使用你的名字混合其他独一无二的东西构建这个包标识符。
注意到应用内购买功能(以及GameKit)默认是启用的。当你填充完表单,依次点击Continue
按钮和Submit
按钮。OK,你现在拥有了一个新的App ID!接下来你将使用这个ID在iTunes Connect中创建一个新的应用。
请登陆iTunes Connect,依次点击我的Apps以及+创建一个新的iOS应用。弹窗提示选择创建应用类型,选中新建 iOS App
(显而易见嘛)。最后按照下图给出的图片填充表格:
如果你很快进行到这一步,你可能注意到我们的Bundle ID没有出现在下拉菜单中。显然,这里有点小小的延迟。趁这时候看看你的手表,出去溜达一小会。希望当你回来之后,点击刷新界面,它就出现在下拉菜单中。
对了,你可能需要稍微修改下应用名称,因为应用名称规定在整个App Store中是独一无二的。而这里我已经为它命名为In App Rage RAF,而你只需要将RAF替换成你独有的标识符就OK了。
Managing In App Purchases
你不得不提前在iTunes Connect中进行设置,这也是为什么在开始写代码前需要创建一个placeholder app
的原因了。言归正传,此时你已经拥有了一个placeholder app,点击应用内购买项目,如下图所示:
然后点击左上角的Create New:
你将跳转到一个新界面,供你选择应用内购买项目的所有类型(译者注:有些同志可能没有出现购买类型,请点击协议、税务和银行业务,分别点击Request确认合同便能出现)。其中有两种用的比较频繁的应用内购项目类型,分别是:
- 消耗型项目:对于消耗型 App 内购买项目,用户每次下载时都必须进行购买。一次性服务通常属于消耗型项目,例如钓鱼 App 中的鱼饵。
- 非消耗型项目:对于非消耗型 App 内购买项目,用户仅需要购买一次。不会过期或随使用而减少的服务通常为非消耗型项目,例如游戏 App 的新跑道。
对于In App Rage应用来说,你要实现的是售卖漫画而已。一旦用户购买之后,它们将永久性存在,因此我们选择非消耗型项目.
注意:任何非消耗型项目的购买内容必须对用户所持有的所有设备有效可用。如果用户有两台设备,你绝不能向用户收取两次费用!
之后我们将详细讨论如何在其他设备上,允许用户恢复之前购买过的非消耗型项目内容。
与非消耗型项目不同,消耗型项目并没有这样的规定(设备之间必须支持恢复购买消耗型项目)。消耗型项目仅仅只能在购买它的设备上生效。如果你想要实现跨设备共享,你可能需要通过iCloud或者其他技术来实现。
接下来,你将跳转到其他页面填写关于应用内购买项目的说明信息。按照下图填充即可:
让我们来讨论下每个字段分别代表的意思:
- 参考名称:这个名称将出现在iTunes Connect的应用内购项目中.而在你所开发的应用内并不会出现,这也正是你想要的。
- 产品 ID: 苹果官方文档中也称之为产品标识(product identifier),它是用于标识应用内购买项目的唯一字符串。通常以你的Bundle ID打头,然后附加唯一的购买项目名称。为了和例程项目保持一致,购买项目名称请按照接下来讨论的名称为准。
- 准许销售:是表明准许销售应用内购买项目。
- 价格等级: 应用内购项目内容的定价。
当你完成如上设置之后,请向下滚动到语言板块并点击添加语言按钮,填写弹出表单中的信息,如下图所示:
以上信息将在之后你向应用商店查询应用内可用购买项目时被反馈回来。至于价格会按照预先你设定好的货币出现,此外你还能在fly模式中使能/禁用
购买功能。无须在意这些描述,你不会在之后的教程中使用到,所以你大可放心地用应用名字来填充即可。
你可能已经注意到表格下方还有审核备注和供审核的屏幕快照两个区域。目前你只是在沙盒中进行测试,大可以忽略,只在你向苹果正式提交时才需要填写。
为了和接下来的项目代码保持一致,你的产品 ID应该应用这种形式”YYYYY.XXXXX”,其中YYYYY是你唯一的名字(我的是org.rayifx.inapprage),XXXXX是要显示的照片名称。这里是: nightlyrage, girlfriendofdrummer, iphonerage和updog 4个购买项目名称。
现在你可以点击保存。非常不错,你已经创建了第一个应用内购买项目。现在重复刚才的操作添加剩余的三个购买选项。当你全部完成,你的购买选项应该看起来这样:
你可能注意到这个过程有点耗时并且枯燥。假如让你添加一堆着玩样,估计你就要暴走了。
Quick Tour of the Starter Project
请点击下载启动项目,解压并在Xcode中打开。选中MasterViewController.swift,这个类中导入了StoreKit包,并采用tableView
列表形式显示可用的应用内购买项目。购买的项目将被存储到一个类型为SKProduct
的数组对象中。再来看看tableView
的界面,每行都有一个”Buy”按钮(假如没有购买过的话)供你点击购买相应产品。NSNumberFormatter
用于显示购买价格。一旦购买完成,右侧会出现一个✔️。 最后,”Restore”按钮用于恢复先前所有的购买选项。
你将注意到MasterViewController.swift 定义了一个类型为IAPHelper
的对象,命名为RageProducts.store
,用于处理一些繁重的事务。不过目前这个类还处于”熄火状态”。点击运行之后你会发现表格中空白一片。
Matching the Identifiers
为了工作正常,你需要保持应用中的bundle identifier和product identifer和先前设定的一致(两者不一样),后者是在iTunes Connect中填写的。
在项目导航栏中选中项目目标(project target),然后点击General选项更改其下的Bundle Identifier值。我使用了”org.rayfix.inappragedemo”,而你肯定是不同的。
打开RageProducts.swift
文件,修改Prefix
变量内容即可,形如:com.xxxx.inapprage
.你会注意到下面分别有GirlfriendOfDrummer 、iPhoneRage、NightlyRage和Updog
四个先前设定的应用内购买项目,通过Prefix + 项目名称 组合成产品ID(product ID)。倘若你忘记,不妨回过头看看。
Listing In-App Purchases
RageProduct.store
是IAPHelper
的一个实例。这个对象通过StoreKit API与购买清单交互以及执行购买。打开IAPHelper.swift
文件,注意到还有一些功能尚未实现,也是你接下来需要做的。
首先,你需要从苹果服务器上获取到应用内购买项目清单。请在IAPHelper
类中添加一些私有属性。
/// MARK: - Private Properties// Used to keep track of the possible products and which ones have been purchased.
private let productIdentifiers: Set<ProductIdentifier>
private var purchasedProductIdentifiers = Set<ProductIdentifier>()// Used by SKProductsRequestDelegate
private var productsRequest: SKProductsRequest?
private var completionHandler: RequestProductsCompletionHandler?
这些属性之后将用于执行购买请求以及追踪哪些应用内购买项目已经被购买。当你添加完如上代码,编译器会在init(productIdentifiers:)
处抛出一个错误。主要是因为swift中的初始化规则要求你必须在super.init()
之前初始化所有类中的属性。往init(productIdentifiers:)
中添加如下代码即可:
self.productIdentifiers = productIdentifiers
IAPHelper
构建方法需要传入整个产品标识(product identifiers)进行实例化。这同时也是RageProducts
类创建store
实例的方式。接下来。替换掉requestProductsWithCompletionHandler(_:)
的原有代码(只有一行):
/// Gets the list of SKProducts from the Apple server calls the handler with the list of products.
public func requestProductsWithCompletionHandler(handler: RequestProductsCompletionHandler) {completionHandler = handlerproductsRequest = SKProductsRequest(productIdentifiers: productIdentifiers)productsRequest?.delegate = selfproductsRequest?.start()
}
这段代码首先保存了用户提供的完成处理程序(handler,是一个闭包),以便在之后被执行;紧接着实例化一个请求,并发送给苹果服务器。由于IAPHelper
没有遵循SKProductsRequestDelegate
协议,你将看到提示错误。通过扩展IAPHelper
来解决这个问题,添加如下代码到文件末尾:
// MARK: - SKProductsRequestDelegateextension IAPHelper: SKProductsRequestDelegate {public func productsRequest(request: SKProductsRequest!, didReceiveResponse response: SKProductsResponse!) {println("Loaded list of products...")let products = response.products as! [SKProduct]completionHandler?(success: true, products: products)clearRequest()// debug printingfor p in products {println("Found product: \(p.productIdentifier) \(p.localizedTitle) \(p.price.floatValue)")}}public func request(request: SKRequest!, didFailWithError error: NSError!) {println("Failed to load list of products.")println("Error: \(error)")clearRequest()}private func clearRequest() {productsRequest = nilcompletionHandler = nil}
}
通过扩展实现了SKProductsRequestDelegate
协议中的两个方法,如此我们能够从苹果服务器获取到一个产品清单,包括产品标题,产品描述和产品价格。
当清单数据成功取回时,productsRequest(_:didReceiveResponse:)
将被调用执行,它接收一个类型为SKProduct
的数组对象,然后将其传递给先前保存的完成处理程序。该处理程序用最新获取到的数据重新加载tableView
。一旦出现错误,productsRequest(_:didFailWithError:)
将会被调用。不管哪种情况,一旦请求结束,我们将调用clearRequest()
方法,并通过赋值nil
的方式清除请求(request)以及完成处理程序( completion handler)。
现在Build and Run.你应该能在tabel view中看到产品购买清单了吧! 这部分内容无须沙盒账号就能正常运行。
不起作用? 请参考论坛中给出的一些处理方式。
- 前往 设置\iTunes 与 App Stores, 注销账号并重试,确保你使用了沙盒账号。
- 项目的Bundle ID 是否和 App ID一致?
- 当然也有可能是iTunes的沙盒宕机了。点击这里确认是否正常。
- 你有没有使能应用内购买项目?
- 在
SKProductRequest
请求时,你是否使用了完整的产品ID。建议再次检查!- iTunes Connect中的一些银行等条款是否生效?
- 先删除应用,再重装一次试试?
还是没法正常工作? 试试老版本.
Purchased Items
我们通过使用刚刚添加的purchagedProductIdentifier
属性来确认用户点击了哪个购买选项。如果一个产品标识符(product identifier)已经包含在这个集合内,那么就表明用户已经购买过这个项目了。校验的方法很简单。请找到isProductPurchased(_:)
函数,然后替换以下内容:
return purchasedProductIdentifiers.contains(productIdentifier)
每一次运行你的应用,你可能并不想要发送请求给苹果服务器,用以检查是否有新的购买已经产生。那么本地化保存这些信息是一个不错的主意。你将使用NSUserDefaults
用以保存purchasedProductIdentifiers
到本地。定位到init(productIdentifiers:)
方法,在super
之前添加如下代码:
for productIdentifier in productIdentifiers {let purchased = NSUserDefaults.standardUserDefaults().boolForKey(productIdentifier)if purchased {purchasedProductIdentifiers.insert(productIdentifier)println("Previously purchased: \(productIdentifier)")} else {println("Not purchased: \(productIdentifier)")}
}
对于每个产品标识符,你首先需要判断值是否存在于NSUserDefaults
中,假如存在,那么就将其插入到这个集合中。之后每一次购买完成,你都将需要往其中添加一个标识符。
Making Purchases (Show Me The Money!)
干得不错,但是你需要实现购买!这正是接下来你所要去实现的功能。依然是在IAPHelper.swift
文件中,用以下代码替换掉purchaseProduct(_:)
的内容:
/// Initiates purchase of a product.
public func purchaseProduct(product: SKProduct) {println("Buying \(product.productIdentifier)...")let payment = SKPayment(product: product)SKPaymentQueue.defaultQueue().addPayment(payment)
}
这段代码使用SKProduct
新建了一个付款对象(从服务器获得)添加到付款队列中。SKPaymentQueue
使用了单例模式,命名为defaultQueue()
。Boom,钱到账了!
那么你又如何得知这个付款是否完成呢?为此,你需要使用IAPHelper
来观察SKPaymentQueue
到底发生了什么。回到init(productIdentifiers:)
方法,添加如下代码到函数的底部,在super.init()
之后即可。
SKPaymentQueue.defaultQueue().addTransactionObserver(self)
写完之后,编译器报错了,那是因为你没有遵循SKPaymentTransactionObserver
协议。
转到文件的末尾,添加以下扩展名和方法:
extension IAPHelper: SKPaymentTransactionObserver { /// This is a function called by the payment queue, not to be called directly./// For each transaction act accordingly, save in the purchased cache, issue notifications,/// mark the transaction as complete.public func paymentQueue(queue: SKPaymentQueue!, updatedTransactions transactions: [AnyObject]!) {for transaction in transactions as! [SKPaymentTransaction] {switch (transaction.transactionState) {case .Purchased:completeTransaction(transaction)breakcase .Failed:failedTransaction(transaction)breakcase .Restored:restoreTransaction(transaction)breakcase .Deferred:breakcase .Purchasing:break}}}private func completeTransaction(transaction: SKPaymentTransaction) {println("completeTransaction...")provideContentForProductIdentifier(transaction.payment.productIdentifier)SKPaymentQueue.defaultQueue().finishTransaction(transaction)}private func restoreTransaction(transaction: SKPaymentTransaction) {let productIdentifier = transaction.originalTransaction.payment.productIdentifierprintln("restoreTransaction... \(productIdentifier)")provideContentForProductIdentifier(productIdentifier)SKPaymentQueue.defaultQueue().finishTransaction(transaction)}// Helper: Saves the fact that the product has been purchased and posts a notification.private func provideContentForProductIdentifier(productIdentifier: String) {purchasedProductIdentifiers.insert(productIdentifier)NSUserDefaults.standardUserDefaults().setBool(true, forKey: productIdentifier)NSUserDefaults.standardUserDefaults().synchronize()NSNotificationCenter.defaultCenter().postNotificationName(IAPHelperProductPurchasedNotification, object: productIdentifier)}private func failedTransaction(transaction: SKPaymentTransaction) {println("failedTransaction...")if transaction.error.code != SKErrorPaymentCancelled {println("Transaction error: \(transaction.error.localizedDescription)")}SKPaymentQueue.defaultQueue().finishTransaction(transaction)}
}
代码略长,让我们通读一遍。首先paymentQueue(_:updatedTransactions:)
是协议中唯一要求实现的方法,当且仅当一个或多个交易状态改变时被调用,在该方法中,我们遍历整个存储了最新订单信息的数组,并查看它们的状态。根据状态码来分别调用这里定义的三个方法:completeTransaction(_:), restoreTransaction(_:) or failedTransaction(_:).
如果这个订单已经完成或者被恢复,订单将添加到已购买集合中,并把标识符存储到NSUserDefaults
当中。同时它还会发送给该交易的通知,你可以根据内容来更新用户界面。最后,不管成功与否,交易已经完成。
Restoring Payments
用户可能删除之后再次重装应用,或者将应用安装到其他设备上,此时我们需要恢复用户先前已购买内容。事实上,假如你没有实现这个恢复购买的功能,苹果审核时有可能会拒绝你的应用。
你已经实现了监听购买内容是否被恢复事件。但是你还需要添加初始化代码。找到restoreCompletedTransactions()
方法,然后添加如下代码:
SKPaymentQueue.defaultQueue().restoreCompletedTransactions()
极其简单!到目前为主,你已经设置了交易观察者以及实现了除了恢复购买的处理方法。
In App Purchases, Accounts, and the Sandbox
当你在Xcode跑你的应用时,你并未向真实应用内购买服务器进行交易—先前只不过是向沙盒服务器交易罢了。
这意味着你在购买应用内项目时无须担心扣费。但是你仍需要建立一个测试账号,同时真机测试时确保你真实的应用商店账号(app store的账号)已经注销掉,难免有些意外发生,不是吗?
为了创建账号,登陆到iTunes Connect,点击Users and Roles,点击Sandbox User按照提示创建一个测试账号。
接着真机调试,确保你已经注销掉你的账号,如下做法:前往设置 -> iTunes Store与App Store -> 点击Apple ID -> 选择注销。
最后在真机上运行你的应用,尝试购买一个暴走漫画包。输入你的账号密码,如果一切正常,该购买内容选项右侧会出现一个✔️
显示购买成功。此时的购买列表应该如下图所示:
Payment Permissions
一些设备和账户可能不允许应用内购买。这是有可能发生的,比如,在家长控制设置中禁用了应用内购买这一选项。苹果公司要求你妥善处理这些情况,否则应用可能被拒。
打开IAPHelper.swift
添加如下方法到类中:
public class func canMakePayments() -> Bool {return SKPaymentQueue.canMakePayments()
}
当canMakePayments()
方法返回false
时,你的主控制器应该显示不同界面的单元格内容。比如,隐藏掉Buy
按钮,或者更简单的做法是不显示购买列表,而是告知购买不可用
即可。
接下来实现这一处理方法,打开MasterViewController.swift
,按照下面给出的代码更新tableView(_:cellForRowAtIndexPath)
方法:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as! UITableViewCelllet product = products[indexPath.row]cell.textLabel?.text = product.localizedTitleif RageProducts.store.isProductPurchased(product.productIdentifier) {cell.accessoryType = .Checkmarkcell.accessoryView = nilcell.detailTextLabel?.text = ""}else if IAPHelper.canMakePayments() {priceFormatter.locale = product.priceLocalecell.detailTextLabel?.text = priceFormatter.stringFromNumber(product.price)var button = UIButton(frame: CGRect(x: 0, y: 0, width: 72, height: 37))button.setTitleColor(view.tintColor, forState: .Normal)button.setTitle("Buy", forState: .Normal)button.tag = indexPath.rowbutton.addTarget(self, action: "buyButtonTapped:", forControlEvents: .TouchUpInside)cell.accessoryType = .Nonecell.accessoryView = button} else {cell.accessoryType = .Nonecell.accessoryView = nilcell.detailTextLabel?.text = "Not Available"}return cell
}
假如当前设备不允许交易时,如此做法能更友善的显示界面。
Where To Go From Here
完整代码下载请点击这里In App Rage Final.zip。
该应用的不足之处在于当应用向苹果服务器提交购买请求时并未给用户一个提示等待的界面,你可以通过显示一个加载等待视图告知用户正在请求购买。不过这些UI改进并不在本教程内容范围之中,因此不做拓展。
应用内购买项目是你商业模式中的重要板块,因此请谨慎对待,确保遵守了苹果给出的准则,包括恢复购买和友好的失败界面,如此你能在成功之路走得更远。
调皮的Ray Fix还给出了一款出自Jayant C Varma的暴走漫画,请笑着结束我们的教程。
iOS 应用内购买基础教程 swift篇相关推荐
- iOS游戏框架Sprite Kit基础教程——Swift版上册
iOS游戏框架Sprite Kit基础教程--Swift版上册 试读下载地址:http://pan.baidu.com/s/1qWBdV0C 介绍:本教程是国内唯一的Swift版的Spritekit ...
- iOS 10应用开发基础教程
iOS 10应用开发基础教程 介绍: 本教程是国内第一本iOS 10开发应用教程.本教程基于Xcode 8.0,使用Swift 3.0语言讲解如何开发iOS 10的应用App. 学习建议:本教程针对i ...
- vue2+vue3小白零基础教程—vue2篇,全网2021最详细教程
vue教程 提示:Vue3系列请参考Vue2+Vue3小白零基础教程-vue3篇文章,本文为vue2篇. 1. Vue核心 1.1 Vue简介 1.1.1 Vue是什么 一套用于构建用户界面的渐进式J ...
- iOS 应用内购买(In-App Purchase)之开发
iOS 应用内购买(In-App Purchase)之协议.税务和银行业务 使用IAP之前,需要签订协议,查看上面的链接. IAP开发 添加App内购项目 登录 iTunes Connect ,选择我 ...
- Java入门基础教程第一篇
Java入门基础 Java是是一门面向对象编程语言,现在广泛使用,名声和c/c++.python一样,虽然我最常用的语言是python,但现在现在闲来无事,就写了这篇文章. 目录 Java入门基础 下 ...
- IOS应用内购买App开发完整流程
2019独角兽企业重金招聘Python工程师标准>>> 看了一些网上教程,基本上是老版本的了.我针对自己遇到的一些问题,结合官方文档把IAP(In-App Purchase)过程梳理 ...
- NSIS安装制作基础教程[初级篇], 献给对NSIS有兴趣的初学者
NSIS简介: NSIS 是"Nullsoft 脚本安装系统"(Nullsoft Scriptable Installation System)的缩写,它是一个免费的 Win32 ...
- iOS开发内购图文教程
2015年最全的内购图文教程,首先是填各种资料,最后是代码,废话不多说,直接上图 ======================第一部分协议=============== 第一步.png 第二步.jpg ...
- iOS应用内购买(In App Purchase)总结
先附上几篇文章: 1.In App Purchases: A Full Walkthrough 这篇文章里说的都很详尽了,代码什么的基本可以照搬. 2.Store Kit Guide(In App P ...
最新文章
- TensorFlow serving远程访问引擎的容器部署
- JPEG压缩原理与DCT离散余弦变换 量化
- python代码自动格式化_代码的自动格式化
- jdk 11 模块系统_JDK 9:模块系统状态的重点
- 深入浅出React Native 1: 环境配置
- leetcode 530. 二叉搜索树的最小绝对差(中序遍历)
- windows。forms.timer设置第一次不等待_适用于初学者的中线交易策略——金叉的三种设置条件...
- 吃相难看!《人民日报》再评视频网站套路:消磨观众信任,必将引火烧身
- python之FTP程序(支持多用户在线)
- SAP 以工序为基准进行发料 机加工行业 Goods Issue to Routing
- C# 5.0 CallerMemberName CallerFilePath CallerLineNumber获取调用方法名称,路径,行号
- matlab运行C程序
- 《算法导论》.pdf
- 网站使用 VideoPlayer 方法
- pdf文件如何生成目录 wps_如何使用WPS把Word文档转换为PDF文档并生成目录?
- android 清理系统垃圾,安卓手机怎么清理系统垃圾
- html5单位转换器,液体单位在线换算工具
- python的文件怎么删除干净_python 实现彻底删除文件夹和文件夹下的文件
- 审查元素html表格后缀,审查元素
- Modeling-Relational-Data-with-Graph-Convolutional-Networks-阅读笔记
热门文章
- 人的思想的成长过程是一个潜意识不断成长并替代思维完成细节工作的过程
- 爱上收纳的花艺师:热爱生活,就能被生活治愈
- 使用java计算数组方差和标准差
- ImportError: cannot import name '_path' from 'matplotlib'的原因分析,可能是因为你适合win32的whl,却下载安装了win64的whl
- 基于W5500的实时远程温湿度监控系统
- p标签内不能包含块级元素
- HTTP/2 stream 1 was not closed cleanly before end of the underlying stream
- 家用wifi能查到浏览记录吗_2020最好用的行车记录仪推荐
- [机缘参悟-79]:深度思考-职场中注意事项-管理者版
- 【电子数据取证】8个门道儿