이 Codelab 정보
1. 소개
Flutter 앱에 인앱 구매를 추가하려면 App Store 및 Play 스토어를 올바르게 설정하고, 구매를 인증하고, 구독 혜택과 같은 필요한 권한을 부여해야 합니다.
이 Codelab에서는 앱에 세 가지 유형의 인앱 구매를 추가하고 (제공됨) Firebase와 함께 Dart 백엔드를 사용하여 이러한 구매를 확인합니다. 제공된 앱인 Dash Clicker에는 Dash 마스코트를 통화로 사용하는 게임이 포함되어 있습니다. 다음과 같은 구매 옵션을 추가합니다.
- 한 번에 2,000개의 Dash를 구매할 수 있는 반복 구매 옵션입니다.
- 기존 스타일의 대시를 최신 스타일의 대시로 업그레이드하는 일회성 구매입니다.
- 자동 생성된 클릭수를 두 배로 늘리는 구독입니다.
첫 번째 구매 옵션은 사용자에게 2,000개의 Dash를 직접 제공합니다. 사용자는 직접 구매할 수 있으며 여러 번 구매할 수 있습니다. 이는 직접 소비되고 여러 번 소비될 수 있으므로 소모성이라고 합니다.
두 번째 옵션은 Dash를 더 멋진 Dash로 업그레이드합니다. 한 번만 구매하면 영구적으로 사용할 수 있습니다. 이러한 구매는 앱에서 사용할 수 없지만 영구적으로 유효하므로 비소모성이라고 합니다.
세 번째이자 마지막 구매 옵션은 정기 결제입니다. 정기 결제가 활성 상태인 동안에는 사용자가 더 빨리 대시를 받을 수 있지만 정기 결제 요금을 더 이상 지불하지 않으면 혜택도 사라집니다.
백엔드 서비스 (개발자도 제공)는 Dart 앱으로 실행되며 구매가 이루어졌는지 확인하고 Firestore를 사용하여 저장합니다. Firestore는 이 프로세스를 더 쉽게 하는 데 사용되지만 프로덕션 앱에서는 어떤 유형의 백엔드 서비스든 사용할 수 있습니다.
빌드할 항목
- 소모성 구매 및 정기 결제를 지원하도록 앱을 확장합니다.
- 또한 Dart 백엔드 앱을 확장하여 구매한 항목을 확인하고 저장합니다.
학습 내용
- 구매 가능한 제품으로 App Store 및 Play 스토어를 구성하는 방법
- 스토어와 통신하여 구매를 확인하고 Firestore에 저장하는 방법
- 앱에서 구매를 관리하는 방법
필요한 항목
- Android 스튜디오 4.1 이상
- Xcode 12 이상(iOS 개발용)
- Flutter SDK
2. 개발 환경 설정
이 Codelab을 시작하려면 코드를 다운로드하고 iOS의 번들 식별자와 Android의 패키지 이름을 변경합니다.
코드 다운로드
명령줄에서 GitHub 저장소를 클론하려면 다음 명령어를 사용합니다.
git clone https://github.com/flutter/codelabs.git flutter-codelabs
또는 GitHub의 CLI 도구가 설치되어 있으면 다음 명령어를 사용합니다.
gh repo clone flutter/codelabs flutter-codelabs
샘플 코드는 Codelab 모음의 코드가 포함된 flutter-codelabs
디렉터리에 클론됩니다. 이 Codelab의 코드는 flutter-codelabs/in_app_purchases
에 있습니다.
flutter-codelabs/in_app_purchases
아래의 디렉터리 구조에는 각 이름이 지정된 단계의 끝에 있어야 하는 위치의 일련의 스냅샷이 포함되어 있습니다. 시작 코드는 0단계에 있으므로 일치하는 파일을 찾는 방법은 다음과 같이 간단합니다.
cd flutter-codelabs/in_app_purchases/step_00
앞으로 건너뛰거나 한 단계를 완료한 후의 결과를 보려면 관심 있는 단계의 이름을 따서 지정된 디렉터리를 살펴보세요. 마지막 단계의 코드는 complete
폴더에 있습니다.
시작 프로젝트 설정
원하는 IDE에서 step_00
의 시작 프로젝트를 엽니다. 스크린샷을 위해 Android 스튜디오를 사용했지만 Visual Studio Code도 좋은 옵션입니다. 두 편집기 중 하나를 사용하여 최신 Dart 및 Flutter 플러그인이 설치되어 있는지 확인합니다.
만들려는 앱은 App Store 및 Play 스토어와 통신하여 어떤 제품을 어떤 가격에 구매할 수 있는지 확인해야 합니다. 모든 앱은 고유한 ID로 식별됩니다. iOS App Store의 경우 번들 식별자라고 하며 Android Play 스토어의 경우 애플리케이션 ID라고 합니다. 이러한 식별자는 일반적으로 역방향 도메인 이름 표기법을 사용하여 만들어집니다. 예를 들어 flutter.dev용 인앱 구매 앱을 만들 때는 dev.flutter.inapppurchase
를 사용합니다. 앱의 식별자를 생각해 보세요. 이제 프로젝트 설정에서 이를 설정할 것입니다.
먼저 iOS의 번들 식별자를 설정합니다.
Android 스튜디오에서 프로젝트를 연 상태로 iOS 폴더를 마우스 오른쪽 버튼으로 클릭하고 Flutter를 클릭한 다음 Xcode 앱에서 모듈을 엽니다.
Xcode의 폴더 구조에서 Runner 프로젝트가 맨 위에 있고 Flutter, Runner, Products 타겟이 Runner 프로젝트 아래에 있습니다. Runner를 더블클릭하여 프로젝트 설정을 수정한 다음 서명 및 기능을 클릭합니다. 팀 필드에서 방금 선택한 번들 식별자를 입력하여 팀을 설정합니다.
이제 Xcode를 닫고 Android 스튜디오로 돌아가 Android 구성을 완료할 수 있습니다. 이렇게 하려면 android/app,
아래의 build.gradle
파일을 열고 applicationId
(아래 스크린샷의 37번 줄)를 iOS 번들 식별자와 동일한 애플리케이션 ID로 변경합니다. iOS 스토어와 Android 스토어의 ID가 동일할 필요는 없지만 동일하게 유지하면 오류가 발생할 가능성이 줄어들므로 이 Codelab에서는 동일한 식별자를 사용합니다.
3. 플러그인 설치
이 Codelab에서는 in_app_purchase 플러그인을 설치합니다.
pubspec에 종속 항목 추가
pubspec의 종속 항목에 in_app_purchase
를 추가하여 pubspec에 in_app_purchase
를 추가합니다.
$ cd app $ flutter pub add in_app_purchase dev:in_app_purchase_platform_interface
pubspec.yaml
을 열고 dependencies
아래에 in_app_purchase
가, dev_dependencies
아래에 in_app_purchase_platform_interface
가 항목으로 표시되는지 확인합니다.
pubspec.yaml
dependencies:
flutter:
sdk: flutter
cloud_firestore: ^5.5.1
cupertino_icons: ^1.0.8
firebase_auth: ^5.3.4
firebase_core: ^3.8.1
google_sign_in: ^6.2.2
http: ^1.2.2
intl: ^0.20.1
provider: ^6.1.2
in_app_purchase: ^3.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
in_app_purchase_platform_interface: ^1.4.0
pub get을 클릭하여 패키지를 다운로드하거나 명령줄에서 flutter pub get
을 실행합니다.
4. App Store 설정
iOS에서 인앱 구매를 설정하고 테스트하려면 App Store에서 새 앱을 만들고 구매 가능한 제품을 만들어야 합니다. 검토를 위해 게시하거나 앱을 Apple에 전송할 필요가 없습니다. 이 작업을 하려면 개발자 계정이 필요합니다. 계정이 없는 경우 Apple 개발자 프로그램에 등록하세요.
유료 앱 계약
인앱 구매를 사용하려면 App Store Connect에서 유료 앱에 대한 활성 계약도 있어야 합니다. https://appstoreconnect.apple.com/으로 이동하여 계약, 세금, 은행을 클릭합니다.
여기에서 무료 및 유료 앱에 대한 계약을 확인할 수 있습니다. 무료 앱의 상태는 활성 상태여야 하며 유료 앱의 상태는 신규 상태여야 합니다. 약관을 확인하고 동의한 후 필요한 정보를 모두 입력하세요.
모든 설정이 올바르게 되면 유료 앱의 상태가 활성 상태로 표시됩니다. 활성 상태의 계약이 없으면 인앱 구매를 사용해 볼 수 없으므로 이 부분이 매우 중요합니다.
앱 ID 등록
Apple 개발자 포털에서 새 식별자를 만듭니다.
앱 ID 선택
앱 선택
설명을 입력하고 번들 ID를 XCode에서 이전에 설정한 것과 동일한 값으로 설정합니다.
새 앱 ID를 만드는 방법에 관한 자세한 내용은 개발자 계정 도움말을 참고하세요 .
새 앱 만들기
App Store Connect에서 고유한 번들 식별자로 새 앱을 만듭니다.
새 앱을 만들고 계약을 관리하는 방법에 관한 자세한 내용은 App Store Connect 도움말을 참고하세요.
인앱 구매를 테스트하려면 샌드박스 테스트 사용자가 필요합니다. 이 테스트 사용자는 iTunes에 연결되어서는 안 됩니다. 인앱 구매를 테스트하는 데만 사용됩니다. 이미 Apple 계정에 사용 중인 이메일 주소는 사용할 수 없습니다. 사용자 및 액세스의 샌드박스 아래에 있는 테스터로 이동하여 새 샌드박스 계정을 만들거나 기존 샌드박스 Apple ID를 관리합니다.
이제 설정 > App Store > 샌드박스 계정으로 이동하여 iPhone에서 샌드박스 사용자를 설정할 수 있습니다.
인앱 구매 구성하기
이제 구매 가능한 세 가지 항목을 구성합니다.
dash_consumable_2k
: 여러 번 구매할 수 있는 소비성 구매로, 구매할 때마다 사용자에게 2,000개의 Dash (인앱 통화)가 부여됩니다.dash_upgrade_3d
: 한 번만 구매할 수 있으며 사용자에게 클릭할 수 있는 외관상 다른 대시를 제공하는 비소비성 '업그레이드' 구매입니다.dash_subscription_doubler
: 구독 기간 동안 사용자에게 클릭당 두 배의 대시를 부여하는 구독입니다.
인앱 구매 > 관리로 이동합니다.
지정된 ID로 인앱 구매를 만듭니다.
dash_consumable_2k
를 소모성으로 설정합니다.
dash_consumable_2k
를 제품 ID로 사용합니다. 참조 이름은 App Store Connect에서만 사용됩니다. dash consumable 2k
로 설정하고 구매의 현지화를 추가하기만 하면 됩니다. 2000 dashes fly out
을 설명으로 사용하여 구매 Spring is in the air
를 호출합니다.
dash_upgrade_3d
를 비소비성으로 설정합니다.
dash_upgrade_3d
를 제품 ID로 사용합니다. 참조 이름을 dash upgrade 3d
로 설정하고 구매의 현지화를 추가합니다. Brings your dash back to the future
을 설명으로 사용하여 구매 3D Dash
를 호출합니다.
dash_subscription_doubler
를 자동 갱신 정기 결제로 설정합니다.
구독 흐름은 약간 다릅니다. 먼저 참조 이름과 제품 ID를 설정해야 합니다.
다음으로 구독 그룹을 만들어야 합니다. 여러 정기 결제가 동일한 그룹에 속하는 경우 사용자는 한 번에 하나의 정기 결제만 구독할 수 있지만 이러한 정기 결제 간에 쉽게 업그레이드하거나 다운그레이드할 수 있습니다. 이 그룹을 subscriptions
라고 부르세요.
그런 다음 구독 기간과 현지화를 입력합니다. 이 구독의 이름을 Jet Engine
로 지정하고 설명을 Doubles your clicks
로 지정합니다. 저장을 클릭합니다.
저장 버튼을 클릭한 후 정기 결제 가격을 추가합니다. 원하는 가격을 선택합니다.
이제 구매 목록에 세 건의 구매 내역이 표시됩니다.
5. Play 스토어 설정
App Store와 마찬가지로 Play 스토어에도 개발자 계정이 필요합니다. 아직 계정이 없는 경우 계정을 등록하세요.
새 앱 만들기
Google Play Console에서 새 앱을 만듭니다.
- Play Console을 엽니다.
- 모든 앱 > 앱 만들기를 선택합니다.
- 기본 언어를 선택하고 앱 제목을 추가합니다. Google Play에 표시하려는 앱 이름을 입력하세요. 이름은 나중에 변경할 수 있습니다.
- 애플리케이션이 게임이라고 지정합니다. 나중에 변경할 수 있습니다.
- 애플리케이션이 무료인지 유료인지 지정합니다.
- Play 스토어 사용자가 이 애플리케이션과 관련해 연락할 수 있는 이메일 주소를 추가합니다.
- 콘텐츠 가이드라인 및 미국 수출법 선언을 작성합니다.
- 앱 만들기를 선택합니다.
앱이 생성되면 대시보드로 이동하여 앱 설정 섹션의 모든 작업을 완료합니다. 여기에서 콘텐츠 등급, 스크린샷 등 앱에 관한 몇 가지 정보를 제공합니다.
애플리케이션 서명하기
인앱 구매를 테스트하려면 Google Play에 업로드된 빌드가 하나 이상 있어야 합니다.
이를 위해서는 출시 빌드가 디버그 키가 아닌 다른 것으로 서명되어야 합니다.
키 저장소 만들기
기존 키 저장소가 있는 경우 다음 단계로 건너뜁니다. 그렇지 않은 경우 명령줄에서 다음을 실행하여 하나 만듭니다.
Mac/Linux에서는 다음 명령어를 사용합니다.
keytool -genkey -v -keystore ~/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key
Windows의 경우 다음 명령어를 사용합니다.
keytool -genkey -v -keystore c:\Users\USER_NAME\key.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias key
이 명령어는 key.jks
파일을 홈 디렉터리에 저장합니다. 파일을 다른 위치에 저장하려면 -keystore
매개변수에 전달하는 인수를 변경합니다. 계속 유지
keystore
비공개 파일로 저장하고 공개 소스 제어에 체크인하지 마세요.
앱에서 키 저장소 참조
키 저장소 참조가 포함된 <your app dir>/android/key.properties
이라는 파일을 만듭니다.
storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=key
storeFile=<location of the key store file, such as /Users/<user name>/key.jks>
Gradle에서 서명 구성
<your app dir>/android/app/build.gradle
파일을 수정하여 앱의 서명을 구성합니다.
속성 파일의 키 저장소 정보를 android
블록 앞에 추가합니다.
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
// omitted
}
key.properties
파일을 keystoreProperties
객체로 로드합니다.
buildTypes
블록 앞에 다음 코드를 추가합니다.
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now,
// so `flutter run --release` works.
signingConfig signingConfigs.debug
}
}
서명 구성 정보로 모듈의 build.gradle
파일에서 signingConfigs
블록을 구성합니다.
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
이제 앱의 출시 빌드가 자동으로 서명됩니다.
앱 서명에 관한 자세한 내용은 developer.android.com의 앱 서명을 참고하세요.
첫 빌드 업로드
앱이 서명되도록 구성되면 다음을 실행하여 애플리케이션을 빌드할 수 있습니다.
flutter build appbundle
이 명령어는 기본적으로 출시 빌드를 생성하며 출력은 <your app dir>/build/app/outputs/bundle/release/
에서 확인할 수 있습니다.
Google Play Console의 대시보드에서 출시 > 테스트 > 비공개 테스트로 이동하여 새 비공개 테스트 버전을 만듭니다.
이 Codelab에서는 Google에서 앱에 서명하도록 하므로 Play 앱 서명에서 계속을 눌러 선택합니다.
그런 다음 빌드 명령어로 생성된 app-release.aab
App Bundle을 업로드합니다.
저장을 클릭한 다음 버전 검토를 클릭합니다.
마지막으로 내부 테스트로 출시 시작을 클릭하여 내부 테스트 버전을 활성화합니다.
테스트 사용자 설정하기
인앱 구매를 테스트하려면 테스터의 Google 계정을 다음 두 위치에 Google Play Console에 추가해야 합니다.
- 특정 테스트 트랙 (내부 테스트)
- 라이선스 테스터로서
먼저 테스터를 내부 테스트 트랙에 추가합니다. 출시 > 테스트 > 내부 테스트로 돌아가서 테스터 탭을 클릭합니다.
이메일 목록 만들기를 클릭하여 새 이메일 목록을 만듭니다. 목록에 이름을 지정하고 인앱 구매 테스트에 액세스해야 하는 Google 계정의 이메일 주소를 추가합니다.
그런 다음 목록의 체크박스를 선택하고 변경사항 저장을 클릭합니다.
그런 다음 라이선스 테스터를 추가합니다.
- Google Play Console의 모든 앱 뷰로 돌아갑니다.
- 설정 > 라이선스 테스트로 이동합니다.
- 인앱 구매를 테스트해야 하는 테스터의 동일한 이메일 주소를 추가합니다.
- 라이선스 응답을
RESPOND_NORMALLY
로 설정합니다. - 변경사항 저장을 클릭합니다.
인앱 구매 구성하기
이제 앱 내에서 구매할 수 있는 상품을 구성합니다.
App Store와 마찬가지로 다음 세 가지 구매를 정의해야 합니다.
dash_consumable_2k
: 여러 번 구매할 수 있는 소비성 구매로, 구매할 때마다 사용자에게 2,000개의 Dash (인앱 통화)가 부여됩니다.dash_upgrade_3d
: 한 번만 구매할 수 있는 비소비성 '업그레이드' 구매로, 사용자에게 클릭할 수 있는 외관상 다른 대시를 제공합니다.dash_subscription_doubler
: 구독 기간 동안 사용자에게 클릭당 두 배의 대시를 부여하는 구독입니다.
먼저 소모성 및 비소모성 항목을 추가합니다.
- Google Play Console로 이동하여 애플리케이션을 선택합니다.
- 수익 창출 > 제품 > 인앱 상품으로 이동합니다.
- 제품 만들기를 클릭합니다.
- 제품에 대한 모든 필수 정보를 입력합니다. 제품 ID가 사용하려는 ID와 정확히 일치하는지 확인합니다.
- 저장을 클릭합니다.
- 활성화를 클릭합니다.
- 비소비성 '업그레이드' 구매에 대해 이 절차를 반복합니다.
다음으로 구독을 추가합니다.
- Google Play Console로 이동하여 애플리케이션을 선택합니다.
- 수익 창출 > 제품 > 구독으로 이동합니다.
- 구독 만들기를 클릭합니다.
- 정기 결제에 필요한 모든 정보를 입력합니다. 제품 ID가 사용하려는 ID와 정확하게 일치하는지 확인합니다.
- 저장을 클릭합니다.
이제 Play Console에서 구매 항목이 설정됩니다.
6. Firebase 설정
이 Codelab에서는 백엔드 서비스를 사용하여 사용자의 구매를 확인하고 추적합니다.
백엔드 서비스를 사용하면 다음과 같은 여러 이점이 있습니다.
- 거래를 안전하게 확인할 수 있습니다.
- 앱 스토어의 결제 이벤트에 반응할 수 있습니다.
- 데이터베이스에서 구매를 추적할 수 있습니다.
- 사용자는 시스템 시계를 되감아 앱이 프리미엄 기능을 제공하도록 속일 수 없습니다.
백엔드 서비스를 설정하는 방법에는 여러 가지가 있지만, 여기서는 Google 자체 Firebase를 사용하여 Cloud 함수와 Firestore를 사용해 설정합니다.
백엔드 작성은 이 Codelab의 범위에 해당하지 않으므로 시작 코드에는 이미 기본 구매를 처리하는 Firebase 프로젝트가 포함되어 있어 시작할 수 있습니다.
Firebase 플러그인도 시작 앱에 포함되어 있습니다.
이제 나만의 Firebase 프로젝트를 만들고 Firebase용 앱과 백엔드를 모두 구성한 후 백엔드를 배포하면 됩니다.
Firebase 프로젝트 만들기
Firebase Console로 이동하여 새 Firebase 프로젝트를 만듭니다. 이 예에서는 프로젝트 이름을 Dash Clicker로 지정합니다.
백엔드 앱에서는 구매를 특정 사용자에게 연결하므로 인증이 필요합니다. 이를 위해 Google 로그인을 사용하는 Firebase의 인증 모듈을 활용합니다.
- Firebase 대시보드에서 인증으로 이동하여 필요한 경우 사용 설정합니다.
- 로그인 방법 탭으로 이동하여 Google 로그인 제공업체를 사용 설정합니다.
Firebase의 Firestore 데이터베이스도 사용하므로 이 데이터베이스도 사용 설정합니다.
다음과 같이 Cloud Firestore 규칙을 설정합니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /purchases/{purchaseId} {
allow read: if request.auth != null && request.auth.uid == resource.data.userId
}
}
}
Flutter용 Firebase 설정
Flutter 앱에 Firebase를 설치하는 권장 방법은 FlutterFire CLI를 사용하는 것입니다. 설정 페이지에 설명된 안내를 따릅니다.
flutterfire configure를 실행할 때는 이전 단계에서 방금 만든 프로젝트를 선택합니다.
$ flutterfire configure
i Found 5 Firebase projects.
? Select a Firebase project to configure your Flutter application with ›
❯ in-app-purchases-1234 (in-app-purchases-1234)
other-flutter-codelab-1 (other-flutter-codelab-1)
other-flutter-codelab-2 (other-flutter-codelab-2)
other-flutter-codelab-3 (other-flutter-codelab-3)
other-flutter-codelab-4 (other-flutter-codelab-4)
<create a new project>
그런 다음 두 플랫폼을 선택하여 iOS 및 Android를 사용 설정합니다.
? Which platforms should your configuration support (use arrow keys & space to select)? ›
✔ android
✔ ios
macos
web
firebase_options.dart 재정의에 관한 메시지가 표시되면 예를 선택합니다.
? Generated FirebaseOptions file lib/firebase_options.dart already exists, do you want to override it? (y/n) › yes
Android용 Firebase 설정: 추가 단계
Firebase 대시보드에서 프로젝트 개요로 이동하여 설정을 선택하고 일반 탭을 선택합니다.
내 앱까지 아래로 스크롤하고 dashclicker (android) 앱을 선택합니다.
디버그 모드에서 Google 로그인을 허용하려면 디버그 인증서의 SHA-1 해시 지문을 제공해야 합니다.
디버그 서명 인증서 해시 가져오기
Flutter 앱 프로젝트의 루트에서 디렉터리를 android/
폴더로 변경한 다음 서명 보고서를 생성합니다.
cd android ./gradlew :app:signingReport
서명 키 목록이 대량으로 표시됩니다. 디버그 인증서의 해시를 찾으므로 Variant
및 Config
속성이 debug
로 설정된 인증서를 찾습니다. 키 저장소는 .android/debug.keystore
아래의 홈 폴더에 있을 가능성이 큽니다.
> Task :app:signingReport
Variant: debug
Config: debug
Store: /<USER_HOME_FOLDER>/.android/debug.keystore
Alias: AndroidDebugKey
MD5: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA1: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA-256: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
Valid until: Tuesday, January 19, 2038
SHA-1 해시를 복사하고 앱 제출 모달 대화상자의 마지막 입력란을 작성합니다.
iOS용 Firebase 설정: 추가 단계
Xcode
를 사용하여 ios/Runnder.xcworkspace
을 엽니다. 또는 원하는 IDE를 사용합니다.
VSCode에서 ios/
폴더를 마우스 오른쪽 버튼으로 클릭한 다음 open in xcode
를 클릭합니다.
Android 스튜디오에서 ios/
폴더를 마우스 오른쪽 버튼으로 클릭한 다음 flutter
를 클릭하고 open iOS module in Xcode
옵션을 클릭합니다.
iOS에서 Google 로그인을 허용하려면 빌드 plist
파일에 CFBundleURLTypes
구성 옵션을 추가합니다. 자세한 내용은 google_sign_in
패키지 문서를 참고하세요. 이 경우 파일은 ios/Runner/Info-Debug.plist
및 ios/Runner/Info-Release.plist
입니다.
키-값 쌍이 이미 추가되었지만 값을 대체해야 합니다.
<string>..</string>
요소로 둘러싸이지 않은REVERSED_CLIENT_ID
값을GoogleService-Info.plist
파일에서 가져옵니다.CFBundleURLTypes
키 아래의ios/Runner/Info-Debug.plist
및ios/Runner/Info-Release.plist
파일 모두에서 값을 바꿉니다.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- TODO Replace this value: -->
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
<string>com.googleusercontent.apps.REDACTED</string>
</array>
</dict>
</array>
이제 Firebase 설정이 완료되었습니다.
7. 구매 업데이트 리슨
이 Codelab의 이 부분에서는 제품을 구매할 수 있도록 앱을 준비합니다. 이 프로세스에는 앱이 시작된 후 구매 업데이트 및 오류를 리슨하는 것이 포함됩니다.
구매 업데이트 리슨
main.dart,
에서 두 페이지가 포함된 BottomNavigationBar
가 있는 Scaffold
가 있는 위젯 MyHomePage
를 찾습니다. 이 페이지에서는 DashCounter
, DashUpgrades,
, DashPurchases
의 세 가지 Provider
도 만듭니다. DashCounter
는 현재 대시 수를 추적하고 자동으로 증분합니다. DashUpgrades
는 Dashes로 구매할 수 있는 업그레이드를 관리합니다. 이 Codelab에서는 DashPurchases
에 중점을 둡니다.
기본적으로 제공업체의 객체는 해당 객체가 처음 요청될 때 정의됩니다. 이 객체는 앱이 시작될 때 구매 업데이트를 직접 리슨하므로 lazy: false
를 사용하여 이 객체에서 지연 로드를 사용 중지합니다.
lib/main.dart
ChangeNotifierProvider<DashPurchases>(
create: (context) => DashPurchases(
context.read<DashCounter>(),
),
lazy: false, // Add this line
),
InAppPurchaseConnection
인스턴스도 필요합니다. 하지만 앱을 테스트 가능한 상태로 유지하려면 연결을 모의 처리할 방법이 필요합니다. 이렇게 하려면 테스트에서 재정의할 수 있는 인스턴스 메서드를 만들고 main.dart
에 추가합니다.
lib/main.dart
// Gives the option to override in tests.
class IAPConnection {
static InAppPurchase? _instance;
static set instance(InAppPurchase value) {
_instance = value;
}
static InAppPurchase get instance {
_instance ??= InAppPurchase.instance;
return _instance!;
}
}
테스트가 계속 작동하도록 하려면 테스트를 약간 업데이트해야 합니다.
test/widget_test.dart
import 'package:dashclicker/main.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; // Add this import
import 'package:in_app_purchase_platform_interface/src/in_app_purchase_platform_addition.dart'; // And this import
void main() {
testWidgets('App starts', (tester) async {
IAPConnection.instance = TestIAPConnection(); // Add this line
await tester.pumpWidget(const MyApp());
expect(find.text('Tim Sneath'), findsOneWidget);
});
}
class TestIAPConnection implements InAppPurchase { // Add from here
@override
Future<bool> buyConsumable(
{required PurchaseParam purchaseParam, bool autoConsume = true}) {
return Future.value(false);
}
@override
Future<bool> buyNonConsumable({required PurchaseParam purchaseParam}) {
return Future.value(false);
}
@override
Future<void> completePurchase(PurchaseDetails purchase) {
return Future.value();
}
@override
Future<bool> isAvailable() {
return Future.value(false);
}
@override
Future<ProductDetailsResponse> queryProductDetails(Set<String> identifiers) {
return Future.value(ProductDetailsResponse(
productDetails: [],
notFoundIDs: [],
));
}
@override
T getPlatformAddition<T extends InAppPurchasePlatformAddition?>() {
// TODO: implement getPlatformAddition
throw UnimplementedError();
}
@override
Stream<List<PurchaseDetails>> get purchaseStream =>
Stream.value(<PurchaseDetails>[]);
@override
Future<void> restorePurchases({String? applicationUserName}) {
// TODO: implement restorePurchases
throw UnimplementedError();
}
@override
Future<String> countryCode() {
// TODO: implement countryCode
throw UnimplementedError();
}
} // To here.
lib/logic/dash_purchases.dart
에서 DashPurchases ChangeNotifier
코드로 이동합니다. 현재 구매한 대시보드에 추가할 수 있는 대시보드는 DashCounter
뿐입니다.
스트림 구독 속성 _subscription
(StreamSubscription<List<PurchaseDetails>> _subscription;
유형), IAPConnection.instance,
, 가져오기를 추가합니다. 결과 코드는 다음과 같습니다.
lib/logic/dash_purchases.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; // Add this import
import '../main.dart'; // And this import
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';
class DashPurchases extends ChangeNotifier {
DashCounter counter;
StoreState storeState = StoreState.available;
late StreamSubscription<List<PurchaseDetails>> _subscription; // Add this line
List<PurchasableProduct> products = [
PurchasableProduct(
'Spring is in the air',
'Many dashes flying out from their nests',
'\$0.99',
),
PurchasableProduct(
'Jet engine',
'Doubles you clicks per second for a day',
'\$1.99',
),
];
bool get beautifiedDash => false;
final iapConnection = IAPConnection.instance; // And this line
DashPurchases(this.counter);
Future<void> buy(PurchasableProduct product) async {
product.status = ProductStatus.pending;
notifyListeners();
await Future<void>.delayed(const Duration(seconds: 5));
product.status = ProductStatus.purchased;
notifyListeners();
await Future<void>.delayed(const Duration(seconds: 5));
product.status = ProductStatus.purchasable;
notifyListeners();
}
}
_subscription
가 생성자에서 초기화되므로 late
키워드가 _subscription
에 추가됩니다. 이 프로젝트는 기본적으로 null 허용 여부가 'false'로 설정되어 있습니다 (NNBD). 즉, null 허용 여부가 선언되지 않은 속성에는 null이 아닌 값이 있어야 합니다. late
한정자를 사용하면 이 값 정의가 지연될 수 있습니다.
생성자에서 purchaseUpdated
스트림을 가져와 스트림 수신 대기를 시작합니다. dispose()
메서드에서 스트림 구독을 취소합니다.
lib/logic/dash_purchases.dart
class DashPurchases extends ChangeNotifier {
DashCounter counter;
StoreState storeState = StoreState.notAvailable; // Modify this line
late StreamSubscription<List<PurchaseDetails>> _subscription;
List<PurchasableProduct> products = [
PurchasableProduct(
'Spring is in the air',
'Many dashes flying out from their nests',
'\$0.99',
),
PurchasableProduct(
'Jet engine',
'Doubles you clicks per second for a day',
'\$1.99',
),
];
bool get beautifiedDash => false;
final iapConnection = IAPConnection.instance;
DashPurchases(this.counter) { // Add from here
final purchaseUpdated = iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
} // To here.
Future<void> buy(PurchasableProduct product) async {
product.status = ProductStatus.pending;
notifyListeners();
await Future<void>.delayed(const Duration(seconds: 5));
product.status = ProductStatus.purchased;
notifyListeners();
await Future<void>.delayed(const Duration(seconds: 5));
product.status = ProductStatus.purchasable;
notifyListeners();
}
// Add from here
void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
// Handle purchases here
}
void _updateStreamOnDone() {
_subscription.cancel();
}
void _updateStreamOnError(dynamic error) {
//Handle error here
} // To here.
}
이제 앱이 구매 업데이트를 수신하므로 다음 섹션에서 구매를 진행합니다.
계속하기 전에 'flutter test"
'로 테스트를 실행하여 모든 것이 올바르게 설정되었는지 확인합니다.
$ flutter test
00:01 +1: All tests passed!
8. 구매
이 Codelab의 이 부분에서는 현재 존재하는 모의 제품을 구매 가능한 실제 제품으로 대체합니다. 이러한 제품은 스토어에서 로드되고 목록에 표시되며 제품을 탭하면 구매됩니다.
PurchasableProduct 조정
PurchasableProduct
: 모의 제품을 표시합니다. purchasable_product.dart
의 PurchasableProduct
클래스를 다음 코드로 바꿔 실제 콘텐츠를 표시하도록 업데이트합니다.
lib/model/purchasable_product.dart
import 'package:in_app_purchase/in_app_purchase.dart';
enum ProductStatus {
purchasable,
purchased,
pending,
}
class PurchasableProduct {
String get id => productDetails.id;
String get title => productDetails.title;
String get description => productDetails.description;
String get price => productDetails.price;
ProductStatus status;
ProductDetails productDetails;
PurchasableProduct(this.productDetails) : status = ProductStatus.purchasable;
}
dash_purchases.dart,
에서 더미 구매를 삭제하고 빈 목록 List<PurchasableProduct> products = [];
으로 바꿉니다.
구매 가능한 항목 로드
사용자에게 구매 기능을 제공하려면 스토어에서 구매 항목을 로드합니다. 먼저 스토어를 사용할 수 있는지 확인합니다. 스토어를 사용할 수 없는 경우 storeState
를 notAvailable
로 설정하면 사용자에게 오류 메시지가 표시됩니다.
lib/logic/dash_purchases.dart
Future<void> loadPurchases() async {
final available = await iapConnection.isAvailable();
if (!available) {
storeState = StoreState.notAvailable;
notifyListeners();
return;
}
}
스토어를 사용할 수 있게 되면 구매할 수 있는 항목을 로드합니다. 이전 Firebase 설정에 따라 storeKeyConsumable
, storeKeySubscription,
, storeKeyUpgrade
가 표시됩니다. 예상 구매 항목을 사용할 수 없는 경우 이 정보를 콘솔에 출력합니다. 이 정보를 백엔드 서비스로 전송하는 것도 좋습니다.
await iapConnection.queryProductDetails(ids)
메서드는 찾을 수 없는 ID와 구매 가능한 제품을 모두 반환합니다. 응답의 productDetails
를 사용하여 UI를 업데이트하고 StoreState
를 available
로 설정합니다.
lib/logic/dash_purchases.dart
import '../constants.dart';
Future<void> loadPurchases() async {
final available = await iapConnection.isAvailable();
if (!available) {
storeState = StoreState.notAvailable;
notifyListeners();
return;
}
const ids = <String>{
storeKeyConsumable,
storeKeySubscription,
storeKeyUpgrade,
};
final response = await iapConnection.queryProductDetails(ids);
products = response.productDetails.map((e) => PurchasableProduct(e)).toList();
storeState = StoreState.available;
notifyListeners();
}
생성자에서 loadPurchases()
함수를 호출합니다.
lib/logic/dash_purchases.dart
DashPurchases(this.counter) {
final purchaseUpdated = iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
loadPurchases();
}
마지막으로 storeState
필드의 값을 StoreState.available
에서 StoreState.loading:
로 변경합니다.
lib/logic/dash_purchases.dart
StoreState storeState = StoreState.loading;
구매 가능한 제품 표시
purchase_page.dart
파일을 예로 들어 보겠습니다. PurchasePage
위젯은 StoreState
에 따라 _PurchasesLoading
, _PurchaseList,
또는 _PurchasesNotAvailable,
을 표시합니다. 또한 위젯에는 다음 단계에서 사용되는 사용자의 이전 구매 내역이 표시됩니다.
_PurchaseList
위젯은 구매 가능한 제품 목록을 표시하고 DashPurchases
객체에 구매 요청을 전송합니다.
lib/pages/purchase_page.dart
class _PurchaseList extends StatelessWidget {
@override
Widget build(BuildContext context) {
var purchases = context.watch<DashPurchases>();
var products = purchases.products;
return Column(
children: products
.map((product) => _PurchaseWidget(
product: product,
onPressed: () {
purchases.buy(product);
}))
.toList(),
);
}
}
올바르게 구성된 경우 Android 및 iOS 스토어에서 사용 가능한 제품을 볼 수 있습니다. 각 콘솔에 구매 항목을 입력하면 구매 항목을 사용할 수 있기까지 다소 시간이 걸릴 수 있습니다.
dash_purchases.dart
로 돌아가서 제품을 구매하는 함수를 구현합니다. 소모품과 비소모품을 구분하기만 하면 됩니다. 업그레이드 및 정기 결제 제품은 소모품이 아닙니다.
lib/logic/dash_purchases.dart
Future<void> buy(PurchasableProduct product) async {
final purchaseParam = PurchaseParam(productDetails: product.productDetails);
switch (product.id) {
case storeKeyConsumable:
await iapConnection.buyConsumable(purchaseParam: purchaseParam);
case storeKeySubscription:
case storeKeyUpgrade:
await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
default:
throw ArgumentError.value(
product.productDetails, '${product.id} is not a known product');
}
}
계속하기 전에 _beautifiedDashUpgrade
변수를 만들고 이를 참조하도록 beautifiedDash
getter를 업데이트합니다.
lib/logic/dash_purchases.dart
bool get beautifiedDash => _beautifiedDashUpgrade;
bool _beautifiedDashUpgrade = false;
_onPurchaseUpdate
메서드는 구매 업데이트를 수신하고 구매 페이지에 표시되는 제품의 상태를 업데이트한 후 구매를 카운터 로직에 적용합니다. 구매를 처리한 후 completePurchase
를 호출하여 스토어에 구매가 올바르게 처리되었음을 알리는 것이 중요합니다.
lib/logic/dash_purchases.dart
Future<void> _onPurchaseUpdate(
List<PurchaseDetails> purchaseDetailsList) async {
for (var purchaseDetails in purchaseDetailsList) {
await _handlePurchase(purchaseDetails);
}
notifyListeners();
}
Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
if (purchaseDetails.status == PurchaseStatus.purchased) {
switch (purchaseDetails.productID) {
case storeKeySubscription:
counter.applyPaidMultiplier();
case storeKeyConsumable:
counter.addBoughtDashes(2000);
case storeKeyUpgrade:
_beautifiedDashUpgrade = true;
}
}
if (purchaseDetails.pendingCompletePurchase) {
await iapConnection.completePurchase(purchaseDetails);
}
}
9. 백엔드 설정
구매 추적 및 확인으로 이동하기 전에 이를 지원하는 Dart 백엔드를 설정하세요.
이 섹션에서는 dart-backend/
폴더를 루트로 하여 작업합니다.
다음 도구가 설치되어 있는지 확인합니다.
- Dart
- Firebase CLI
기본 프로젝트 개요
이 프로젝트의 일부는 이 Codelab의 범위에 해당하지 않으므로 시작 코드에 포함되어 있습니다. 시작하기 전에 시작 코드에 이미 있는 내용을 검토하여 구조를 어떻게 구성할지 파악하는 것이 좋습니다.
이 백엔드 코드는 컴퓨터에서 로컬로 실행할 수 있으며, 이를 사용하기 위해 배포할 필요가 없습니다. 하지만 개발 기기 (Android 또는 iPhone)에서 서버가 실행될 머신에 연결할 수 있어야 합니다. 그러려면 두 기기가 동일한 네트워크에 있어야 하며 컴퓨터의 IP 주소를 알아야 합니다.
다음 명령어를 사용하여 서버를 실행해 봅니다.
$ dart ./bin/server.dart
Serving at http://0.0.0.0:8080
Dart 백엔드는 shelf
및 shelf_router
를 사용하여 API 엔드포인트를 제공합니다. 기본적으로 서버는 경로를 제공하지 않습니다. 나중에 구매 인증 절차를 처리할 경로를 만듭니다.
시작 코드에 이미 포함된 부분 중 하나는 lib/iap_repository.dart
의 IapRepository
입니다. Firestore 또는 일반적인 데이터베이스와 상호작용하는 방법을 배우는 것은 이 Codelab과 관련이 없으므로 시작 코드에는 Firestore에서 구매 항목을 만들거나 업데이트하는 함수와 이러한 구매 항목의 모든 클래스가 포함되어 있습니다.
Firebase 액세스 설정
Firebase Firestore에 액세스하려면 서비스 계정 액세스 키가 필요합니다. Firebase 프로젝트 설정을 열고 서비스 계정 섹션으로 이동한 다음 새 비공개 키 생성을 선택하여 비공개 키를 생성합니다.
다운로드한 JSON 파일을 assets/
폴더에 복사하고 이름을 service-account-firebase.json
으로 바꿉니다.
Google Play 액세스 설정하기
Play 스토어에 액세스하여 구매를 확인하려면 이러한 권한이 있는 서비스 계정을 생성하고 JSON 사용자 인증 정보를 다운로드해야 합니다.
- Google Play Console로 이동하여 모든 앱 페이지에서 시작합니다.
- 설정 > API 액세스로 이동합니다.
Google Play Console에서 기존 프로젝트를 만들거나 연결하도록 요청하는 경우 먼저 프로젝트를 만든 다음 이 페이지로 돌아오세요.
- 서비스 계정을 정의할 수 있는 섹션을 찾아 새 서비스 계정 만들기를 클릭합니다.
- 팝업 대화상자에서 Google Cloud Platform 링크를 클릭합니다.
- 프로젝트를 선택합니다. 표시되지 않으면 오른쪽 상단의 계정 드롭다운 목록에서 올바른 Google 계정에 로그인했는지 확인하세요.
- 프로젝트를 선택한 후 상단 메뉴 바에서 + 서비스 계정 만들기를 클릭합니다.
- 서비스 계정의 이름을 지정하고 필요한 경우 용도를 기억할 수 있도록 설명을 제공한 다음 다음 단계로 이동합니다.
- 서비스 계정에 편집자 역할을 할당합니다.
- 마법사를 완료하고 개발자 콘솔 내의 API 액세스 페이지로 돌아가 서비스 계정 새로고침을 클릭합니다. 목록에 새로 만든 계정이 표시됩니다.
- 새 서비스 계정의 액세스 권한 부여를 클릭합니다.
- 다음 페이지에서 재무 데이터 블록까지 아래로 스크롤합니다. 재무 데이터, 주문, 취소 설문조사 응답 보기 및 주문 및 정기 결제 관리를 모두 선택합니다.
- 사용자 초대를 클릭합니다.
- 이제 계정이 설정되었으므로 사용자 인증 정보를 생성하기만 하면 됩니다. Cloud 콘솔로 돌아가 서비스 계정 목록에서 서비스 계정을 찾은 다음 세 개의 점 아이콘을 클릭하고 키 관리를 선택합니다.
- 새 JSON 키를 만들고 다운로드합니다.
- 다운로드한 파일의 이름을
service-account-google-play.json,
로 바꾸고assets/
디렉터리로 이동합니다.
lib/constants.dart,
를 열고 androidPackageId
값을 Android 앱에 선택한 패키지 ID로 바꾸는 작업도 하나 더 남아 있습니다.
Apple App Store 액세스 설정하기
구매를 확인하기 위해 앱 스토어에 액세스하려면 공유 비밀을 설정해야 합니다.
- App Store Connect를 엽니다.
- 내 앱으로 이동하여 앱을 선택합니다.
- 사이드바 탐색에서 인앱 구매 > 관리로 이동합니다.
- 목록의 오른쪽 상단에서 앱별 공유 비밀을 클릭합니다.
- 새 보안 비밀을 생성하고 복사합니다.
lib/constants.dart,
를 열고appStoreSharedSecret
값을 방금 생성한 공유 보안 비밀로 바꿉니다.
상수 구성 파일
계속하기 전에 lib/constants.dart
파일에 다음 상수가 구성되어 있는지 확인합니다.
androidPackageId
: Android에서 사용되는 패키지 ID입니다(예:com.example.dashclicker
).appStoreSharedSecret
: App Store Connect에 액세스하여 구매 확인을 실행하기 위한 공유 비밀입니다.bundleId
: iOS에서 사용되는 번들 ID입니다(예:com.example.dashclicker
).
나머지 상수는 당분간 무시해도 됩니다.
10. 구매 확인
구매 확인을 위한 일반적인 흐름은 iOS와 Android에서 비슷합니다.
두 스토어 모두 구매 시 애플리케이션이 토큰을 수신합니다.
이 토큰은 앱에서 백엔드 서비스로 전송되며, 백엔드 서비스는 제공된 토큰을 사용하여 각 스토어의 서버에서 구매를 확인합니다.
그러면 백엔드 서비스는 구매 항목을 저장하고 애플리케이션에 구매가 유효한지 여부를 응답할 수 있습니다.
백엔드 서비스가 사용자 기기에서 실행되는 애플리케이션이 아닌 스토어에서 유효성 검사를 실행하도록 하면 사용자가 시스템 시계를 되감아 프리미엄 기능에 액세스하는 것을 방지할 수 있습니다.
Flutter 측 설정
인증을 설정합니다.
구매 내역을 백엔드 서비스로 전송할 예정이므로 구매하는 동안 사용자가 인증되었는지 확인해야 합니다. 대부분의 인증 로직은 이미 시작 프로젝트에 추가되어 있으므로 사용자가 아직 로그인하지 않은 경우 PurchasePage
에 로그인 버튼이 표시되는지 확인하기만 하면 됩니다. PurchasePage
의 빌드 메서드 시작 부분에 다음 코드를 추가합니다.
lib/pages/purchase_page.dart
import '../logic/firebase_notifier.dart';
import '../model/firebase_state.dart';
import 'login_page.dart';
class PurchasePage extends StatelessWidget {
const PurchasePage({super.key});
@override
Widget build(BuildContext context) {
var firebaseNotifier = context.watch<FirebaseNotifier>();
if (firebaseNotifier.state == FirebaseState.loading) {
return _PurchasesLoading();
} else if (firebaseNotifier.state == FirebaseState.notAvailable) {
return _PurchasesNotAvailable();
}
if (!firebaseNotifier.loggedIn) {
return const LoginPage();
}
// omitted
앱에서 확인 엔드포인트 호출
앱에서 http post 호출을 사용하여 Dart 백엔드에서 /verifypurchase
엔드포인트를 호출하는 _verifyPurchase(PurchaseDetails purchaseDetails)
함수를 만듭니다.
선택한 스토어 (Play 스토어의 경우 google_play
, App Store의 경우 app_store
), serverVerificationData
, productID
를 전송합니다. 서버는 구매가 확인되었는지 여부를 나타내는 상태 코드를 반환합니다.
앱 상수에서 서버 IP를 로컬 머신 IP 주소로 구성합니다.
lib/logic/dash_purchases.dart
FirebaseNotifier firebaseNotifier;
DashPurchases(this.counter, this.firebaseNotifier) {
// omitted
}
main.dart:
에서 DashPurchases
를 만들 때 firebaseNotifier
를 추가합니다.
lib/main.dart
ChangeNotifierProvider<DashPurchases>(
create: (context) => DashPurchases(
context.read<DashCounter>(),
context.read<FirebaseNotifier>(),
),
lazy: false,
),
사용자 ID를 구매 확인 함수에 전달할 수 있도록 FirebaseNotifier에 사용자의 getter를 추가합니다.
lib/logic/firebase_notifier.dart
User? get user => FirebaseAuth.instance.currentUser;
_verifyPurchase
함수를 DashPurchases
클래스에 추가합니다. 이 async
함수는 구매가 확인되었는지 여부를 나타내는 불리언을 반환합니다.
lib/logic/dash_purchases.dart
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
final url = Uri.parse('http://$serverIp:8080/verifypurchase');
const headers = {
'Content-type': 'application/json',
'Accept': 'application/json',
};
final response = await http.post(
url,
body: jsonEncode({
'source': purchaseDetails.verificationData.source,
'productId': purchaseDetails.productID,
'verificationData':
purchaseDetails.verificationData.serverVerificationData,
'userId': firebaseNotifier.user?.uid,
}),
headers: headers,
);
if (response.statusCode == 200) {
return true;
} else {
return false;
}
}
구매를 적용하기 직전에 _handlePurchase
에서 _verifyPurchase
함수를 호출합니다. 구매가 확인된 경우에만 구매를 적용해야 합니다. 프로덕션 앱에서는 이를 추가로 지정하여 예를 들어 스토어를 일시적으로 사용할 수 없는 경우 무료 체험판 구독을 적용할 수 있습니다. 하지만 이 예에서는 간단하게 유지하고 구매가 확인된 경우에만 구매를 적용합니다.
lib/logic/dash_purchases.dart
Future<void> _onPurchaseUpdate(
List<PurchaseDetails> purchaseDetailsList) async {
for (var purchaseDetails in purchaseDetailsList) {
await _handlePurchase(purchaseDetails);
}
notifyListeners();
}
Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
if (purchaseDetails.status == PurchaseStatus.purchased) {
// Send to server
var validPurchase = await _verifyPurchase(purchaseDetails);
if (validPurchase) {
// Apply changes locally
switch (purchaseDetails.productID) {
case storeKeySubscription:
counter.applyPaidMultiplier();
case storeKeyConsumable:
counter.addBoughtDashes(1000);
}
}
}
if (purchaseDetails.pendingCompletePurchase) {
await iapConnection.completePurchase(purchaseDetails);
}
}
이제 앱에서 구매를 확인할 준비가 되었습니다.
백엔드 서비스 설정
그런 다음 백엔드에서 구매를 확인하기 위한 Cloud 함수를 설정합니다.
구매 핸들러 빌드
두 매장의 인증 흐름이 거의 동일하므로 각 매장에 대해 별도의 구현이 포함된 추상 PurchaseHandler
클래스를 설정합니다.
먼저 lib/
폴더에 purchase_handler.dart
파일을 추가합니다. 여기에서 구독과 비구독이라는 두 가지 종류의 구매를 확인하는 두 가지 추상 메서드가 있는 추상 PurchaseHandler
클래스를 정의합니다.
lib/purchase_handler.dart
import 'products.dart';
/// Generic purchase handler,
/// must be implemented for Google Play and Apple Store
abstract class PurchaseHandler {
/// Verify if non-subscription purchase (aka consumable) is valid
/// and update the database
Future<bool> handleNonSubscription({
required String userId,
required ProductData productData,
required String token,
});
/// Verify if subscription purchase (aka non-consumable) is valid
/// and update the database
Future<bool> handleSubscription({
required String userId,
required ProductData productData,
required String token,
});
}
보시다시피 각 메서드에는 세 개의 매개변수가 필요합니다.
userId:
로그인한 사용자의 ID로, 구매를 사용자에게 연결할 수 있습니다.productData:
제품에 대한 데이터입니다. 잠시 후 이를 정의할 것입니다.token:
스토어에서 사용자에게 제공한 토큰입니다.
또한 이러한 구매 핸들러를 더 쉽게 사용할 수 있도록 정기 결제 및 비정기 결제 모두에 사용할 수 있는 verifyPurchase()
메서드를 추가합니다.
lib/purchase_handler.dart
/// Verify if purchase is valid and update the database
Future<bool> verifyPurchase({
required String userId,
required ProductData productData,
required String token,
}) async {
switch (productData.type) {
case ProductType.subscription:
return handleSubscription(
userId: userId,
productData: productData,
token: token,
);
case ProductType.nonSubscription:
return handleNonSubscription(
userId: userId,
productData: productData,
token: token,
);
}
}
이제 두 경우 모두 verifyPurchase
를 호출할 수 있지만 구현은 별도로 유지할 수 있습니다.
ProductData
클래스에는 구매 가능한 다양한 제품에 관한 기본 정보(제품 ID(SKU라고도 함) 및 ProductType
포함)가 포함되어 있습니다.
lib/products.dart
class ProductData {
final String productId;
final ProductType type;
const ProductData(this.productId, this.type);
}
ProductType
은 정기 결제 또는 비정기 결제일 수 있습니다.
lib/products.dart
enum ProductType {
subscription,
nonSubscription,
}
마지막으로 제품 목록은 동일한 파일에서 맵으로 정의됩니다.
lib/products.dart
const productDataMap = {
'dash_consumable_2k': ProductData(
'dash_consumable_2k',
ProductType.nonSubscription,
),
'dash_upgrade_3d': ProductData(
'dash_upgrade_3d',
ProductType.nonSubscription,
),
'dash_subscription_doubler': ProductData(
'dash_subscription_doubler',
ProductType.subscription,
),
};
그런 다음 Google Play 스토어 및 Apple App Store의 자리표시자 구현을 정의합니다. Google Play에서 시작해 보세요.
lib/google_play_purchase_handler.dart
를 만들고 방금 작성한 PurchaseHandler
를 확장하는 클래스를 추가합니다.
lib/google_play_purchase_handler.dart
import 'dart:async';
import 'package:googleapis/androidpublisher/v3.dart' as ap;
import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';
class GooglePlayPurchaseHandler extends PurchaseHandler {
final ap.AndroidPublisherApi androidPublisher;
final IapRepository iapRepository;
GooglePlayPurchaseHandler(
this.androidPublisher,
this.iapRepository,
);
@override
Future<bool> handleNonSubscription({
required String? userId,
required ProductData productData,
required String token,
}) async {
return true;
}
@override
Future<bool> handleSubscription({
required String? userId,
required ProductData productData,
required String token,
}) async {
return true;
}
}
지금은 핸들러 메서드에 true
를 반환합니다. 나중에 살펴보겠습니다.
알 수 있듯이 생성자는 IapRepository
의 인스턴스를 사용합니다. 구매 핸들러는 이 인스턴스를 사용하여 나중에 Firestore에 구매 정보를 저장합니다. Google Play와 통신하려면 제공된 AndroidPublisherApi
를 사용합니다.
그런 다음 앱 스토어 핸들러에도 동일한 작업을 실행합니다. lib/app_store_purchase_handler.dart
를 만들고 PurchaseHandler
를 다시 확장하는 클래스를 추가합니다.
lib/app_store_purchase_handler.dart
import 'dart:async';
import 'package:app_store_server_sdk/app_store_server_sdk.dart';
import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';
class AppStorePurchaseHandler extends PurchaseHandler {
final IapRepository iapRepository;
AppStorePurchaseHandler(
this.iapRepository,
);
@override
Future<bool> handleNonSubscription({
required String userId,
required ProductData productData,
required String token,
}) {
return true;
}
@override
Future<bool> handleSubscription({
required String userId,
required ProductData productData,
required String token,
}) {
return true;
}
}
좋습니다. 이제 구매 핸들러가 2개가 됩니다. 다음으로 Purchase Verification API 엔드포인트를 만들어 보겠습니다.
구매 핸들러 사용
bin/server.dart
를 열고 shelf_route
를 사용하여 API 엔드포인트를 만듭니다.
bin/server.dart
Future<void> main() async {
final router = Router();
final purchaseHandlers = await _createPurchaseHandlers();
router.post('/verifypurchase', (Request request) async {
final dynamic payload = json.decode(await request.readAsString());
final (:userId, :source, :productData, :token) = getPurchaseData(payload);
final result = await purchaseHandlers[source]!.verifyPurchase(
userId: userId,
productData: productData,
token: token,
);
if (result) {
return Response.ok('all good!');
} else {
return Response.internalServerError();
}
});
await serveHandler(router.call);
}
({
String userId,
String source,
ProductData productData,
String token,
}) getPurchaseData(dynamic payload) {
if (payload
case {
'userId': String userId,
'source': String source,
'productId': String productId,
'verificationData': String token,
}) {
return (
userId: userId,
source: source,
productData: productDataMap[productId]!,
token: token,
);
} else {
throw const FormatException('Unexpected JSON');
}
}
위의 코드는 다음을 실행합니다.
- 이전에 만든 앱에서 호출할 POST 엔드포인트를 정의합니다.
- JSON 페이로드를 디코딩하고 다음 정보를 추출합니다.
userId
: 현재 로그인한 사용자 IDsource
: 사용된 스토어입니다(app_store
또는google_play
).productData
: 이전에 만든productDataMap
에서 가져옵니다.token
: 스토어로 전송할 인증 데이터를 포함합니다.- 소스에 따라
GooglePlayPurchaseHandler
또는AppStorePurchaseHandler
의verifyPurchase
메서드를 호출합니다. - 인증에 성공하면 메서드는 클라이언트에
Response.ok
를 반환합니다. - 인증에 실패하면 메서드는 클라이언트에
Response.internalServerError
를 반환합니다.
API 엔드포인트를 만든 후에는 두 가지 구매 핸들러를 구성해야 합니다. 이렇게 하려면 이전 단계에서 가져온 서비스 계정 키를 로드하고 Android 게시자 API 및 Firebase Firestore API를 비롯한 다양한 서비스에 대한 액세스를 구성해야 합니다. 그런 다음 서로 다른 종속 항목으로 두 개의 구매 핸들러를 만듭니다.
bin/server.dart
Future<Map<String, PurchaseHandler>> _createPurchaseHandlers() async {
// Configure Android Publisher API access
final serviceAccountGooglePlay =
File('assets/service-account-google-play.json').readAsStringSync();
final clientCredentialsGooglePlay =
auth.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay);
final clientGooglePlay =
await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
ap.AndroidPublisherApi.androidpublisherScope,
]);
final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay);
// Configure Firestore API access
final serviceAccountFirebase =
File('assets/service-account-firebase.json').readAsStringSync();
final clientCredentialsFirebase =
auth.ServiceAccountCredentials.fromJson(serviceAccountFirebase);
final clientFirebase =
await auth.clientViaServiceAccount(clientCredentialsFirebase, [
fs.FirestoreApi.cloudPlatformScope,
]);
final firestoreApi = fs.FirestoreApi(clientFirebase);
final dynamic json = jsonDecode(serviceAccountFirebase);
final projectId = json['project_id'] as String;
final iapRepository = IapRepository(firestoreApi, projectId);
return {
'google_play': GooglePlayPurchaseHandler(
androidPublisher,
iapRepository,
),
'app_store': AppStorePurchaseHandler(
iapRepository,
),
};
}
Android 구매 확인: 구매 핸들러 구현
그런 다음 Google Play 구매 핸들러를 계속 구현합니다.
Google은 이미 구매를 확인하는 데 필요한 API와 상호작용하는 Dart 패키지를 제공하고 있습니다. server.dart
파일에서 초기화한 후 이제 GooglePlayPurchaseHandler
클래스에서 사용합니다.
정기 결제 유형이 아닌 구매의 핸들러를 구현합니다.
lib/google_play_purchase_handler.dart
@override
Future<bool> handleNonSubscription({
required String? userId,
required ProductData productData,
required String token,
}) async {
print(
'GooglePlayPurchaseHandler.handleNonSubscription'
'($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
);
try {
// Verify purchase with Google
final response = await androidPublisher.purchases.products.get(
androidPackageId,
productData.productId,
token,
);
print('Purchases response: ${response.toJson()}');
// Make sure an order id exists
if (response.orderId == null) {
print('Could not handle purchase without order id');
return false;
}
final orderId = response.orderId!;
final purchaseData = NonSubscriptionPurchase(
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(response.purchaseTimeMillis ?? '0'),
),
orderId: orderId,
productId: productData.productId,
status: _nonSubscriptionStatusFrom(response.purchaseState),
userId: userId,
iapSource: IAPSource.googleplay,
);
// Update the database
if (userId != null) {
// If we know the userId,
// update the existing purchase or create it if it does not exist.
await iapRepository.createOrUpdatePurchase(purchaseData);
} else {
// If we do not know the user id, a previous entry must already
// exist, and thus we'll only update it.
await iapRepository.updatePurchase(purchaseData);
}
return true;
} on ap.DetailedApiRequestError catch (e) {
print(
'Error on handle NonSubscription: $e\n'
'JSON: ${e.jsonResponse}',
);
} catch (e) {
print('Error on handle NonSubscription: $e\n');
}
return false;
}
구독 구매 핸들러도 비슷한 방식으로 업데이트할 수 있습니다.
lib/google_play_purchase_handler.dart
/// Handle subscription purchases.
///
/// Retrieves the purchase status from Google Play and updates
/// the Firestore Database accordingly.
@override
Future<bool> handleSubscription({
required String? userId,
required ProductData productData,
required String token,
}) async {
print(
'GooglePlayPurchaseHandler.handleSubscription'
'($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
);
try {
// Verify purchase with Google
final response = await androidPublisher.purchases.subscriptions.get(
androidPackageId,
productData.productId,
token,
);
print('Subscription response: ${response.toJson()}');
// Make sure an order id exists
if (response.orderId == null) {
print('Could not handle purchase without order id');
return false;
}
final orderId = extractOrderId(response.orderId!);
final purchaseData = SubscriptionPurchase(
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(response.startTimeMillis ?? '0'),
),
orderId: orderId,
productId: productData.productId,
status: _subscriptionStatusFrom(response.paymentState),
userId: userId,
iapSource: IAPSource.googleplay,
expiryDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(response.expiryTimeMillis ?? '0'),
),
);
// Update the database
if (userId != null) {
// If we know the userId,
// update the existing purchase or create it if it does not exist.
await iapRepository.createOrUpdatePurchase(purchaseData);
} else {
// If we do not know the user id, a previous entry must already
// exist, and thus we'll only update it.
await iapRepository.updatePurchase(purchaseData);
}
return true;
} on ap.DetailedApiRequestError catch (e) {
print(
'Error on handle Subscription: $e\n'
'JSON: ${e.jsonResponse}',
);
} catch (e) {
print('Error on handle Subscription: $e\n');
}
return false;
}
}
주문 ID 파싱을 용이하게 하는 다음 메서드와 구매 상태를 파싱하는 두 가지 메서드를 추가합니다.
lib/google_play_purchase_handler.dart
NonSubscriptionStatus _nonSubscriptionStatusFrom(int? state) {
return switch (state) {
0 => NonSubscriptionStatus.completed,
2 => NonSubscriptionStatus.pending,
_ => NonSubscriptionStatus.cancelled,
};
}
SubscriptionStatus _subscriptionStatusFrom(int? state) {
return switch (state) {
// Payment pending
0 => SubscriptionStatus.pending,
// Payment received
1 => SubscriptionStatus.active,
// Free trial
2 => SubscriptionStatus.active,
// Pending deferred upgrade/downgrade
3 => SubscriptionStatus.pending,
// Expired or cancelled
_ => SubscriptionStatus.expired,
};
}
/// If a subscription suffix is present (..#) extract the orderId.
String extractOrderId(String orderId) {
final orderIdSplit = orderId.split('..');
if (orderIdSplit.isNotEmpty) {
orderId = orderIdSplit[0];
}
return orderId;
}
이제 Google Play 구매가 확인되고 데이터베이스에 저장됩니다.
다음으로 iOS의 App Store 구매로 이동합니다.
iOS 구매 확인: 구매 핸들러 구현
App Store에서 구매를 확인하는 경우 이 과정을 더 쉽게 만들어주는 서드 파티 Dart 패키지인 app_store_server_sdk
가 있습니다.
먼저 ITunesApi
인스턴스를 만듭니다. 샌드박스 구성을 사용하고 로깅을 사용 설정하여 오류 디버깅을 용이하게 합니다.
lib/app_store_purchase_handler.dart
final _iTunesAPI = ITunesApi(
ITunesHttpClient(
ITunesEnvironment.sandbox(),
loggingEnabled: true,
),
);
이제 Google Play API와 달리 App Store에서는 정기 결제와 비정기 결제 모두에 동일한 API 엔드포인트를 사용합니다. 즉, 두 핸들러에 동일한 로직을 사용할 수 있습니다. 동일한 구현을 호출하도록 두 개의 클래스를 병합합니다.
lib/app_store_purchase_handler.dart
@override
Future<bool> handleNonSubscription({
required String userId,
required ProductData productData,
required String token,
}) {
return handleValidation(userId: userId, token: token);
}
@override
Future<bool> handleSubscription({
required String userId,
required ProductData productData,
required String token,
}) {
return handleValidation(userId: userId, token: token);
}
/// Handle purchase validation.
Future<bool> handleValidation({
required String userId,
required String token,
}) async {
//..
}
이제 handleValidation
를 구현합니다.
lib/app_store_purchase_handler.dart
/// Handle purchase validation.
Future<bool> handleValidation({
required String userId,
required String token,
}) async {
print('AppStorePurchaseHandler.handleValidation');
final response = await _iTunesAPI.verifyReceipt(
password: appStoreSharedSecret,
receiptData: token,
);
print('response: $response');
if (response.status == 0) {
final receipts = response.latestReceiptInfo ?? [];
for (final receipt in receipts) {
final product = productDataMap[receipt.productId];
if (product == null) {
print('Error: Unknown product: ${receipt.productId}');
continue;
}
switch (product.type) {
case ProductType.nonSubscription:
await iapRepository.createOrUpdatePurchase(NonSubscriptionPurchase(
userId: userId,
productId: receipt.productId ?? '',
iapSource: IAPSource.appstore,
orderId: receipt.originalTransactionId ?? '',
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(receipt.originalPurchaseDateMs ?? '0')),
type: product.type,
status: NonSubscriptionStatus.completed,
));
break;
case ProductType.subscription:
await iapRepository.createOrUpdatePurchase(SubscriptionPurchase(
userId: userId,
productId: receipt.productId ?? '',
iapSource: IAPSource.appstore,
orderId: receipt.originalTransactionId ?? '',
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(receipt.originalPurchaseDateMs ?? '0')),
type: product.type,
expiryDate: DateTime.fromMillisecondsSinceEpoch(
int.parse(receipt.expiresDateMs ?? '0')),
status: SubscriptionStatus.active,
));
break;
}
}
return true;
} else {
print('Error: Status: ${response.status}');
return false;
}
}
이제 App Store 구매가 인증되고 데이터베이스에 저장됩니다.
백엔드 실행
이제 dart bin/server.dart
를 실행하여 /verifypurchase
엔드포인트를 제공할 수 있습니다.
$ dart bin/server.dart
Serving at http://0.0.0.0:8080
11. 구매 내역 확인하기
사용자의 구매를 추적하는 권장 방법은 백엔드 서비스에서 추적하는 것입니다. 백엔드가 스토어의 이벤트에 응답할 수 있으므로 캐싱으로 인해 오래된 정보가 표시될 가능성이 줄어들고 조작될 가능성도 줄어듭니다.
먼저 빌드한 Dart 백엔드를 사용하여 백엔드에서 스토어 이벤트 처리를 설정합니다.
백엔드에서 매장 이벤트 처리
스토어는 구독이 갱신될 때와 같이 발생하는 모든 결제 이벤트를 백엔드에 알릴 수 있습니다. 백엔드에서 이러한 이벤트를 처리하여 데이터베이스의 구매를 최신 상태로 유지할 수 있습니다. 이 섹션에서는 Google Play 스토어와 Apple App Store 모두에 대해 설정합니다.
Google Play 결제 이벤트 처리
Google Play는 Cloud Pub/Sub 주제를 통해 결제 이벤트를 제공합니다. 이는 기본적으로 메시지를 게시하고 소비할 수 있는 메시지 대기열입니다.
이 기능은 Google Play에만 해당하는 기능이므로 GooglePlayPurchaseHandler
에 이 기능을 포함합니다.
먼저 lib/google_play_purchase_handler.dart
를 열고 PubsubApi 가져오기를 추가합니다.
lib/google_play_purchase_handler.dart
import 'package:googleapis/pubsub/v1.dart' as pubsub;
그런 다음 PubsubApi
를 GooglePlayPurchaseHandler
에 전달하고 클래스 생성자를 수정하여 다음과 같이 Timer
를 만듭니다.
lib/google_play_purchase_handler.dart
class GooglePlayPurchaseHandler extends PurchaseHandler {
final ap.AndroidPublisherApi androidPublisher;
final IapRepository iapRepository;
final pubsub.PubsubApi pubsubApi; // new
GooglePlayPurchaseHandler(
this.androidPublisher,
this.iapRepository,
this.pubsubApi, // new
) {
// Poll messages from Pub/Sub every 10 seconds
Timer.periodic(Duration(seconds: 10), (_) {
_pullMessageFromPubSub();
});
}
Timer
는 10초마다 _pullMessageFromSubSub
메서드를 호출하도록 구성됩니다. 원하는 대로 시간 설정을 조정할 수 있습니다.
그런 다음 _pullMessageFromSubSub
를 만듭니다.
lib/google_play_purchase_handler.dart
/// Process messages from Google Play
/// Called every 10 seconds
Future<void> _pullMessageFromPubSub() async {
print('Polling Google Play messages');
final request = pubsub.PullRequest(
maxMessages: 1000,
);
final topicName =
'projects/$googlePlayProjectName/subscriptions/$googlePlayPubsubBillingTopic-sub';
final pullResponse = await pubsubApi.projects.subscriptions.pull(
request,
topicName,
);
final messages = pullResponse.receivedMessages ?? [];
for (final message in messages) {
final data64 = message.message?.data;
if (data64 != null) {
await _processMessage(data64, message.ackId);
}
}
}
Future<void> _processMessage(String data64, String? ackId) async {
final dataRaw = utf8.decode(base64Decode(data64));
print('Received data: $dataRaw');
final dynamic data = jsonDecode(dataRaw);
if (data['testNotification'] != null) {
print('Skip test messages');
if (ackId != null) {
await _ackMessage(ackId);
}
return;
}
final dynamic subscriptionNotification = data['subscriptionNotification'];
final dynamic oneTimeProductNotification =
data['oneTimeProductNotification'];
if (subscriptionNotification != null) {
print('Processing Subscription');
final subscriptionId =
subscriptionNotification['subscriptionId'] as String;
final purchaseToken = subscriptionNotification['purchaseToken'] as String;
final productData = productDataMap[subscriptionId]!;
final result = await handleSubscription(
userId: null,
productData: productData,
token: purchaseToken,
);
if (result && ackId != null) {
await _ackMessage(ackId);
}
} else if (oneTimeProductNotification != null) {
print('Processing NonSubscription');
final sku = oneTimeProductNotification['sku'] as String;
final purchaseToken =
oneTimeProductNotification['purchaseToken'] as String;
final productData = productDataMap[sku]!;
final result = await handleNonSubscription(
userId: null,
productData: productData,
token: purchaseToken,
);
if (result && ackId != null) {
await _ackMessage(ackId);
}
} else {
print('invalid data');
}
}
/// ACK Messages from Pub/Sub
Future<void> _ackMessage(String id) async {
print('ACK Message');
final request = pubsub.AcknowledgeRequest(
ackIds: [id],
);
final subscriptionName =
'projects/$googlePlayProjectName/subscriptions/$googlePlayPubsubBillingTopic-sub';
await pubsubApi.projects.subscriptions.acknowledge(
request,
subscriptionName,
);
}
방금 추가한 코드는 10초마다 Google Cloud의 Pub/Sub 주제와 통신하여 새 메시지를 요청합니다. 그런 다음 _processMessage
메서드에서 각 메시지를 처리합니다.
이 메서드는 수신 메시지를 디코딩하고 구독 및 비구독 등 각 구매에 관한 업데이트된 정보를 가져와 필요한 경우 기존 handleSubscription
또는 handleNonSubscription
를 호출합니다.
각 메시지는 _askMessage
메서드로 확인해야 합니다.
그런 다음 server.dart
파일에 필요한 종속 항목을 추가합니다. 사용자 인증 정보 구성에 PubsubApi.cloudPlatformScope를 추가합니다.
bin/server.dart
final clientGooglePlay =
await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
ap.AndroidPublisherApi.androidpublisherScope,
pubsub.PubsubApi.cloudPlatformScope, // new
]);
그런 다음 PubsubApi 인스턴스를 만듭니다.
bin/server.dart
final pubsubApi = pubsub.PubsubApi(clientGooglePlay);
마지막으로 GooglePlayPurchaseHandler
생성자에 전달합니다.
bin/server.dart
return {
'google_play': GooglePlayPurchaseHandler(
androidPublisher,
iapRepository,
pubsubApi, // new
),
'app_store': AppStorePurchaseHandler(
iapRepository,
),
};
Google Play 설정
pub/sub 주제에서 결제 이벤트를 소비하는 코드를 작성했지만 pub/sub 주제를 만들지 않았으며 결제 이벤트를 게시하지도 않았습니다. 이제 설정할 차례입니다.
먼저 pub/sub 주제를 만듭니다.
- Google Cloud 콘솔에서 Cloud Pub/Sub 페이지로 이동합니다.
- Firebase 프로젝트에 있는지 확인하고 + Create Topic(주제 만들기)을 클릭합니다.
- 새 주제에
constants.ts
에서GOOGLE_PLAY_PUBSUB_BILLING_TOPIC
에 설정된 값과 동일한 이름을 지정합니다. 이 경우 이름을play_billing
로 지정합니다. 다른 항목을 선택하는 경우constants.ts
를 업데이트해야 합니다. 주제를 만듭니다. - Pub/Sub 주제 목록에서 방금 만든 주제의 세로 점 3개를 클릭하고 권한 보기를 클릭합니다.
- 오른쪽 사이드바에서 주 구성원 추가를 선택합니다.
- 여기에서
google-play-developer-notifications@system.gserviceaccount.com
를 추가하고 Pub/Sub 게시자 역할을 부여합니다. - 권한 변경사항을 저장합니다.
- 방금 만든 주제의 주제 이름을 복사합니다.
- Play Console을 다시 열고 모든 앱 목록에서 앱을 선택합니다.
- 아래로 스크롤하여 수익 창출 > 수익 창출 설정으로 이동합니다.
- 전체 주제를 작성하고 변경사항을 저장합니다.
이제 모든 Google Play 결제 이벤트가 주제에 게시됩니다.
App Store 결제 이벤트 처리
그런 다음 App Store 결제 이벤트에 대해 동일한 작업을 실행합니다. 앱 스토어 구매에서 업데이트 처리를 구현하는 데는 두 가지 효과적인 방법이 있습니다. 하나는 개발자가 Apple에 제공하고 Apple에서 서버와 통신하는 데 사용하는 Webhook을 구현하는 것입니다. 두 번째 방법은 이 Codelab에서 다루는 방법으로, App Store Server API에 연결하고 정기 결제 정보를 수동으로 가져오는 것입니다.
이 Codelab에서는 두 번째 솔루션에 중점을 두고 있습니다. 웹훅을 구현하려면 서버를 인터넷에 노출해야 하기 때문입니다.
프로덕션 환경에서는 두 가지 모두를 사용하는 것이 이상적입니다. App Store에서 이벤트를 가져오는 웹훅과 이벤트를 놓쳤거나 구독 상태를 다시 확인해야 하는 경우의 서버 API
먼저 lib/app_store_purchase_handler.dart
를 열고 AppStoreServerAPI 종속 항목을 추가합니다.
lib/app_store_purchase_handler.dart
final AppStoreServerAPI appStoreServerAPI;
AppStorePurchaseHandler(
this.iapRepository,
this.appStoreServerAPI, // new
)
_pullStatus
메서드를 호출하는 타이머를 추가하도록 생성자를 수정합니다. 이 타이머는 10초마다 _pullStatus
메서드를 호출합니다. 이 타이머 시간은 필요에 따라 조정할 수 있습니다.
lib/app_store_purchase_handler.dart
AppStorePurchaseHandler(
this.iapRepository,
this.appStoreServerAPI,
) {
// Poll Subscription status every 10 seconds.
Timer.periodic(Duration(seconds: 10), (_) {
_pullStatus();
});
}
그런 다음 다음과 같이 _pullStatus 메서드를 만듭니다.
lib/app_store_purchase_handler.dart
Future<void> _pullStatus() async {
print('Polling App Store');
final purchases = await iapRepository.getPurchases();
// filter for App Store subscriptions
final appStoreSubscriptions = purchases.where((element) =>
element.type == ProductType.subscription &&
element.iapSource == IAPSource.appstore);
for (final purchase in appStoreSubscriptions) {
final status =
await appStoreServerAPI.getAllSubscriptionStatuses(purchase.orderId);
// Obtain all subscriptions for the order id.
for (final subscription in status.data) {
// Last transaction contains the subscription status.
for (final transaction in subscription.lastTransactions) {
final expirationDate = DateTime.fromMillisecondsSinceEpoch(
transaction.transactionInfo.expiresDate ?? 0);
// Check if subscription has expired.
final isExpired = expirationDate.isBefore(DateTime.now());
print('Expiration Date: $expirationDate - isExpired: $isExpired');
// Update the subscription status with the new expiration date and status.
await iapRepository.updatePurchase(SubscriptionPurchase(
userId: null,
productId: transaction.transactionInfo.productId,
iapSource: IAPSource.appstore,
orderId: transaction.originalTransactionId,
purchaseDate: DateTime.fromMillisecondsSinceEpoch(
transaction.transactionInfo.originalPurchaseDate),
type: ProductType.subscription,
expiryDate: expirationDate,
status: isExpired
? SubscriptionStatus.expired
: SubscriptionStatus.active,
));
}
}
}
}
이 메서드는 다음과 같이 작동합니다.
- IapRepository를 사용하여 Firestore에서 활성 상태인 정기 결제 목록을 가져옵니다.
- 주문마다 App Store Server API에 정기 결제 상태를 요청합니다.
- 해당 정기 결제 구매의 마지막 거래를 가져옵니다.
- 만료일을 확인합니다.
- Firestore에서 구독 상태를 업데이트합니다. 만료된 경우 만료된 것으로 표시됩니다.
마지막으로 App Store Server API 액세스를 구성하는 데 필요한 모든 코드를 추가합니다.
bin/server.dart
// add from here
final subscriptionKeyAppStore =
File('assets/SubscriptionKey.p8').readAsStringSync();
// Configure Apple Store API access
var appStoreEnvironment = AppStoreEnvironment.sandbox(
bundleId: bundleId,
issuerId: appStoreIssuerId,
keyId: appStoreKeyId,
privateKey: subscriptionKeyAppStore,
);
// Stored token for Apple Store API access, if available
final file = File('assets/appstore.token');
String? appStoreToken;
if (file.existsSync() && file.lengthSync() > 0) {
appStoreToken = file.readAsStringSync();
}
final appStoreServerAPI = AppStoreServerAPI(
AppStoreServerHttpClient(
appStoreEnvironment,
jwt: appStoreToken,
jwtTokenUpdatedCallback: (token) {
file.writeAsStringSync(token);
},
),
);
// to here
return {
'google_play': GooglePlayPurchaseHandler(
androidPublisher,
iapRepository,
pubsubApi,
),
'app_store': AppStorePurchaseHandler(
iapRepository,
appStoreServerAPI, // new
),
};
App Store 설정
다음으로 App Store를 설정합니다.
- App Store Connect에 로그인하고 사용자 및 액세스를 선택합니다.
- 키 유형 > 인앱 구매로 이동합니다.
- '더하기' 아이콘을 탭하여 새 위치를 추가합니다.
- 이름을 지정합니다(예: 'Codelab 키').
- 키가 포함된 p8 파일을 다운로드합니다.
- 애셋 폴더에
SubscriptionKey.p8
라는 이름으로 복사합니다. - 새로 만든 키에서 키 ID를 복사하여
lib/constants.dart
파일에서appStoreKeyId
상수로 설정합니다. - 키 목록 상단에 있는 Issuer ID를 복사하고
lib/constants.dart
파일에서appStoreIssuerId
상수로 설정합니다.
기기에서 구매 추적하기
클라이언트를 보호하기가 어렵기 때문에 구매를 추적하는 가장 안전한 방법은 서버 측에서 하는 것입니다. 하지만 앱이 정기 결제 상태 정보를 기반으로 작업할 수 있도록 정보를 클라이언트로 다시 가져오는 방법이 있어야 합니다. Firestore에 구매 항목을 저장하면 데이터를 클라이언트에 쉽게 동기화하고 자동으로 업데이트된 상태로 유지할 수 있습니다.
List<PastPurchase> purchases
에 있는 사용자의 모든 구매 데이터가 포함된 Firestore 저장소인 IAPRepo를 이미 앱에 포함했습니다. 저장소에는 만료되지 않은 상태의 productId storeKeySubscription
가 포함된 구매가 있는 경우 true인 hasActiveSubscription,
도 포함되어 있습니다. 사용자가 로그인하지 않은 경우 목록은 비어 있습니다.
lib/repo/iap_repo.dart
void updatePurchases() {
_purchaseSubscription?.cancel();
var user = _user;
if (user == null) {
purchases = [];
hasActiveSubscription = false;
hasUpgrade = false;
return;
}
var purchaseStream = _firestore
.collection('purchases')
.where('userId', isEqualTo: user.uid)
.snapshots();
_purchaseSubscription = purchaseStream.listen((snapshot) {
purchases = snapshot.docs.map((DocumentSnapshot document) {
var data = document.data();
return PastPurchase.fromJson(data);
}).toList();
hasActiveSubscription = purchases.any((element) =>
element.productId == storeKeySubscription &&
element.status != Status.expired);
hasUpgrade = purchases.any(
(element) => element.productId == storeKeyUpgrade,
);
notifyListeners();
});
}
모든 구매 로직은 DashPurchases
클래스에 있으며 여기에서 정기 결제를 적용하거나 삭제해야 합니다. 따라서 iapRepo
를 클래스의 속성으로 추가하고 생성자에 iapRepo
를 할당합니다. 그런 다음 생성자에 리스너를 직접 추가하고 dispose()
메서드에서 리스너를 삭제합니다. 처음에는 리스너가 빈 함수일 수 있습니다. IAPRepo
는 ChangeNotifier
이고 Firestore의 구매 항목이 변경될 때마다 notifyListeners()
를 호출하므로 구매한 제품이 변경될 때마다 purchasesUpdate()
메서드가 항상 호출됩니다.
lib/logic/dash_purchases.dart
IAPRepo iapRepo;
DashPurchases(this.counter, this.firebaseNotifier, this.iapRepo) {
final purchaseUpdated = iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
iapRepo.addListener(purchasesUpdate);
loadPurchases();
}
@override
void dispose() {
_subscription.cancel();
iapRepo.removeListener(purchasesUpdate);
super.dispose();
}
void purchasesUpdate() {
//TODO manage updates
}
그런 다음 main.dart.
의 생성자에 IAPRepo
를 제공합니다. 저장소는 이미 Provider
에 생성되어 있으므로 context.read
를 사용하여 가져올 수 있습니다.
lib/main.dart
ChangeNotifierProvider<DashPurchases>(
create: (context) => DashPurchases(
context.read<DashCounter>(),
context.read<FirebaseNotifier>(),
context.read<IAPRepo>(),
),
lazy: false,
),
다음으로 purchaseUpdate()
함수의 코드를 작성합니다. dash_counter.dart,
에서 applyPaidMultiplier
및 removePaidMultiplier
메서드는 배수를 각각 10 또는 1로 설정하므로 정기 결제가 이미 적용되었는지 확인할 필요가 없습니다. 구독 상태가 변경되면 구매 페이지에 이미 활성화되었다고 표시할 수 있도록 구매 가능한 제품의 상태도 업데이트합니다. 업그레이드 구매 여부에 따라 _beautifiedDashUpgrade
속성을 설정합니다.
lib/logic/dash_purchases.dart
void purchasesUpdate() {
var subscriptions = <PurchasableProduct>[];
var upgrades = <PurchasableProduct>[];
// Get a list of purchasable products for the subscription and upgrade.
// This should be 1 per type.
if (products.isNotEmpty) {
subscriptions = products
.where((element) => element.productDetails.id == storeKeySubscription)
.toList();
upgrades = products
.where((element) => element.productDetails.id == storeKeyUpgrade)
.toList();
}
// Set the subscription in the counter logic and show/hide purchased on the
// purchases page.
if (iapRepo.hasActiveSubscription) {
counter.applyPaidMultiplier();
for (var element in subscriptions) {
_updateStatus(element, ProductStatus.purchased);
}
} else {
counter.removePaidMultiplier();
for (var element in subscriptions) {
_updateStatus(element, ProductStatus.purchasable);
}
}
// Set the Dash beautifier and show/hide purchased on
// the purchases page.
if (iapRepo.hasUpgrade != _beautifiedDashUpgrade) {
_beautifiedDashUpgrade = iapRepo.hasUpgrade;
for (var element in upgrades) {
_updateStatus(
element,
_beautifiedDashUpgrade
? ProductStatus.purchased
: ProductStatus.purchasable);
}
notifyListeners();
}
}
void _updateStatus(PurchasableProduct product, ProductStatus status) {
if (product.status != ProductStatus.purchased) {
product.status = ProductStatus.purchased;
notifyListeners();
}
}
이제 백엔드 서비스에서 정기 결제 및 업그레이드 상태가 항상 최신 상태로 유지되고 앱과 동기화됩니다. 앱은 그에 따라 작동하여 Dash 클릭 게임에 정기 결제 및 업그레이드 기능을 적용합니다.
12. 완료
수고하셨습니다. Codelab을 완료했습니다. 이 Codelab의 완료된 코드는 complete 폴더에서 확인할 수 있습니다.
자세한 내용은 다른 Flutter Codelab을 참고하세요.