๐ Paywall SDK โ ์ฌ์ฉ ์ค๋ช ์ A๋ถํฐ Z๊น์ง (์ํ JSON ํฌํจ)
Source: Dev.to
๋ฒ์ญํ ํ ์คํธ๋ฅผ ์ ๊ณตํด ์ฃผ์๋ฉด ํ๊ตญ์ด๋ก ๋ฒ์ญํด ๋๋ฆฌ๊ฒ ์ต๋๋ค.
Paywall SDK โ ๋น ๋ฅธ ์ฐธ๊ณ
๋์: AndroidโฏKotlin (๋ด๋ถ ๋ฐ ํ๋ก๋์
์ฑ)
๋ฒ์:
- JSON ๊ตฌ์ฑ ๊ตฌ์กฐ
- ํ์ฑ ๋ฐ ๋ฐ์ดํฐ ์ ๊ณต
- UI ๋ ๋๋ง (Fragment / Dialog)
- ๊ตฌ๋งค, ํ๋งค ๋ฐ ์ ์ง ํ๋ฆ
- ์ ํ ์ต์ ํ ๋ฒ ์คํธ ํ๋ํฐ์ค
์ํคํ ์ฒ ํ๋ฆ
Remote Config / API / Local JSON
โ
PaywallDataProvider
โ
PaywallRepository
โ
BasePaywallFragment / BasePaywallDialog
โ
Paywall UI (Full / One / Dialog)
์์ JSON ๊ตฌ์ฑ
{
"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
}
]
}
ํ๋ ์ ์
ํ์ด์โ์์ค ํ๋
| Field | Meaning |
|---|---|
| uiType | UI ๋งคํ (์: paywall_full_01, paywall_dialog_01) |
| ctaId | ํจํค์ง๋ฅผ ์ ํํ์ง ์์์ ๋ ๊ธฐ๋ณธ CTA |
| enable | ํ์ด์์ ์ผ๊ธฐ/๋๊ธฐ |
| timesOpen | ํ์๋๊ธฐ ์ ์ฑ ์คํ ํ์ (0 = ํญ์) |
| delayButtonX | ๋ซ๊ธฐ ๋ฒํผ์ด ๋ํ๋๊ธฐ ์ ์ง์ฐ ์๊ฐ(์ด) |
| timesFree | ๋ฌด๋ฃ์ธ ์คํ ํ์์ ์ผํ ๊ตฌ๋ถ ๋ชฉ๋ก |
| showAdsClose | ๊ด๊ณ ๋ฅผ ํตํด ํ์ด์ ๋ซ๊ธฐ ํ์ฉ |
ํจํค์งโ์์ค ํ๋
| Field | Meaning |
|---|---|
| packageId | ํ๋งค/ํ์ฑํ๋ SKU |
| packageIdOrigin | ์๋ณธ SKU (ํ ์ธ ๊ณ์ฐ์ ์ฌ์ฉ) |
| packageType | weekly / monthly / yearly / lifetime |
| ctaId | ํด๋น ํจํค์ง ์ ์ฉ CTA |
| isPopular | โPopularโ ๋ฐฐ์ง ํ์ |
| isSelected | ํ์ด์์ด ์ด๋ฆด ๋ ์๋ ์ ํ |
ํ์ฑ ๋์ฐ๋ฏธ
fun String.parsePaywall(): PaywallData? {
if (isBlank()) return null
return Json {
ignoreUnknownKeys = true
explicitNulls = false
}.decodeFromString(this)
}
JSON์ ์ํ ์์ค
- Firebase ์๊ฒฉ ๊ตฌ์ฑ
- ๋ฐฑ์๋ API
- ๋ก์ปฌ ํ์ผ
๋ฆฌํฌ์งํ ๋ฆฌ ์ฌ์ฉ๋ฒ
override fun getPaywallConfig(placementType: String): PaywallData? {
return when (placementType) {
PAYWALL_HOME_ICON -> homeIconJson.parsePaywall()
PAYWALL_SETTING -> settingJson.parsePaywall()
else -> null
}
}
override fun getRetentionPlacement(current: String): String? {
return when (current) {
PAYWALL_HOME_ICON -> PAYWALL_HOME_ICON_SALE
else -> null
}
}
paywallRepository.initialize(isDebugMode = true)
if (paywallRepository.shouldShow(PAYWALL_HOME_ICON)) {
showPaywall()
}
val data = paywallRepository.getPaywallData(PAYWALL_HOME_ICON)
val packages = paywallRepository.getPackages(data)
์ ์ฒด ํ๋ฉด ํ์ด์ ํ๋๊ทธ๋จผํธ (์์)
class PaywallFull01Fragment :
BasePaywallFragment(),
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) {
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
)
}
}
}
๋จ์ผโํจํค์ง ํ์ด์ ๋ค์ด์ผ๋ก๊ทธ (์์)
class PaywallDialog01 :
BasePaywallDialog() {
override val bindingInflater = DialogPaywall01Binding::inflate
private var currentItem: PaywallProduct? = null
override fun bindData(config: PaywallData, packages: List) {
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() }
}
}
์ฌ์ฉ ์ฌ๋ก โ ์จ๋ณด๋ฉ, ํ๋ ํ์ด์, ๊ธฐ๋ฅ ์ ๊ธ, ์ ์ง, ์ธ์ผโ์คํ, ์ดํ ๋ฐฉ์ง.
CTA ๋ก์ง
PaywallCtaUtils.getCtaStringResId(paywallProduct)
- CTA๋ ์ฒดํ / ์ธ์ผ / ํ์์ ๋ฐ๋ผ ๋ฌ๋ผ์ง๋๋ค.
packageIdOrigin != null์ด๋ฉด โ ์ธ์ผ ๋ชจ๋๊ฐ ํ์ฑํ๋ฉ๋๋ค.- ํ ์ธ %๋ ์๋ ๊ฐ๊ฒฉ์ ๊ธฐ์ค์ผ๋ก ๊ณ์ฐ๋ฉ๋๋ค.
- ์๋ ๊ฐ๊ฒฉ์ ์ทจ์์ ์ผ๋ก ํ์๋ฉ๋๋ค.
๊ตฌ๋งค ํ๋ฆ (SDK์์ ์ฒ๋ฆฌ):
- ๊ฒฐ์ ์์ฒญ
- ๊ฒ์ฆ
purchaseStatus๋ฐฉ์ถ
Paywall A โ close
Sale Paywall Dialog โ close
โ No further paywall
Quick Checklist
- ์ ํจํ JSON ๊ตฌ์ฑ
- ๋ฐฐ์น โ ๊ตฌ์ฑ ๋งคํ
- ์ ์ฅ์ ์ด๊ธฐํ
Recommended UI Patterns
| UI ์ ํ | ์ผ๋ฐ์ ์ธ ํจํค์ง | ๋น๊ณ |
|---|---|---|
| ์ ์ฒด ํ๋ฉด | 2โ3๊ฐ์ ํจํค์ง | ํ์์ ์ข์ |
| ๋ค์ด์ผ๋ก๊ทธ (์ธ์ผ) | 1๊ฐ ํจํค์ง | ์ข ๋ฃ ์๋ ๋๋ ์ ํ ์๊ฐ ์ ์์ ์ด์์ |
| ์ฃผ๊ฐ | ๋ฎ์ ์ง์ ๊ฐ๊ฒฉ | ์ ๋ฌธ์ฉ ํ |
| ์ฐ๊ฐ | ์ต๊ณ ์ ๊ฐ์น | ์ฅ๊ธฐ ์ฝ์์ ์ฅ๋ ค |
| ํ์ | ๊ธฐ์ค ๊ฐ๊ฒฉ | ๊ณ ๊ฐ ์ ์ |
Subscription Flow Overview
- [ ] Test sandbox purchase
- [ ] Test sale / retention
Soft Paywall (userโinitiated purchase)
- Light upsell โ does not interrupt the user experience.
- Allows management of the subscription after purchase.
Kotlin ๊ตฌํ
private val paywallRepository: PaywallRepository by inject()
private val launcher: PaywallLauncher by inject()
// Show the Subscribe CTA only when the paywall is enabled
ctlSub.isVisible =
paywallRepository
.getPaywallData(Constants.PaywallConfigKey.PAYWALL_SETTING)
?.enable == true
// Text description depends on purchase state
txtSubDes.text = if (paywallRepository.isPurchased()) {
getString(R.string.unlocked_all_features)
} else {
getString(R.string.upgrade_to_unlock_all)
}
// Show โManage subscriptionโ only for nonโlifetime purchases
ctlManageSubscription.isVisible =
paywallRepository.isPurchased() &&
paywallRepository.getCurrentPackagePurchased()?.type != PandaPackageType.LIFETIME
// ๐ Lifetime subscriptions do **not** need a โmanageโ button โ matches Play Store UX
ctlSub.setOnClickListener {
lifecycleScope.launch {
val activity = activity ?: return@launch
when (
launcher.launch(
activity,
Constants.PaywallConfigKey.PAYWALL_SETTING
)
) {
PaywallResult.Success -> {
// Update UI after a successful purchase
ctlManageSubscription.isVisible =
paywallRepository.isPurchased() &&
paywallRepository.getCurrentPackagePurchased()?.type != PandaPackageType.LIFETIME
txtSubDes.text = getString(R.string.unlocked_all_features)
// Disable all ad formats
NativeAdManager.disableAllAds()
InterManager.disableAllAds()
RewardAdsManager.disableAllAds()
BannerAdManager.disableAllAds()
PandaResumeAd.INSTANCE?.disableAllAds()
}
PaywallResult.Dismissed -> {
// User closed the paywall
}
PaywallResult.NotShown -> {
// Already purchased or not eligible to show the paywall
}
}
}
}
UI ํ๋ฆ ๋ค์ด์ด๊ทธ๋จ
Setting Screen
โ
Subscribe CTA
โ click
PaywallLauncher
โ
โโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโ
โ Success โ Dismissed โ NotShown โ
โ Disable ads โ Log event โ Continue app โ
โ Update UI โ โ โ
โโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโ
์ฒดํฌ๋ฆฌ์คํธ
- โ ์ํํธ ํ์ด์ โ ๊ฐ์ ๊ตฌ๋งค ์์.
- โ
enable = false์ผ ๋ ํ์ด์์ด ์จ๊ฒจ์ง. - โ ํ์ ๊ตฌ๋ โ โ๊ตฌ๋ ๊ด๋ฆฌโ.
- โ
๊ด๊ณ ๋
Success์์๋ง ๋นํ์ฑํ๋จ. - โ UI๊ฐ ์ฆ์ ์ ๋ฐ์ดํธ โ ํ๋ฉด ์๋ก๊ณ ์นจ ํ์ ์์.