(译)实现应用内购买基础教程

应用内购买基础教程

原文地址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和updog4个购买项目名称。

现在你可以点击保存。非常不错,你已经创建了第一个应用内购买项目。现在重复刚才的操作添加剩余的三个购买选项。当你全部完成,你的购买选项应该看起来这样:

你可能注意到这个过程有点耗时并且枯燥。假如让你添加一堆着玩样,估计你就要暴走了。

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 identifierproduct 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.storeIAPHelper的一个实例。这个对象通过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 = handler
    productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
    productsRequest?.delegate = self
    productsRequest?.start()
}

这段代码首先保存了用户提供的完成处理程序(handler,是一个闭包),以便在之后被执行;紧接着实例化一个请求,并发送给苹果服务器。由于IAPHelper没有遵循SKProductsRequestDelegate协议,你将看到提示错误。通过扩展IAPHelper来解决这个问题,添加如下代码到文件末尾:

// MARK: - SKProductsRequestDelegate
 
extension 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 printing
    for 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 = nil
    completionHandler = 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)
        break
      case .Failed:
        failedTransaction(transaction)
        break
      case .Restored:
        restoreTransaction(transaction)
        break
      case .Deferred:
        break
      case .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.productIdentifier
    println("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! UITableViewCell
 
    let product = products[indexPath.row]
    cell.textLabel?.text = product.localizedTitle
 
    if RageProducts.store.isProductPurchased(product.productIdentifier) {
        cell.accessoryType = .Checkmark
        cell.accessoryView = nil
        cell.detailTextLabel?.text = ""
    }
    else if IAPHelper.canMakePayments() {
        priceFormatter.locale = product.priceLocale
        cell.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.row
        button.addTarget(self, action: "buyButtonTapped:", forControlEvents: .TouchUpInside)
        cell.accessoryType = .None
        cell.accessoryView = button
    } else {
        cell.accessoryType = .None
        cell.accessoryView = nil
        cell.detailTextLabel?.text = "Not Available"
    }
    return cell
}

假如当前设备不允许交易时,如此做法能更友善的显示界面。

Where To Go From Here

完整代码下载请点击这里In App Rage Final.zip

该应用的不足之处在于当应用向苹果服务器提交购买请求时并未给用户一个提示等待的界面,你可以通过显示一个加载等待视图告知用户正在请求购买。不过这些UI改进并不在本教程内容范围之中,因此不做拓展。

应用内购买项目是你商业模式中的重要板块,因此请谨慎对待,确保遵守了苹果给出的准则,包括恢复购买和友好的失败界面,如此你能在成功之路走得更远。

调皮的Ray Fix还给出了一款出自Jayant C Varma的暴走漫画,请笑着结束我们的教程。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,565评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,611评论 4 59
  • 幼儿期 “妈妈,帮我穿衣服!”我总是这么说着,然后将那几件带有小花猫图案的小衣服整整齐齐地摆在床上,随后,自己...
    忽忆旧河关阅读 161评论 0 6
  • “铮!铮!” 初九院后传出优美的琴音,男子在亭中弹着古琴,纤细而修长的手指若行云流水般舞弄着琴弦。 男子一身白衣,...
    酉禾禾禾禾阅读 135评论 0 1