Tài liệu này hướng dẫn toàn bộ quy trình Paywall SDK, từ:
- Cấu trúc JSON config
- Parse & cung cấp dữ liệu
- Hiển thị UI (Fragment / Dialog)
- Purchase & Sale / Retention flow
- Best practices tối ưu conversion
Phù hợp cho Android Kotlin – app nội bộ & production.
1️⃣ Tổng quan kiến trúc Paywall
Remote Config / API / Local JSON
↓
PaywallDataProvider
↓
PaywallRepository
↓
BasePaywallFragment / BasePaywallDialog
↓
Paywall UI (Full / One / Dialog)
2️⃣ JSON Paywall Config – Chuẩn sử dụng
2.1 JSON mẫu (FULL SCREEN)
{
"uiType": "paywall_full_01",
"ctaId": 0,
"enable": true,
"timesOpen": 0,
"delayButtonX": 2,
"timesFree": "1,2,9,10,14",
"showAdsClose": false,
"packages": [
{
"packageId": "test.com.year.discount",
"packageIdOrigin": "test.com.yearly",
"packageType": "yearly",
"ctaId": 1,
"isPopular": false,
"isSelected": false
},
{
"packageId": "test.com.month.discount",
"packageIdOrigin": "test.com.monthly",
"packageType": "weekly",
"ctaId": 3,
"isPopular": false,
"isSelected": false
},
{
"packageId": "test.com.week.offer",
"packageType": "weekly",
"ctaId": 2,
"isPopular": true,
"isSelected": true
}
]
}
2.2 Giải thích từng field
🔹 Root level
| Field | Ý nghĩa |
|---|---|
uiType |
Mapping UI (paywall_full_01, paywall_dialog_01) |
ctaId |
CTA mặc định nếu chưa chọn package |
enable |
Bật / tắt paywall |
timesOpen |
Số lần app mở mới show (0 = luôn check) |
delayButtonX |
Delay hiển thị nút close (giây) |
timesFree |
Danh sách lần sử dụng được free |
showAdsClose |
Đóng paywall bằng ads |
🔹 Package item
| Field | Ý nghĩa |
|---|---|
packageId |
SKU sale / active |
packageIdOrigin |
SKU gốc (để tính discount) |
packageType |
weekly / monthly / yearly / lifetime |
ctaId |
CTA riêng cho package |
isPopular |
Hiển thị badge “Popular” |
isSelected |
Auto select khi mở paywall |
3️⃣ Parse JSON → PaywallData
fun String.parsePaywall(): PaywallData? {
if (isBlank()) return null
return Json {
ignoreUnknownKeys = true
explicitNulls = false
}.decodeFromString(this)
}
📌 JSON có thể lấy từ:
- Firebase Remote Config
- API backend
- Local config
4️⃣ PaywallDataProvider – Cấp dữ liệu cho SDK
4.1 Cung cấp Paywall config
override fun getPaywallConfig(placementType: String): PaywallData? {
return when (placementType) {
PAYWALL_HOME_ICON -> homeIconJson.parsePaywall()
PAYWALL_SETTING -> settingJson.parsePaywall()
else -> null
}
}
4.2 Retention mapping
override fun getRetentionPlacement(current: String): String? {
return when (current) {
PAYWALL_HOME_ICON -> PAYWALL_HOME_ICON_SALE
else -> null
}
}
5️⃣ PaywallRepository – Luồng sử dụng
5.1 Init
paywallRepository.initialize(isDebugMode = true)
5.2 Check show paywall
if (paywallRepository.shouldShow(PAYWALL_HOME_ICON)) {
showPaywall()
}
5.3 Load data & product
val data = paywallRepository.getPaywallData(PAYWALL_HOME_ICON)
val packages = paywallRepository.getPackages(data)
6️⃣ Hiển thị UI Paywall
6.1 Full Screen – Ví dụ PaywallFull01Fragment
class PaywallFull01Fragment :
BasePaywallFragment<FragmentPaywallFull01Binding>(),
PackageSubCallback {
override val bindingInflater =
FragmentPaywallFull01Binding::inflate
private val adapter by lazy {
PackageSubAdapter(this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.rcvPackage.apply {
layoutManager = GridLayoutManager(context, 3)
adapter = this@PaywallFull01Fragment.adapter
}
binding.btnContinue.setOnClickListener {
adapter.getPaywallProduct()?.let {
buy(it.product)
}
}
binding.imgClose.setOnClickListener { close() }
binding.txtPolicy.setOnClickListener { openPolicy() }
binding.txtTerm.setOnClickListener { openTerm() }
}
override fun bindData(
config: PaywallData,
packages: List<PaywallProduct>
) {
adapter.submitData(packages)
}
override fun onSelectPackage(paywallProduct: PaywallProduct) {
binding.btnContinue.setText(
PaywallCtaUtils.getCtaStringResId(paywallProduct)
)
val descRes = PaywallDescUtils.getDescResIds(
paywallProduct,
PaywallDescUtils.LayoutType.FULL_SCREEN
)
if (descRes.descRes != 0) {
binding.txtDescription.text = PaywallDescUtils.formatDescription(
requireContext(),
descRes.descRes,
paywallProduct
)
}
}
}
📌 Khi dùng: onboarding, hard paywall, feature lock
6.2 Dialog Sale – Ví dụ PaywallDialog01
class PaywallDialog01 :
BasePaywallDialog<DialogPaywall01Binding>() {
override val bindingInflater =
DialogPaywall01Binding::inflate
private var currentItem: PaywallProduct? = null
override fun bindData(
config: PaywallData,
packages: List<PaywallProduct>
) {
val item = packages.firstOrNull() ?: return
currentItem = item
binding.txtPrice.text = item.product.priceText
binding.btnContinue.setText(
PaywallCtaUtils.getCtaStringResId(item)
)
val descRes = PaywallDescUtils.getDescResIds(
item,
PaywallDescUtils.LayoutType.DIALOG_1_ITEM
)
if (descRes.descRes != 0) {
binding.txtDescription.text = PaywallDescUtils.formatDescription(
requireContext(),
descRes.descRes,
item
)
}
binding.btnContinue.setOnClickListener {
currentItem?.let { buy(it) }
}
binding.imgClose.setOnClickListener { close() }
}
}
📌 Khi dùng: retention, sale off, exit intent
6.2 Dialog (Sale / Retention)
PaywallDialog01()
Dùng cho:
- Sale
- Exit intent
7️⃣ Package selection & CTA
PaywallCtaUtils.getCtaStringResId(paywallProduct)
CTA thay đổi theo:
- Trial
- Sale
- Lifetime
8️⃣ Discount & Sale logic
-
packageIdOrigin≠ null → enable sale - Tính %OFF từ giá gốc
- Gạch ngang giá cũ
9️⃣ Purchase flow
buy(paywallProduct)
SDK xử lý:
- Billing
- Verify
- Emit purchaseStatus
🔟 Retention Flow (chuẩn)
Paywall A
↓ close
Sale Paywall Dialog
↓ close
No more paywall
1️⃣1️⃣ Best Practices (Rất quan trọng)
✅ Full screen: 2–3 packages
✅ Dialog sale: 1 package
✅ Weekly = entry price thấp
✅ Yearly = best value
✅ Lifetime = anchor price
1️⃣2️⃣ Checklist tích hợp nhanh
- [ ] JSON config hợp lệ
- [ ] Map placement → config
- [ ] Init repository
- [ ] Test sandbox purchase
- [ ] Test sale / retention
1️⃣3️⃣ Ví dụ thực tế: Tích hợp Paywall trong SettingFragment
Mục tiêu
- Soft paywall (user chủ động mua)
- Upsell nhẹ, không làm gián đoạn trải nghiệm
- Cho phép quản lý subscription sau khi mua
Inject dependency
private val paywallRepository: PaywallRepository by inject()
private val launcher: PaywallLauncher by inject()
Hiển thị Subscribe CTA theo config & trạng thái mua
ctlSub.isVisible =
paywallRepository
.getPaywallData(Constants.PaywallConfigKey.PAYWALL_SETTING)
?.enable == true
txtSubDes.text = if (paywallRepository.isPurchased()) {
getString(R.string.unlocked_all_features)
} else {
getString(R.string.upgrade_to_unlock_all)
}
Manage Subscription (chỉ khi là subscription)
ctlManageSubscription.isVisible =
paywallRepository.isPurchased() &&
paywallRepository.getCurrentPackagePurchased()?.type != PandaPackageType.LIFETIME
📌 Lifetime không cần manage → UX chuẩn Play Store
Click Subscribe → Mở Paywall bằng Launcher
ctlSub.setOnClickListener {
lifecycleScope.launch {
val activity = activity ?: return@launch
when (
launcher.launch(
activity,
Constants.PaywallConfigKey.PAYWALL_SETTING
)
) {
PaywallResult.Success -> {
ctlManageSubscription.isVisible =
paywallRepository.isPurchased() &&
paywallRepository.getCurrentPackagePurchased()?.type != PandaPackageType.LIFETIME
txtSubDes.text = getString(R.string.unlocked_all_features)
NativeAdManager.disableAllAds()
InterManager.disableAllAds()
RewardAdsManager.disableAllAds()
BannerAdManager.disableAllAds()
PandaResumeAd.INSTANCE?.disableAllAds()
}
PaywallResult.Dismissed -> {
// User đóng paywall
}
PaywallResult.NotShown -> {
// Đã mua hoặc chưa đủ điều kiện show
}
}
}
}
Flow tổng quát
Setting Screen
↓
Subscribe CTA
↓ click
PaywallLauncher
↓
┌───────────────┬───────────────┬───────────────┐
│ Success │ Dismissed │ NotShown │
│ Disable ads │ Log event │ Continue app │
│ Update UI │ │ │
└───────────────┴───────────────┴───────────────┘
Best Practices cho Setting Paywall
✅ Soft paywall, không ép
✅ Không show paywall nếu enable = false
✅ Lifetime ≠ manage subscription
✅ Disable ads chỉ khi Success
✅ Update UI ngay, không reload màn hình
Top comments (0)