App 出海实践:Google Play 结算系统
作者:业志陈
现如今,App 出海热度不减,是很多公司和个人开发者选择的一个市场方向。App 为了实现盈利,除了接入广告这种最常见的变现方式外,就是通过提供各类虚拟商品或者是会员服务来吸引用户付费了,此时 Google Play 结算系统(Google Play’s billing system)就是 Android 端应用必须使用到的一个支付渠道了
Google 对 Google Play 结算系统的简介:Google Play’s billing system is a service that enables you to sell digital products and content in your Android app, whether you want to monetize through one-time purchases or offer subscriptions to your services. Google Play offers a full set of APIs for integration with both your Android app and your server backend that unlock the familiarity and safety of Google Play purchases for your users.
也就是说:Google Play 结算系统是一项可以让我们在 Android 应用中销售数字商品和内容的服务。无论是要通过一次性购买交易创收,还是要为用户提供订阅服务,它都能帮我们搞定。Google Play 提供了一整套 API,可集成到 Android 应用和服务器后端中,从而为用户提供熟悉又安全的 Google Play 购买交易服务
在最近的一年多时间里,我一直在负责一个海外项目的开发工作,这个过程中也接入了 Google Play 结算系统。在刚开始时,由于对当中的各个概念不够了解,其整体支付流程又和国内常用的各类支付服务相差挺大的,导致我走了不少的弯路
这里我就来写一篇文章,对 Google Play 结算系统进行详细介绍,希望对你有所帮助
一、概述
想要通过 Google Play 结算系统向用户展示并售卖商品,自然需要先创建商品,创建商品的方式有两种:
- 在 Google Play Console 手动创建
- 通过 Google Play Developer API 以代码的方式创建
在 Google Play 中创建的商品都属于虚拟商品,每个商品代表的都是 App 给用户提供的一种权益,而每个商品都包含一个唯一标识,也即 ProductId,我们在业务上就需要根据 ProductId 的命名规则来定义商品所代表的具体权益类型
每个商品又可以分为两种类型:
- 一次性商品。用户通过单次付费获得的商品,属于买断制,对应 Google Play 结算库中的
BillingClient.ProductType.INAPP
- 订阅型商品。用户以固定周期不断重复付费的商品,属于订阅制,对应 Google Play 结算库中的
BillingClient.ProductType.SUBS
当用户购买了商品后,App 还需要对这笔订单进行核销。处理流程和商品类型有关,分为两种:
- 确认交易。不管购买的商品是什么类型,App 都需要先对这笔交易进行 确认,如果在限定的时间内未完成确认,Google Play 就会自动撤销这笔交易并向用户退款。“确认交易” 这个操作应该是 Google Play 为了让 App 确定已经向用户提供了权益,尽量避免出现用户已付款但 App 没有向用户下发权益这种情况。确认操作可以由服务端或者移动端来实现,对应
acknowledgePurchase
操作 - 消耗商品。消耗商品针对的是一次性商品中的消耗型商品,也即对其执行 消耗 操作。通过执行消耗操作,使得用户后续可以再次购买此商品。消耗操作可以由服务端或者移动端来实现,对应
consumePurchase
操作
二、一次性商品
一次性商品也称为应用内商品,属于一次性买断的商品,具体又可以细分为两种子类型:
- 消耗型商品。也即是说,此商品在购买后可以被消耗,从而使得用户可以重复购买。例如,该商品可以用于表示游戏中的金币,用户在使用完金币后该商品代表的权益就失效了,用户需要再次购买商品才能再次获得金币
- 非消耗型商品。也即是说,此商品在购买后是不可消耗的,用户可以永久获得该商品代表的权益。例如,该商品可以用于表示某课程的观看权益,用户只要购买商品后,就可以永久享有该课程的观看权益
一次性商品到底属于 消耗型 还是 非消耗型 都取决于 App 在业务上的定义,在 Google Play Console 中都统一将其称为 应用内商品,在创建一次性商品时也没有区分子类型的选项
假设我们对一件一次性商品在业务上的定义是消耗型的,那么就可以在适当的时候通过执行 consumePurchase
来对其执行 “消耗” 操作。例如,用户通过购买某个一次性商品获得了游戏金币,用户在后续过程中使用这些金币来购买游戏道具,那么开发者就需要同时执行 consumePurchase
来消耗掉商品,从而使得该商品变为无效状态,这样用户后续也可以再次购买此商品
而对于非消耗型商品,在业务上代表的是用户可以永久享有的某个权益,只要买了该商品权益就不会丢失,因此用户也不应该再次购买,自然也就不需要也不能执行消耗操作了
三、订阅型商品
订阅型商品,也即需要用户以固定周期定期进行付费的商品,在付费周期内用户均能享有该商品代表的权益。最常见的应用场景就是各类会员服务:用户按月付费,App 在每个订阅周期内向用户提供会员独有的功能,直至用户取消订阅
订阅型商品包含四个比较重要的概念:
- 基础方案
- 续订类型
- 优惠
- 定价阶段
基础方案
基础方案,也称为 BasePaln,每个订阅型商品都必须包含一个或多个基础方案才能让用户购买
基础方案就用于定义商品的售卖规则,包括结算周期、续订类型、订阅价格、优惠策略等。例如,一个订阅型商品可以同时提供 按月付费 和 按年付费 这两个基础方案供用户选择,每个周期分别设定不同的价格,用户根据喜好来选择不同的方案进行订阅
续订类型
每个基础方案均需要指定续订类型,用于指定用户的付费方式
续订类型分为两种:
- 自动续订。在每个结算周期即将结束时主动向用户扣款,从而自动延长权益使用权的期限。付费操作对于用户来说是被动的
- 预付费。不会自动续订和扣款,用户需要通过主动付款来推迟权益使用权的结束日期,以此保持不间断地享有订阅内容。付费操作对于用户来说是主动的
优惠
优惠,也称为 Offer,只有 自动续订型 的基础方案才能设定优惠
每个自动续订型的基础方案可以同时设定多个优惠,让用户可以在订阅初期享受一定的价格折扣或者是直接就免费使用,从而吸引用户购买
Offer 的类型分为三种,也即分为三种优惠策略。例如,假设现在有一个按月订阅的基础方案,我们就可以为其添加以下三个 Offer 供用户选择:
- 免费试订。用户在前七天内免费试用,在七天后再正式进行按月付费
- 单次付款。用户一次性预付三个月的订阅费用,总价享受七折折扣,三个月后再按原价进行按月订阅
- 周期性付款折扣。用户还是按月订阅,但前三个月每次付费时均能享受八折折扣,三个月后再按原价进行按月订阅
价格阶段
价格阶段,也称为 PricingPhases,可以看做是 Offer 的一个内部属性
由于一个 Offer 可以同时包含多个优惠策略,所以当用户在享用某个 Offer 时,其需要支出的价格就会随时间发生多次变动,每个时间段分别对应的不同的价格,PricingPhases 就用于表示 Offer 在每一个时间段的收费规则
例如,某个按月自动续订的基础方案包含一个 Offer,此 Offer 包含一个七天免费试订的优惠策略。那么,此 Offer 的价格阶段就分别是:
- 用户先享受七天的免费试订
- 七天后,用户再按原价按月付费
假如为这个 Offer 再添加一个 “折扣为七折,为期一个月的周期性付款” 的优惠策略,此时 Offer 的价格阶段就变成了:
- 用户先享受七天的免费试订
- 七天后,用户按原价的七折进行付费,获得一个月的订阅期
- 一个月后,用户再按原价按月付费
所以说,价格阶段就决定了用户在不同时间段下所需要支出的费用,每个 Offer 最多允许添加两个价格阶段,也即最多发生三次价格变动,用户会按顺序来接收价格变化
总结
Google Play 设定 BasePlan 和 Offer 的自由度很高。自动续订的 BasePlan 的付费周期可以从一周到一年,预付费的 BasePlan 的付费周期可以从一天到一年。每种优惠策略的优惠周期和优惠价也都可以很灵活地设定。我们可以通过设定多种不同的周期时长和优惠策略供用户选择,从而尽量提高用户的付费率
此外,每个订阅型商品最多可以创建 250 个基础方案和优惠,但同时启用的基础方案和优惠不能超过 50 个,多出的基础方案和优惠必须处于草稿或未启用状态
四、Billing SDK
了解了以上的基础概念后,再来看这些概念如何和 Billing SDK 对应起来
本文所有的代码示例使用的均是当前 Google Play 结算系统在 Android 端最新版本的 SDK,且是协程版本,读者需要对协程有一定了解
dependencies {val billingVersion = "6.0.1"implementation("com.android.billingclient:billing-ktx:$billingVersion")
}
整个支付流程可以总结为以下几点:
- 通过 BillingClient 和 Google Play 建立连接,同时绑定用于回调支付结果的 PurchasesUpdatedListener 接口
- 通过 BillingClient 查询到本地化处理的商品信息,也即 ProductDetails,从而拿到 商品描述、基础方案、价格信息、优惠策略 等属性
- 根据查到的 ProductDetails,向 BillingClient 发起支付请求,调起支付弹窗
- 在 PurchasesUpdatedListener 里拿到支付结果,判断用户的支付状态
- 当确定用户支付成功后,根据商品类型择机对商品进行 确认 或 消耗
BillingClient
BillingClient 是 Google Play 结算库与 App 进行通信的主接口,App 在执行任何与支付相关的操作之前,都需要先通过 BillingClient 和 Google Play 建立连接。在初始化 BillingClient 实例时,需要同时绑定 PurchasesUpdatedListener,以便得到支付结果的回调通知。也正因为如此,App 在同一时间段最多只能保持一个活跃的 BillingClient 连接,以免同一个支付事件同时回调多个 PurchasesUpdatedListener
private val purchasesUpdatedListener =PurchasesUpdatedListener { billingResult: BillingResult, purchases: List<Purchase>? ->}private lateinit var billingClient: BillingClientsuspend fun startConnection(context: Context) {billingClient = buildBillingClient(context = context, purchasesUpdatedListener)startConnection(billingClient = mBillingClient)
}private fun buildBillingClient(context: Context,listener: PurchasesUpdatedListener
): BillingClient {return BillingClient.newBuilder(context).setListener(listener).enablePendingPurchases().build()
}private suspend fun startConnection(billingClient: BillingClient): BillingResult? {return withContext(context = Dispatchers.Default) {if (billingClient.isReady) {return@withContext null}return@withContext suspendCancellableCoroutine { continuation ->billingClient.startConnection(object : BillingClientStateListener {override fun onBillingSetupFinished(billingResult: BillingResult) {if (!continuation.isCompleted) {continuation.resume(value = billingResult)}}override fun onBillingServiceDisconnected() {if (!continuation.isCompleted) {continuation.resume(value = null)}}})}}
}
ProductDetails
ProductDetails 也即商品详情,不管是一次性商品还是订阅型商品,都通过 ProductDetails 来承载具体的商品信息
查询 ProductDetails 需要两个查询参数:ProductId 和 商品类型,商品类型也即 一次性商品 INAPP 和 订阅型商品 SUBS 两种
private suspend fun queryProductDetails() {//查询一次性商品queryProductDetails(billingClient = mBillingClient,productIdList = setOf("1", "2"),productType = BillingClient.ProductType.INAPP)//查询订阅型商品queryProductDetails(billingClient = mBillingClient,productIdList = setOf("1", "2"),productType = BillingClient.ProductType.SUBS)
}private suspend fun queryProductDetails(billingClient: BillingClient,productIdList: Set<String>,productType: String
): List<ProductDetails>? {return withContext(context = Dispatchers.Default) {if (!billingClient.isReady || productIdList.isEmpty()) {return@withContext null}val productDetailParamsList = productIdList.map {QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build()}val queryProductDetailsParams = QueryProductDetailsParams.newBuilder().setProductList(productDetailParamsList).build()val productDetailsResult = billingClient.queryProductDetails(queryProductDetailsParams)productDetailsResult.productDetailsList}
}
ProductDetails 的数据结构如下所示,我们可以依靠这些信息来向用户展示商品详情。oneTimePurchaseOfferDetails 和 subscriptionOfferDetails 这两个字段就分别用来承载一次性商品和订阅型商品的价格信息
{"productId": "","productType": "","title": "","name": "","description": "","oneTimePurchaseOfferDetails": {},"subscriptionOfferDetails": []
}
oneTimePurchaseOfferDetails
oneTimePurchaseOfferDetails 对应的是一次性商品的详情,数据结构比较简单,主要就是价格信息了
{"priceAmountMicros": 548000000,"priceCurrencyCode": "HKD","formattedPrice": "HK$548.00"
}
需要注意,Google Play 返回的价格信息都是做了本地化处理的,会自动根据当前设备的 Google Play 账号所对应的国家地区来返回详情,所以商品的价格货币代号 priceCurrencyCode
和格式化好的商品价格 formattedPrice
都会因实际情况而变化
subscriptionOfferDetails
subscriptionOfferDetails 对应的是订阅型商品的详情
由于订阅型商品是可以包含多个 BasePlan 的,每个 BasePlan 又可以包含多个 Offer,所以 subscriptionOfferDetails 字段在 ProductDetails 中对应的数据类型是 List<SubscriptionOfferDetails>
。每个 SubscriptionOfferDetails 都对应一个 Offer,每个 Offer 又关联一个 BasePlan,Google Play 以 Offer 为单位来返回价格信息
[{"basePlanId": "yearly","offerId": null,"offerToken": "xxx","pricingPhases": {"pricingPhaseList": [{"formattedPrice": "HK$469.00","priceAmountMicros": 469000000,"priceCurrencyCode": "HKD","billingPeriod": "P1Y","billingCycleCount": 0,"recurrenceMode": 1}]}},{"basePlanId": "yearly","offerId": "xxx","offerToken": "xxx","pricingPhases": {"pricingPhaseList": [{"formattedPrice": "免費","priceAmountMicros": 0,"priceCurrencyCode": "HKD","billingPeriod": "P1W","billingCycleCount": 1,"recurrenceMode": 2},{"formattedPrice": "HK$469.00","priceAmountMicros": 469000000,"priceCurrencyCode": "HKD","billingPeriod": "P1Y","billingCycleCount": 0,"recurrenceMode": 1}]}}
]
上文有讲到,Offer 是包含价格阶段 PricingPhases 这个概念的,这个概念就体现在以上 Json 中,当中就可以解读出以下商品信息:
- 该商品包含一个 Id 为 yearly 的 basePlan,一共包含两个 Offer
- offerToken 用于唯一标识每一个 Offer,具有唯一性
- billingPeriod 用于表示计费周期,以 ISO 8601 格式来指定。例如,P1W 表示一周,P1Y 表示一年,P1M3D 表示一个月加三天
- billingCycleCount 用于表示计费周期的周期数。例如,以上的第二个 Offer 的第一个 PricingPhases,就表示允许用户免费试用一周;假如 billingCycleCount 是 2,就表示允许用户免费试用两周
- recurrenceMode 用于表示价格阶段的重复模式,当值为 1 或 3 时,billingCycleCount 值都会是 0
- 值为 1 就表示将在无限的计费周期内重复进行,除非用户主动取消
- 值为 2 就表示将在 billingCycleCount 指定的周期内重复扣费
- 值为 3 表示是一次性收费,不会重复
- 第一个 Offer 的 offerId 为 null,说明此 Offer 不包含实际的优惠策略,代表的其实是 BasePlan 的原价,所以 pricingPhaseList 也会只有一个值。且由于 billingPeriod 是 P1Y,说明关联的 BasePlan 的付费周期是一年。选中此 Offer 后用户就需要直接付 HK$469.00 的原价来进行订阅
- 第二个 Offer 的 offerId 不为 null,说明此 Offer 包含真实的优惠策略,所以 pricingPhaseList 的大小就会大于一。该 Offer 允许用户先免费试用一周,然后再和第一个 Offer 同样的价格和周期来进行订阅
所以说,想要解读出 BasePlan 的定价策略和 Offer 的优惠策略,就需要结合所有字段来进行解析。首先,不管我们在创建 BasePlan 时有没有为其指定优惠策略,Google Play 都会将 BasePlan 的原价视为一个 Offer 并返回,这种情况下 Offer 也只会有一个定价阶段。而对于真实的优惠策略,其 offerId 是必须设定的,自然也就不会为 null,也会有最多三个定价阶段。我们要区分出 “虚假的” Offer 和 "真实的” Offer。然后,再通过 pricingPhases 来解析出 BasePlan 的订阅周期和价格、Offer 的优惠策略、Offer 的价格阶段具体是如何设定的。这样我们才能向用户完整展示整个商品的价格信息
launchBillingFlow
launchBillingFlow 用于调起支付弹窗发起支付操作,根据商品类型,其调用方式分为两种
假如要购买的是一次性商品,支付参数仅需要 ProductDetails 即可
private suspend fun launchBilling(activity: Activity,billingClient: BillingClient,productDetails: ProductDetails
): BillingResult {return withContext(context = Dispatchers.Main.immediate) {val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(productDetails).build()val billingFlowParams = BillingFlowParams.newBuilder().setProductDetailsParamsList(listOf(productDetailsParams)).build()billingClient.launchBillingFlow(activity, billingFlowParams)}
}
假如要购买的是订阅型商品,则需要同时传递 ProductDetails 和 offerToken
由于一个订阅型商品可能同时包含多个 BasePlan 和多个 Offer,每个 Offer 的优惠策略又各不相同。因此 App 在发起支付操作时,就需要通过 offerToken 来标明用户想要购买的到底是哪个 BasePlan,选中的又是哪个 Offer。而由于 Google Play 也会将 BasePlan 的原价视为一个 Offer 并返回,所以我们是可以自主选择要不要让用户享用优惠的,自由度还是比较高的
private suspend fun launchBilling(activity: Activity,billingClient: BillingClient,productDetails: ProductDetails,offerToken: String
): BillingResult {return withContext(context = Dispatchers.Main.immediate) {val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(productDetails).setOfferToken(offerToken).build()val billingFlowParams = BillingFlowParams.newBuilder().setProductDetailsParamsList(listOf(productDetailsParams)).build()billingClient.launchBillingFlow(activity, billingFlowParams)}
}
之后,我们在 PurchasesUpdatedListener 回调里来获取用户的支付结果
假如用户已支付成功,Purchase 就包含了此笔订单的具体信息,包括 ProductId、OrderId、Quantity、PurchaseTime 等
private val purchasesUpdatedListener =PurchasesUpdatedListener { billingResult: BillingResult, purchases: List<Purchase>? ->when (billingResult.responseCode) {BillingClient.BillingResponseCode.OK -> {if (!purchases.isNullOrEmpty()) {purchases.forEach {when (it.purchaseState) {Purchase.PurchaseState.PURCHASED -> {//用户支付成功}Purchase.PurchaseState.PENDING -> {//用户仅是预创建了订单,还未真正付款}Purchase.PurchaseState.UNSPECIFIED_STATE -> {//未知}}}}}BillingClient.BillingResponseCode.USER_CANCELED -> {//用户取消支付}else -> {}}}
acknowledgePurchase
用户支付成功后,就需要对订单进行确认了,否则 Google Play 会在限定时间内退款给用户
private suspend fun acknowledgePurchase(billingClient: BillingClient,purchase: Purchase
): Boolean {return withContext(context = Dispatchers.Default) {if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {return@withContext false}if (purchase.isAcknowledged) {return@withContext true}if (!billingClient.isReady) {return@withContext false}val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()val acknowledgePurchase = billingClient.acknowledgePurchase(acknowledgePurchaseParams)acknowledgePurchase.responseCode == BillingClient.BillingResponseCode.OK}
}
consumePurchase
如果用户购买的是消耗型的一次性商品,那么就需要根据实际业务择机对订单执行消耗操作了
private suspend fun consumePurchase(billingClient: BillingClient,purchase: Purchase
): Boolean {return withContext(context = Dispatchers.Default) {if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {return@withContext false}if (!billingClient.isReady) {return@withContext false}val consumeParams = ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()val consumeResult = billingClient.consumePurchase(consumeParams)consumeResult.billingResult.responseCode == BillingClient.BillingResponseCode.OK}
}
五、鉴权
当用户购买商品后,就需要来考虑如何对用户进行鉴权了。如果鉴权失败或者是鉴权错了,不仅会给用户带来不良体验,引来用户投诉,也有可能会给项目带来不可估量的资金损失
按照一般情况,App 在供用户使用时,App 都会为当前用户创建一个自己账户体系下的用户身份,我们可以称之为 appUser。当用户购买商品后,这笔订单也会和当前设备付款的 Google Play 账号绑定在一起,我们可以称之为 gpUser
如此一来,这笔订单就会和两个不同角度下的用户产生关联。这也就连锁带来一个问题:商品代表的权益应该挂载在哪个用户的名下?appUser 还是 gpUser ?
这两个选择都各有优缺点
挂载在 appUser 名下:
- 优点:用户权益清晰明确,可以精准隔离用户的权益状态
- 缺点:在国外,以游客身份来购买虚拟商品是很常见的情况,假如 App 只允许正式用户(绑定了邮箱或者电话号码)才能购买商品的话,很有可能会流失大部分的潜在付费用户。因此,如果 appUser 是游客的话,当用户卸载应用、更换或者重置设备后,就有可能导致已付费的用户再也找不回这笔订单了
挂载在 gpUser 名下:
- 优点:即使用户卸载应用、更换或者重置设备,只要当前设备登录的就是付款时的 Google Play 账号,App 都能通过 Billing SDK 的
queryPurchasesAsync
方法重新找回该账号名下所有的订单信息,不用担心出现权益丢失的情况。同个 Google Play 账号在不同设备上也能共同享有 App 的权益,用户体验是最好的 - 缺点:App 是无法拿到 gpUser 的唯一身份标识的,容易出现账号倒卖的情况,多个用户通过共享同一个 Google Play 账号来一起享有同一笔订单的权益
所以说,App 需要根据自己的业务类型和用户属性,来决定是否要允许游客也能进行购买操作,用户应该以哪种维度来进行身份鉴权,当发现同笔订单在多台设备上生效时,又应该如何避免资产损失
六、最后
本文主要是以移动端的角度来进行阐述,虽然 Google Play 结算系统也允许在没有 App 后端服务参与的情况下就直接完成整个支付流程并完成用户鉴权,但为了安全性考虑,最好还是需要将订单信息同步保存到服务端,并由服务端对订单进行校验后再决定是否要下发权益。此外,用户是可以在不经过 App 的情况下,直接从 Google Play 中取消订阅或者恢复订阅,App 无法实时获知该笔订单的状态变化,此时 Google Play 也只会通过 开发者实时通知 将这种变化通知给服务端,这种情况下也需要服务端的参与才能完整记录下用户的整个付费状态变化
Android 学习笔录
Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap
相关文章:
App 出海实践:Google Play 结算系统
作者:业志陈 现如今,App 出海热度不减,是很多公司和个人开发者选择的一个市场方向。App 为了实现盈利,除了接入广告这种最常见的变现方式外,就是通过提供各类虚拟商品或者是会员服务来吸引用户付费了,此时 …...
国际慈善日 | 追寻大爱无疆,拓世科技集团的公益之路
每年的9月5日,是联合国大会正式选定的国际慈善日。这一天的设立,旨在通过提高公众对慈善活动的意识,鼓励慈善公益活动通过各种形式在全球范围内得到增强和发展。这是一个向慈善公益事业致敬的日子,同时也是呼吁全球团结一致共同发…...
关于DNS的一些认识
目录 什么是DNS? 一台具有单个DNS的机器可以拥有多个地址吗? 一台计算机可以有多个属于不同顶级域的DNS名字吗? 什么是DNS? DNS是域名系统(Domain Name System)的缩写,它是互联网中用于将域名…...
游戏性能优化
Unity性能优化主要包括以下方面: 1.渲染性能 。包括减少Draw Calls、减少三角面数、使用LOD、使用批处理技术、减少实时光源等,以提高游戏的帧率和渲染效率。 2.内存性能 。包括使用对象池、使用合适的纹理、使用异步加载资源等,以减少内存占…...
公开游戏、基于有向图的游戏
目录 〇,背景 一,公开游戏、策梅洛定理 1,公开游戏 2,策梅洛定理 二,有向图游戏 1,狭义有向图游戏 2,广义有向图游戏 3,狭义有向图游戏的SG数 4,Bash Game 力扣…...
CSS学习笔记05
CSS笔记05 定位 position CSS 属性position - 用于指定一个元素在文档中的定位方式。top,right,bottom 和 left 属性则决定了该元素的最终位置。position 有以下常用的属性值: position: static; - 默认值。指定元素使用正常的布局行为&am…...
Linux查看指定端口是否被占用
在Linux中,可以使用多种方法来检查一个特定端口(例如3306,通常由MySQL使用)是否被占用: 使用netstat命令: 如果系统中已安装了netstat,可以使用以下命令检查3306端口: netstat -tuln | grep 330…...
【Python 自动化】小说推文一键生成思路概述
最近看了一下小说推文成品软件的思路,发现可以完全迁移到我的 BookerAutoVideo 上面来。这篇短文里面,我试着分析一下整个推文视频生成的流程,以及简要阐述一下有什么工具。 整体流程是这样: 分句 原文是按照段落组织的…...
MySQL中的字符集与排序规则详解
在 MySQL 中,字符集(Character Set)用于确定可以在数据库中存储的字符集合,而排序规则(Collation)用于指定比较和排序字符串的规则。下面是关于 MySQL 中字符集和排序规则的一些详细信息: 字符集…...
Java中如何进行加锁??
笔者在上篇文章介绍了线程安全的问题,接下来本篇文章就是来讲解如何避免线程安全问题~~ 前言:创建两个线程,每个线程都实现对同一个变量count各自自增5W次,我们来看一下代码: class Counter{private int count0;publi…...
Pytorch3D多角度渲染.obj模型
3D理解在从自动驾驶汽车和自主机器人到虚拟现实和增强现实的众多应用中发挥着至关重要的作用。在过去的一年里,PyTorch3D已经成为一个越来越流行的开源框架,用于使用Python进行3D深度学习。值得庆幸的是,PyTorch3D 库背后的人员已经完成了实现…...
MyBatisPlus 基础Mapperr接口:增删改查
MyBatisPlus 基础Mapper接口:增删改查 插入一条数据 代码 Testpublic void insert() {User user new User();user.setId(6L);user.setName("张三");user.setAge(25);user.setEmail("zhangsanexample.com");userMapper.insert(user);}日志 数…...
计算机网络与技术——概述
😊计算机网络与技术——概述 👻前言🥏信息时代下计算机网络的发展🌏互联网概述📡计算机网络基本概念📡互联网发展三阶段📡互联网的标准化 🌏互联网的组成📡互联网的边缘部…...
详解TCP/IP协议第三篇:通信数据在OSI通信模型的上下传输
文章目录 一:OSI通信模型间数据传输展示 二:应用层到会话层解析 1:应用层 2:表现层 3:会话层...
《C++ primer plus》精炼(OOP部分)——对象和类(2)
“学习是人类成长的喷泉。” - 亚里士多德 文章目录 内联函数对象的方法和属性构造函数和析构函数构造函数的种类使用构造函数析构函数列表初始化 const成员函数this指针对象数组类作用域作用域为类的常量类作用域内的枚举 内联函数 定义位于类声明中的函数自动成为内联函数。…...
一点感受
做了两天企业数字化转型的评委,涉及全国最顶级的公司、最顶级的实际落地项目案例,由企业真实的落地团队亲自当面讲解。主要是为了了解了解真实的一线、真实的客户、真实的应用现状和应用水平。 (1)现状 我评审的涉及底层技术平台&…...
VirtualBox RockyLinux9 网络连接
有几次都是隔一段时间之后启动虚拟机,用第三方ssh工具就连接不上了。 简单记录一下。 1、VirtualBox设置 2、IP设置 cd /etc/NetworkManager/system-connections/ vim enp0s3.nmconnection[connection] idenp0s3 uuid9c404b41-4636-397c-8feb-5c2ed38ef404 typeet…...
java 实现适配器模式
适配器模式(Adapter Pattern)是一种结构型设计模式,用于将一个类的接口转换成另一个类的接口,使得原本不兼容的类可以协同工作。适配器模式包括两种类型:类适配器和对象适配器。下面分别介绍这两种类型的实现方式。 类…...
后端常用的Linux命令大全
后端常用的Linux命令大全 基础常用命令 Sudo Command 该命令是“superuser do”的缩写。sudo 是最常用的命令之一,可让你执行需要管理或 root 特权和权限的任务。 使用sudo命令时系统会提示用户重新使用密码进行身份验证。接下来,Linux 系统将记录一…...
C++面向对象
C面向对象知识 内存字节对齐 #pragma pack(n) 表示的是设置n字节对齐,windows默认是8字节,linux是4字节,鲲鹏是4字节 struct A{char a;int b;short c; };char占一个字节,起始偏移为零,int占四个字节,min(8,4)4&#x…...
什么是栈顶缓存技术
假设有一个基于流水线架构的处理器,它需要执行一系列指令。这些指令包括加载数据、执行计算和存储结果。在流水线中,不同阶段的指令可以并行执行。 现在考虑一个简单的情况,其中需要执行以下两个指令: 加载数据指令:…...
TDesign的input标签
目录 一、 新建页面01-todolist 二、 t-input标签、t-button标签 2.1 t-input标签 2.1.1 01-todolist.wxml页面 2.2 01-todolist.json页面 2.3 01-todolist.js页面 2.4 01-todolist.wxss页面 2.2 t-button标签 示例1:bind:change 示例2:bind:…...
从零开始学习 Java:简单易懂的入门指南之Map集合(二十三)
Map集合 1.Map集合1.1Map集合概述和特点1.2Map集合的基本功能1.3Map集合的获取功能1.4Map集合的遍历(方式1)1.5Map集合的遍历(方式2) 2.HashMap集合2.1HashMap集合概述和特点2.2HashMap集合应用案例 3.TreeMap集合3.1TreeMap集合概述和特点3.2TreeMap集合应用案例 1.Map集合 1…...
SpringBoot 拦截org.thymeleaf.exceptions.TemplateInputException异常
SpringBoot 拦截thymeleaf异常 org.thymeleaf.exceptions.TemplateInputException异常 org.thymeleaf.exceptions.TemplateProcessingE xception: Could not parse as each: "message : xxx " (template: “xxxx” - line xx, col xx) thymeleaf异常复现 你是故意的…...
Qt之随机数
介绍使用qsrand和qrand生成随机数。 生成随机数 生成随机数主要用到了函数qsrand和qrand,qsrand用来设置种子点,该种子为qrand生成随机数的起始值。如果不调用qsrand,那么qrand()就会自动调用qsrand(1),即系统默认将1作为随机数的起始值。使…...
UWB学习——day2
UWB应用 基于上文UWB学习——day1中对UWB技术的相关优势介绍,UWB技术可广泛应用于以下场景。 WPAN(无线个域网) 基于其高精度(亚厘米级)、低功耗和高穿透性等特征,在以人为基础的个域网中应用广泛&#…...
使用 multiprocessing 多进程处理批量数据
示例代码 import multiprocessingdef process_data(data):# 这里是处理单个数据的过程return data * 2# 待处理的数据 data [1, 2, 3, 4, 5]def normal_func():# 普通处理方式result []for obj in data:result.append(process_data(obj)return resultdef parallel_func():# …...
React 与 TS 结合使用时组件传参总结
在学习 React 时,我们总会遇到在 TS 和 JS 之间切换来开发多个项目,而有时会忘记 TS 的语法,所以编写一下 React 结合 TS 开发时的一些总结知识点,以便后续回顾用。 向组件传递基础参数(字符串、数字和布尔值…...
性能炸裂c++20协程+iocp/epoll,超轻量高性能异步库开发实战
前言: c20出来有一段时间了。其中一大功能就是终于支持协程了(c作为行业大哥大级别的语言,居然到C20才开始支持协程,我也是无力吐槽了,让多少人等了多少年,等了多少青春)但千呼万唤他终于还是来…...
自定义Dynamics 365实施和发布业务解决方案 - 4. 自动化业务流程
本章的主要重点是研究拟议应用程序的关键业务流程的自动化。每个组织每天都有自己独特的业务操作,这些操作是业务的关键部分。有些自动化的业务流程不需要用户交互,有些流程需要用户交互。此外,在某些业务流程中,某些用户操作完成,然后触发自动化流程来完成业务流程。 Dy…...
哪个网站做校招/网络优化工程师骗局
算法,即解决问题的方法。同一个问题,使用不同的算法,虽然得到的结果相同,但是耗费的时间和资源是不同的。 时间复杂度的计算 计算一个算法的时间复杂度,不可能把所有的算法都编写出实际的程序出来让计算机跑…...
政府网站设计要求/产品软文代写
(1)static static关键字:静态。 注意:静态成员在类中,只有一份。非静态成员在对象中、有多少个对象、就有多少分成员。 (2)final final修饰变量:不能被更改。 final修饰方法:不能被覆盖。 final修饰类:…...
WordPress万级数据优化/天津外贸seo推广
213. 打家劫舍 II - 力扣(LeetCode) 一、题目 你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相…...
免费自己制作网站方法/辽源seo
先去下载https://dev.mysql.com/downloads/mysql/解压完是这个样子将这个文件放入到C盘并将文件名字改掉成MySQL配置初始化的my.ini文件的文件解压后的目录并没有的my.ini文件,没关系可以自行创建在安装根目录下添加的my.ini(新建文本文件,将文件类型改为…...
瑞诺国际的员工数量/苏州seo优化公司
之前分组topN 一直都是算子来做的,今天复习一下 sql 怎么直接实现分组topN 首先我们创建一张表 CREATE TABLE student (id int(11) NOT NULL AUTO_INCREMENT,name varchar(20) DEFAULT NULL,course varchar(20) DEFAULT NULL,score int(11) DEFAULT NULL,PRIMARY K…...
现在能用的网站/开发网站多少钱
其实这个很简单,在img我们加入一个a标签,然后 <a hreftencent://message/?uinQQ号码&Site网站地址&Menuyes></a>转载于:https://www.cnblogs.com/jiangu66/archive/2013/04/12/3017508.html...