1. מבוא
כדי להוסיף רכישות באפליקציה לאפליקציית Flutter, צריך להגדיר נכון את חנויות האפליקציות ואת חנות Play, לאמת את הרכישה ולהעניק את ההרשאות הנדרשות, כמו הטבות של מינוי.
ב-codelab הזה תוסיפו לאפליקציה (שמסופקת לכם) שלושה סוגים של רכישות מתוך האפליקציה, ותאמתו את הרכישות האלה באמצעות קצה עורפי של Dart עם Firebase. האפליקציה שצוינה, Dash Clicker, מכילה משחק שמשתמש בקמע Dash כמטבע. תוסיפו את אפשרויות הרכישה הבאות:
- אפשרות רכישה חוזרת של 2,000 נקודות Dash בבת אחת.
- רכישת שדרוג חד-פעמי כדי לשנות את לוח הבקרה הישן ללוח בקרה מודרני.
- מינוי שמכפיל את מספר הקליקים שנוצרים באופן אוטומטי.
האפשרות הראשונה לרכישה מעניקה למשתמש הטבה ישירה של 2,000 נקודות Dash. הן זמינות ישירות למשתמש ואפשר לקנות אותן הרבה פעמים. המוצר הזה נקרא מוצר מתכלה כי הוא נצרך ישירות ואפשר לצרוך אותו כמה פעמים.
האפשרות השנייה משדרגת את ה-Dash ל-Dash יפה יותר. צריך לרכוש את המינוי הזה רק פעם אחת, והוא זמין לתמיד. רכישה כזו נקראת רכישה לא מתכלה כי אי אפשר לצרוך אותה באפליקציה, אבל היא תקפה לנצח.
אפשרות הרכישה השלישית והאחרונה היא מינוי. בזמן שהמינוי פעיל, המשתמש יקבל מקפים מהר יותר, אבל כשהוא יפסיק לשלם על המינוי, הוא גם יאבד את ההטבות.
השירות לקצה העורפי (שמסופק גם הוא) פועל כאפליקציית Dart, מאמת שהרכישות בוצעו ומאחסן אותן באמצעות Firestore. משתמשים ב-Firestore כדי להקל על התהליך, אבל באפליקציה שלכם בסביבת הייצור אתם יכולים להשתמש בכל סוג של שירות לקצה העורפי.

מה תפַתחו
- תלמדו איך להרחיב אפליקציה כדי לתמוך ברכישות של מוצרים מתכלים ובמינויים.
- בנוסף, תרחיבו אפליקציית Dart backend כדי לאמת ולאחסן את הפריטים שנרכשו.
מה תלמדו
- איך מגדירים את App Store ואת חנות Play עם מוצרים שאפשר לרכוש.
- איך מתקשרים עם החנויות כדי לאמת רכישות ולאחסן אותן ב-Firestore.
- איך לנהל רכישות באפליקציה.
הדרישות
- Android Studio
- Xcode (לפיתוח ל-iOS)
- Flutter SDK
2. הגדרת סביבת הפיתוח
כדי להתחיל את ה-Codelab הזה, מורידים את הקוד ומשנים את מזהה החבילה ל-iOS ואת שם החבילה ל-Android.
הורדת הקוד
כדי לשכפל את מאגר GitHub משורת הפקודה, משתמשים בפקודה הבאה:
git clone https://github.com/flutter/codelabs.git flutter-codelabs
לחלופין, אם התקנתם את כלי ה-CLI של GitHub, אתם יכולים להשתמש בפקודה הבאה:
gh repo clone flutter/codelabs flutter-codelabs
הקוד לדוגמה משוכפל לספרייה flutter-codelabs שמכילה את הקוד לאוסף של סדנאות קוד. הקוד של ה-Codelab הזה נמצא ב-flutter-codelabs/in_app_purchases.
מבנה הספריות מתחת ל-flutter-codelabs/in_app_purchases מכיל סדרה של תמונות מצב שמראות איפה אתם אמורים להיות בסוף כל שלב עם שם. קוד ההתחלה נמצא בשלב 0, ולכן צריך לנווט אליו באופן הבא:
cd flutter-codelabs/in_app_purchases/step_00
אם רוצים לדלג קדימה או לראות איך משהו אמור להיראות אחרי שלב מסוים, אפשר לחפש בספרייה שנקראת על שם השלב שמעניין אתכם. הקוד של השלב האחרון נמצא בתיקייה complete.
הגדרת פרויקט התחלתי
פותחים את פרויקט המתחילים מ-step_00/app בסביבת הפיתוח המשולבת (IDE) המועדפת. השתמשנו ב-Android Studio לצילומי המסך, אבל Visual Studio Code היא גם אפשרות מצוינת. בכל אחד מהעורכים, מוודאים שהתוספים העדכניים של Dart ו-Flutter מותקנים.
האפליקציות שאתם יוצרים צריכות לתקשר עם חנות האפליקציות של אפל ועם חנות Play כדי לדעת אילו מוצרים זמינים ומה המחיר שלהם. כל אפליקציה מזוהה באמצעות מזהה ייחודי. ב-App Store ל-iOS זה נקרא מזהה החבילה, וב-חנות Play ל-Android זה נקרא מזהה האפליקציה. בדרך כלל, המזהים האלה נוצרים באמצעות סימון של שם דומיין הפוך. לדוגמה, כשמבצעים רכישה באפליקציה ב-flutter.dev, משתמשים ב-dev.flutter.inapppurchase. תחשבו על מזהה לאפליקציה שלכם, ועכשיו תגדירו אותו בהגדרות הפרויקט.
קודם כול, מגדירים את מזהה החבילה ב-iOS. כדי לעשות את זה, פותחים את הקובץ Runner.xcworkspace באפליקציית Xcode.

במבנה התיקיות של Xcode, פרויקט Runner נמצא בחלק העליון, והיעדים Flutter, Runner וProducts נמצאים מתחת לפרויקט Runner. לוחצים לחיצה כפולה על Runner כדי לערוך את הגדרות הפרויקט, ואז לוחצים על Signing & Capabilities (חתימה ויכולות). מזינים את מזהה החבילה שבחרתם בשדה צוות כדי להגדיר את הצוות.

עכשיו אפשר לסגור את Xcode ולחזור אל Android Studio כדי לסיים את ההגדרה של Android. כדי לעשות זאת, פותחים את הקובץ build.gradle.kts בקטע android/app, ומשנים את applicationId (בשורה 24 בצילום המסך שלמטה) למזהה האפליקציה, זהה למזהה החבילה של iOS. שימו לב: המזהים של חנויות iOS ו-Android לא חייבים להיות זהים, אבל אם הם זהים יש פחות סיכוי לטעות. לכן, במעבדת הקוד הזו נשתמש גם במזהים זהים.

3. התקנת הפלאגין
בחלק הזה של ה-codelab תתקינו את הפלאגין in_app_purchase.
הוספת תלות ב-pubspec
מוסיפים את in_app_purchase לקובץ pubspec על ידי הוספת in_app_purchase לתלויות בפרויקט:
$ cd app $ flutter pub add in_app_purchase dev:in_app_purchase_platform_interface Resolving dependencies... Downloading packages... characters 1.4.0 (1.4.1 available) flutter_lints 5.0.0 (6.0.0 available) + in_app_purchase 3.2.3 + in_app_purchase_android 0.4.0+3 + in_app_purchase_platform_interface 1.4.0 + in_app_purchase_storekit 0.4.4 + json_annotation 4.9.0 lints 5.1.1 (6.0.0 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) provider 6.1.5 (6.1.5+1 available) test_api 0.7.6 (0.7.7 available) Changed 5 dependencies! 7 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
פותחים את pubspec.yaml ומוודאים ש-in_app_purchase מופיע כרשומה בקטע dependencies, ו-in_app_purchase_platform_interface מופיע בקטע dev_dependencies.
pubspec.yaml
dependencies:
flutter:
sdk: flutter
cloud_firestore: ^6.0.0
cupertino_icons: ^1.0.8
firebase_auth: ^6.0.1
firebase_core: ^4.0.0
google_sign_in: ^7.1.1
http: ^1.5.0
intl: ^0.20.2
provider: ^6.1.5
logging: ^1.3.0
in_app_purchase: ^3.2.3
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
in_app_purchase_platform_interface: ^1.4.0
4. הגדרת App Store
כדי להגדיר רכישות באפליקציה ולבדוק אותן ב-iOS, צריך ליצור אפליקציה חדשה ב-App Store וליצור בה מוצרים שאפשר לרכוש. אתם לא צריכים לפרסם שום דבר או לשלוח את האפליקציה לאפל לבדיקה. כדי לעשות את זה, צריך חשבון פיתוח. אם אין לכם חשבון כזה, אתם צריכים להירשם לתוכנית המפתחים של אפל.
הסכמים בנושא אפליקציות בתשלום
כדי להשתמש ברכישות מתוך האפליקציה, צריך גם הסכם פעיל לגבי אפליקציות בתשלום ב-App Store Connect. עוברים לכתובת https://appstoreconnect.apple.com/ ולוחצים על Agreements, Tax, and Banking (הסכמים, מיסים ובנקאות).

כאן יופיעו הסכמים לאפליקציות בחינם ולאפליקציות בתשלום. הסטטוס של אפליקציות חינמיות צריך להיות פעיל, והסטטוס של אפליקציות בתשלום צריך להיות חדש. חשוב לעיין בתנאים, לאשר אותם ולהזין את כל המידע הנדרש.

אם הכול מוגדר בצורה נכונה, הסטטוס של אפליקציות בתשלום יהיה פעיל. זה חשוב מאוד כי לא תוכלו לנסות רכישות מתוך האפליקציה בלי הסכם פעיל.

רישום מזהה האפליקציה
יוצרים מזהה חדש בפורטל Apple Developer. נכנסים לכתובת developer.apple.com/account/resources/identifiers/list ולוחצים על סמל הפלוס לצד הכותרת מזהים.

בחירת מזהי אפליקציות

בחירת אפליקציה

מוסיפים תיאור ומגדירים את מזהה החבילה כך שיהיה זהה לערך שהוגדר קודם ב-XCode.

הוראות נוספות ליצירת מזהה אפליקציה חדש זמינות בעזרה בנושא חשבון פיתוח.
יצירת אפליקציה חדשה
יוצרים אפליקציה חדשה ב-App Store Connect עם מזהה החבילה הייחודי שלכם.


הוראות נוספות ליצירת אפליקציה חדשה ולניהול הסכמים זמינות בעזרה של App Store Connect.
כדי לבדוק את הרכישות מתוך האפליקציה, צריך משתמש לבדיקה בסביבת ארגז חול. המשתמש הבודק הזה לא צריך להיות מקושר ל-iTunes – הוא משמש רק לבדיקת רכישות מתוך האפליקציה. אי אפשר להשתמש בכתובת אימייל שכבר משמשת לחשבון אפל. בקטע משתמשים וגישה, עוברים אל ארגז חול כדי ליצור חשבון ארגז חול חדש או כדי לנהל את מזהי Apple הקיימים של ארגז החול.

עכשיו אפשר להגדיר את משתמש הארגז חול באייפון. לשם כך, עוברים אל הגדרות > מפתח > חשבון ארגז חול של אפל.

הגדרת רכישות מתוך האפליקציה
עכשיו מגדירים את שלושת הפריטים שאפשר לרכוש:
-
dash_consumable_2k: רכישה מתכלה שאפשר לבצע הרבה פעמים, שמעניקה למשתמש 2,000 נקודות Dash (המטבע באפליקציה) לכל רכישה. -
dash_upgrade_3d: רכישת 'שדרוג' חד-פעמית שלא ניתן לצרוך, שמעניקה למשתמש אפשרות ללחוץ על מקף שונה מבחינה ויזואלית. -
dash_subscription_doubler: מינוי שמעניק למשתמש פי שניים יותר מקפים לכל קליק למשך תקופת המינוי.

עוברים אל רכישות מתוך האפליקציה.
יוצרים את הרכישות מתוך האפליקציה עם המזהים שצוינו:
- הגדרה של
dash_consumable_2kכפריט מתכלה. משתמשים ב-dash_consumable_2kבתור מזהה המוצר. השם לצורך הפניה משמש רק ב-App Store Connect, לכן אפשר להגדיר אותו כ-dash consumable 2k.
הגדרת הזמינות. המוצר צריך להיות זמין במדינה של משתמש ארגז החול.
מוסיפים מחיר ומגדירים אותו ל-$1.99או לסכום שווה ערך במטבע אחר.
מוסיפים את הלוקליזציות לרכישה. קוראים לרכישה Spring is in the airומוסיפים את התיאור2000 dashes fly out.
מוסיפים צילום מסך של הביקורת. התוכן לא משנה אלא אם המוצר נשלח לבדיקה, אבל הוא נדרש כדי שהמוצר יהיה במצב 'מוכן לשליחה', שנדרש כשהאפליקציה מאחזרת מוצרים מ-App Store. 
- מגדירים את
dash_upgrade_3dכפריט שלא ניתן לצריכה. משתמשים ב-dash_upgrade_3dבתור מזהה המוצר. מגדירים את שם ההפניה ל-dash upgrade 3d. קוראים לרכישה3D Dashומוסיפים את התיאורBrings your dash back to the future. הגדרת המחיר ל-$0.99. מגדירים את הזמינות ומעלים את צילום המסך של הביקורת באותו אופן כמו שעושים עם מוצרdash_consumable_2k.
- מגדירים את
dash_subscription_doublerכמינוי שמתחדש אוטומטית. תהליך ההרשמה למינויים שונה קצת. קודם צריך ליצור קבוצת מינויים. אם כמה מינויים הם חלק מאותה קבוצה, משתמש יכול להירשם רק לאחד מהם בכל פעם, אבל הוא יכול לשדרג או לשנמך בין המינויים האלה. פשוט מתקשרים לקבוצהsubscriptions.
מוסיפים לוקליזציה לקבוצת המנויים.
בשלב הבא יוצרים את המינוי. מגדירים את שם ההפניה ל-dash subscription doublerואת מזהה המוצר ל-dash_subscription_doubler.
לאחר מכן בוחרים את משך המינוי (שבוע אחד) ואת הלוקליזציות. תקרא למינוי הזה Jet Engineותוסיף את התיאורDoubles your clicks. הגדרת המחיר ל-$0.49. מגדירים את הזמינות ומעלים את צילום המסך של הביקורת באותו אופן כמו שעושים עם מוצרdash_consumable_2k.
עכשיו המוצרים אמורים להופיע ברשימות:

5. הגדרת חנות Play
בדומה ל-App Store, תצטרכו גם חשבון מפתח בחנות Play. אם עדיין אין לכם חשבון, צריך להירשם.
יצירת אפליקציה חדשה
יוצרים אפליקציה חדשה ב-Google Play Console:
- פותחים את Play Console.
- בוחרים באפשרות כל האפליקציות > יצירת אפליקציה.
- בוחרים שפת ברירת מחדל ומוסיפים שם לאפליקציה. מקלידים את שם האפליקציה כפי שרוצים שהוא יופיע ב-Google Play. אפשר לשנות את השם בהמשך.
- מציינים שהאפליקציה היא משחק. אפשר לשנות את הבחירה בשלב מאוחר יותר.
- מציינים אם האפליקציה חינמית או בתשלום.
- משלימים את ההצהרות בנוגע להנחיות לתוכן ולחוקי הייצוא של ארה"ב.
- בוחרים באפשרות יצירת אפליקציה.
אחרי שיוצרים את האפליקציה, עוברים ללוח הבקרה ומשלימים את כל המשימות בקטע הגדרת האפליקציה. בשלב הזה, צריך לספק מידע על האפליקציה, כמו סיווגי תוכן וצילומי מסך. 
חתימה על הבקשה
כדי לבדוק רכישות מתוך האפליקציה, צריך להעלות לפחות גרסת build אחת ל-Google Play.
לשם כך, צריך לחתום על גרסת build להפצה באמצעות משהו אחר מלבד מפתחות ניפוי הבאגים.
יצירת מאגר מפתחות
אם יש לכם מאגר מפתחות קיים, מדלגים לשלב הבא. אם לא, יוצרים אותו באמצעות הפקודה הבאה בשורת הפקודה.
ב-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
file private; don't check it into public source control!
הפניה אל מאגר המפתחות מהאפליקציה
יוצרים קובץ בשם <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.kts כדי להגדיר חתימה לאפליקציה.
מוסיפים את פרטי מאגר המפתחות מקובץ המאפיינים לפני הבלוק android:
import java.util.Properties
import java.io.FileInputStream
plugins {
// omitted
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
// omitted
}
טוענים את הקובץ key.properties לאובייקט keystoreProperties.
מעדכנים את הבלוק buildTypes כך שיהיה:
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
מגדירים את הבלוק signingConfigs בקובץ build.gradle.kts של המודול עם פרטי הגדרות החתימה:
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
מעכשיו, חתימה על גרסאות של האפליקציה תתבצע באופן אוטומטי.
מידע נוסף על חתימת האפליקציה זמין במאמר בנושא חתימת האפליקציה באתר developer.android.com.
העלאת הגרסה הראשונה
אחרי שמגדירים את האפליקציה לחתימה, אפשר ליצור את האפליקציה על ידי הרצת הפקודה:
flutter build appbundle
הפקודה הזו יוצרת כברירת מחדל גרסת build להפצה, והפלט נמצא בנתיב <your app dir>/build/app/outputs/bundle/release/
מלוח הבקרה ב-Google Play Console, עוברים אל בדיקה והפצה > בדיקה > בדיקה בקבוצה מוגדרת ויוצרים גרסת בדיקה חדשה בקבוצה מוגדרת.
לאחר מכן, מעלים את קובץ ה-AAB app-release.aab שנוצר על ידי פקודת הבנייה.
לוחצים על שמירה ואז על בדיקת הגרסה.
לבסוף, לוחצים על השקת הגרסה לבדיקות בקבוצות מוגדרות כדי להפעיל את הגרסה לבדיקות בקבוצות מוגדרות.
הגדרת משתמשי בדיקה
כדי לבדוק רכישות מתוך האפליקציה, צריך להוסיף את חשבונות 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 ובוחרים את האפליקציה.
- עוברים אל מונטיזציה > מוצרים > מוצרים מתוך האפליקציה.
- לוחצים על יצירת מוצר
. - מזינים את כל המידע הנדרש לגבי המוצר. חשוב לוודא שמזהה המוצר תואם בדיוק למזהה שבו אתם רוצים להשתמש.
- לוחצים על שמירה.
- לוחצים על הפעלה.
- חוזרים על התהליך עבור הרכישה של השדרוג שאינו מתכלה.
בשלב הבא, מוסיפים את המינוי:
- עוברים אל Google Play Console ובוחרים את האפליקציה.
- עוברים אל מונטיזציה > מוצרים > מינויים.
- לוחצים על יצירת מינוי
. - מזינים את כל הפרטים הנדרשים לגבי המינוי. חשוב לוודא שמזהה המוצר תואם בדיוק למזהה שבו אתם רוצים להשתמש.
- לוחצים על שמירה.
ההגדרות של הרכישות שלכם אמורות להופיע עכשיו ב-Play Console.
6. הגדרת Firebase
ב-Codelab הזה תשתמשו בשירות לקצה העורפי כדי לאמת ולעקוב אחרי הרכישות של המשתמשים.
לשימוש בשירות קצה עורפי יש כמה יתרונות:
- אתם יכולים לאמת עסקאות בצורה מאובטחת.
- אתם יכולים להגיב לאירועי חיוב מחנויות האפליקציות.
- אפשר לעקוב אחרי הרכישות במסד נתונים.
- המשתמשים לא יוכלו להטעות את האפליקציה כדי לקבל תכונות פרימיום על ידי החזרה של השעון במערכת.
יש הרבה דרכים להגדיר שירות קצה עורפי, אבל אנחנו נשתמש ב-Cloud Functions וב-Firestore באמצעות Firebase של Google.
כתיבת הבק-אנד לא נכללת בתחום של ה-Codelab הזה, ולכן קוד לתחילת הדרך כבר כולל פרויקט Firebase שמטפל ברכישות בסיסיות כדי שתוכלו להתחיל.
תוספי Firebase כלולים גם באפליקציה לתחילת הדרך.
מה שנותר לכם לעשות הוא ליצור פרויקט Firebase משלכם, להגדיר את האפליקציה ואת ה-Backend ל-Firebase, ולבסוף לפרוס את ה-Backend.
יצירת פרויקט Firebase
עוברים אל מסוף Firebase ויוצרים פרויקט חדש ב-Firebase. בדוגמה הזו, שם הפרויקט הוא Dash Clicker.
באפליקציה לקצה העורפי, אתם מקשרים רכישות למשתמש ספציפי, ולכן אתם צריכים אימות. לשם כך, משתמשים במודול האימות של Firebase עם כניסה באמצעות חשבון Google.
- מלוח הבקרה של Firebase, עוברים אל אימות ומפעילים אותו, אם צריך.
- עוברים לכרטיסייה שיטת התחברות ומפעילים את ספק הכניסה Google.

תשתמשו גם במסד הנתונים של Firestore ב-Firebase, לכן צריך להפעיל גם אותו.

מגדירים כללים ל-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
}
}
}
הגדרה של Firebase ל-Flutter
הדרך המומלצת להתקין את Firebase באפליקציית Flutter היא באמצעות 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
הגדרה של Firebase ל-Android: שלבים נוספים
מלוח הבקרה של Firebase, עוברים אל Project Overview (סקירת הפרויקט), בוחרים באפשרות Settings (הגדרות) ולוחצים על הכרטיסייה General (כללי).
גוללים למטה אל האפליקציות שלך ובוחרים באפליקציה dashclicker (android).

כדי לאפשר כניסה באמצעות חשבון Google במצב ניפוי באגים, צריך לספק את טביעת האצבע של הגיבוב SHA-1 של אישור ניפוי הבאגים.
קבלת הגיבוב (hash) של אישור החתימה לניפוי באגים
בשורש של פרויקט אפליקציית 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 וממלאים את השדה האחרון בתיבת הדו-שיח של שליחת האפליקציה.
בסוף, מריצים שוב את הפקודה flutterfire configure כדי לעדכן את האפליקציה כך שתכלול את הגדרות החתימה.
$ flutterfire configure ? You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the valus in your existing `firebase.json` file to configure your project? (y/n) › yes ✔ You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the values in your existing `firebase.json` file to configure your project? · yes
הגדרה של Firebase ל-iOS: שלבים נוספים
פותחים את ios/Runner.xcworkspace באמצעות Xcode. או באמצעות סביבת הפיתוח המשולבת (IDE) שאתם בוחרים.
ב-VSCode לוחצים לחיצה ימנית על התיקייה ios/ ואז על open in xcode.
ב-Android Studio, לוחצים לחיצה ימנית על התיקייה ios/ ואז לוחצים על flutter ואחריו על האפשרות open iOS module in Xcode.
כדי לאפשר כניסה באמצעות חשבון Google ב-iOS, מוסיפים את אפשרות ההגדרה CFBundleURLTypes לקובצי ה-build plist. (מידע נוסף זמין במסמכי התיעוד של google_sign_in החבילה). במקרה הזה, הקובץ הוא ios/Runner/Info.plist.
צמד המפתח/ערך כבר נוסף, אבל צריך להחליף את הערכים שלו:
- הערך של
REVERSED_CLIENT_IDמגיע מקובץGoogleService-Info.plist, בלי רכיב<string>..</string>שמקיף אותו. - מחליפים את הערך בקובץ
ios/Runner/Info.plistבמפתחCFBundleURLTypes.
<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, מוצאים את הווידג'ט MyHomePage עם Scaffold שמכיל BottomNavigationBar עם שני דפים. בדף הזה נוצרים גם שלושה Provider עבור DashCounter, DashUpgrades, ו-DashPurchases. DashCounter עוקב אחרי המספר הנוכחי של מקפים ומגדיל אותו אוטומטית. DashUpgrades מנהל את השדרוגים שאפשר לקנות באמצעות מקפים. ב-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, עוברים לקוד של DashPurchasesChangeNotifier. בשלב הזה, יש רק 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();
}
}
מילת המפתח late נוספת ל-_subscription כי _subscription מאותחל בבונה. הפרויקט הזה מוגדר כפרויקט שבו אי אפשר להגדיר ערך null כברירת מחדל (NNBD), כלומר מאפיינים שלא הוגדרו כמאפיינים שאפשר להגדיר להם ערך null חייבים לקבל ערך שאינו null. התוחם late מאפשר להגדיר את הערך הזה בשלב מאוחר יותר.
ב-constructor, מקבלים את הזרם purchaseUpdated ומתחילים להאזין לו. בשיטה dispose(), מבטלים את המינוי לשידור.
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';
import '../main.dart';
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.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, תחליפו את המוצרים הקיימים שהם מוצרים לדוגמה במוצרים אמיתיים שאפשר לקנות. המוצרים האלה נטענים מהחנויות, מוצגים ברשימה ונרכשים כשמקישים על המוצר.
Adapt PurchasableProduct
PurchasableProduct מוצג מוצר לדוגמה. כדי לעדכן את הקוד כך שיוצג תוכן בפועל, מחליפים את המחלקה PurchasableProduct ב-purchasable_product.dart בקוד הבא:
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;
}
}
כשהחנות זמינה, טוענים את הרכישות הזמינות. בהתאם להגדרות הקודמות של Google Play ו-App Store, אמורים להופיע storeKeyConsumable, storeKeySubscription, ו-storeKeyUpgrade. אם רכישה צפויה לא זמינה, המידע הזה יודפס במסוף. כדאי גם לשלוח את המידע הזה לשירות לקצה העורפי.
השיטה await iapConnection.queryProductDetails(ids) מחזירה גם את המזהים שלא נמצאו וגם את המוצרים שניתן לרכוש שנמצאו. משתמשים ב-productDetails מהתגובה כדי לעדכן את ממשק המשתמש, ומגדירים את 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() ב-constructor:
lib/logic/dash_purchases.dart
DashPurchases(this.counter) {
final purchaseUpdated = iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
loadPurchases(); // Add this line
}
לבסוף, משנים את הערך של השדה storeState מ-StoreState.available ל-StoreState.loading:
lib/logic/dash_purchases.dart
StoreState storeState = StoreState.loading;
הצגת המוצרים שאפשר לקנות
כדאי לעיין בקובץ purchase_page.dart. בווידג'ט PurchasePage מוצגים _PurchasesLoading, _PurchaseList, או _PurchasesNotAvailable, בהתאם לStoreState. בווידג'ט מוצגות גם הרכישות הקודמות של המשתמש, שמשמשות בשלב הבא.
בווידג'ט _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 הזה, ולכן הם כלולים בקוד לתחילת הדרך. לפני שמתחילים, כדאי לעבור על מה שכבר קיים בקוד ההתחלתי כדי להבין איך כדאי לארגן את הדברים.
קוד ה-Backend הזה יכול לפעול באופן מקומי במחשב שלכם, אתם לא צריכים לפרוס אותו כדי להשתמש בו. עם זאת, אתם צריכים להיות מסוגלים להתחבר ממכשיר הפיתוח (Android או iPhone) למחשב שבו השרת יפעל. לשם כך, הם צריכים להיות באותה רשת, ואתם צריכים לדעת את כתובת ה-IP של המחשב שלכם.
מנסים להריץ את השרת באמצעות הפקודה הבאה:
$ dart ./bin/server.dart Serving at http://0.0.0.0:8080
הקצה העורפי של Dart משתמש ב-shelf וב-shelf_router כדי להציג נקודות קצה של API. כברירת מחדל, השרת לא מספק נתיבים. בהמשך תיצרו מסלול לטיפול בתהליך אימות הרכישה.
חלק אחד שכבר כלול בקוד ההתחלתי הוא IapRepository ב-lib/iap_repository.dart. ההסבר על האינטראקציה עם Firestore או עם מסדי נתונים באופן כללי לא רלוונטי ל-codelab הזה, ולכן קוד ההתחלה מכיל פונקציות ליצירה או לעדכון של רכישות ב-Firestore, וגם את כל המחלקות של הרכישות האלה.
הגדרת גישה ל-Firebase
כדי לגשת ל-Firebase Firestore, צריך מפתח גישה לחשבון שירות. כדי ליצור מפתח פרטי, פותחים את הגדרות הפרויקט ב-Firebase, עוברים לקטע חשבונות שירות ובוחרים באפשרות יצירת מפתח פרטי חדש.

מעתיקים את קובץ ה-JSON שהורדתם לתיקייה assets/ ומשנים את השם שלו ל-service-account-firebase.json.
הגדרת גישה ל-Google Play
כדי לגשת לחנות Play ולאמת רכישות, צריך ליצור חשבון שירות עם ההרשאות האלה ולהוריד את פרטי הכניסה שלו בפורמט JSON.
- נכנסים לדף Google Play Android Developer API במסוף Google Cloud.
אם ב-Google Play Console מופיעה בקשה ליצור פרויקט או לקשר לפרויקט קיים, צריך לעשות זאת ואז לחזור לדף הזה. - עוברים אל הדף 'חשבונות שירות' ולוחצים על + יצירת חשבון שירות.

- מזינים את שם חשבון השירות ולוחצים על יצירה והמשך.

- בוחרים בתפקיד Pub/Sub Subscriber ולוחצים על Done.

- אחרי שיוצרים את החשבון, עוברים אל ניהול מפתחות.

- בוחרים באפשרות הוספת מפתח > יצירת מפתח חדש.

- יוצרים ומורידים מפתח JSON.

- משנים את השם של הקובץ שהורדתם ל-
service-account-google-play.json,ומעבירים אותו לספרייהassets/. - לאחר מכן, עוברים לדף משתמשים והרשאות ב-Play Console

- לוחצים על הזמנת משתמשים חדשים ומזינים את כתובת האימייל של חשבון השירות שנוצר קודם. אפשר למצוא את כתובת האימייל בטבלה בדף Service accounts

- נותנים לאפליקציה את ההרשאות הצגת נתונים פיננסיים וניהול הזמנות ומינויים.

- לוחצים על הזמנת משתמש.
עוד דבר שצריך לעשות הוא לפתוח את lib/constants.dart, ולהחליף את הערך של androidPackageId במזהה החבילה שבחרתם לאפליקציית Android.
הגדרת גישה ל-Apple App Store
כדי לגשת ל-App Store ולאמת רכישות, צריך להגדיר סוד לשימוש עם טוקן צרכן:
- פותחים את App Store Connect.
- עוברים אל האפליקציות שלי ובוחרים את האפליקציה.
- בסרגל הצד לניווט, עוברים אל כללי > פרטי האפליקציה.
- לוחצים על ניהול מתחת לכותרת App-Specific Shared Secret (סוד משותף ספציפי לאפליקציה).

- יוצרים סוד חדש ומעתיקים אותו.

- פותחים את
lib/constants.dart,ומחליפים את הערך שלappStoreSharedSecretבסוד לשימוש עם טוקן צרכן שיצרתם.
קובץ ההגדרות של הקבועים
לפני שממשיכים, צריך לוודא שהקבועים הבאים מוגדרים בקובץ lib/constants.dart:
-
androidPackageId: מזהה החבילה שמשמש ב-Android, למשלcom.example.dashclicker -
appStoreSharedSecret: סוד משותף לגישה ל-App Store Connect כדי לבצע אימות רכישה. -
bundleId: מזהה החבילה שמשמש ב-iOS, כמוcom.example.dashclicker
אפשר להתעלם משאר הקבועים בשלב הזה.
10. אימות רכישות
תהליך האימות של רכישות דומה באופן כללי ב-iOS וב-Android.
בשתי החנויות, האפליקציה מקבלת אסימון כשמתבצעת רכישה.
האפליקציה שולחת את האסימון הזה לשירות לקצה העורפי שלכם, שמאמת את הרכישה מול השרתים של החנות הרלוונטית באמצעות האסימון שסופק.
לאחר מכן, שירות לקצה העורפי יכול לבחור אם לאחסן את הרכישה, ולהשיב לאפליקציה אם הרכישה הייתה תקפה או לא.
אם שירות לקצה העורפי מבצע את האימות מול המאגרים במקום האפליקציה שפועלת במכשיר של המשתמש, אפשר למנוע מהמשתמש לקבל גישה לתכונות פרימיום, למשל על ידי החזרת השעון של המערכת אחורה.
הגדרת הצד של Flutter
הגדרת אימות
אתם רוצים לשלוח את הרכישות לשירות הקצה העורפי, ולכן חשוב לוודא שהמשתמש מאומת בזמן הרכישה. רוב הלוגיקה של האימות כבר נוספה בשבילכם בפרויקט המתחיל, וכל מה שאתם צריכים לעשות זה לוודא שPurchasePage מציג את לחצן הכניסה כשהמשתמש עדיין לא מחובר. מוסיפים את הקוד הבא לתחילת שיטת ה-build של PurchasePage:
lib/pages/purchase_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../logic/dash_purchases.dart';
import '../logic/firebase_notifier.dart'; // Add this import
import '../model/firebase_state.dart'; // And this import
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import '../repo/iap_repo.dart';
import 'login_page.dart'; // And this one as well
class PurchasePage extends StatelessWidget {
const PurchasePage({super.key});
@override
Widget build(BuildContext context) { // Update from here
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();
} // To here.
// ...
התקשרות לנקודת הקצה של אימות השיחות מהאפליקציה
באפליקציה, יוצרים את הפונקציה _verifyPurchase(PurchaseDetails purchaseDetails) שקוראת לנקודת הקצה /verifypurchase בשרת הקצה העורפי של Dart באמצעות קריאת http post.
שולחים את החנות שנבחרה (google_play לחנות Play או app_store ל-App Store), את serverVerificationData ואת productID. השרת מחזיר קוד סטטוס שמציין אם הרכישה אומתה.
בקבועי האפליקציה, מגדירים את כתובת ה-IP של השרת לכתובת ה-IP של המחשב המקומי.
lib/logic/dash_purchases.dart
import 'dart:async';
import 'dart:convert'; // Add this import
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; // And this import
import 'package:in_app_purchase/in_app_purchase.dart';
import '../constants.dart';
import '../main.dart';
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';
import 'firebase_notifier.dart'; // And this one
class DashPurchases extends ChangeNotifier {
DashCounter counter;
FirebaseNotifier firebaseNotifier; // Add this line
StoreState storeState = StoreState.loading;
late StreamSubscription<List<PurchaseDetails>> _subscription;
List<PurchasableProduct> products = [];
bool get beautifiedDash => _beautifiedDashUpgrade;
bool _beautifiedDashUpgrade = false;
final iapConnection = IAPConnection.instance;
DashPurchases(this.counter, this.firebaseNotifier) { // Update this line
final purchaseUpdated = iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
loadPurchases();
}
הוספת firebaseNotifier עם היצירה של DashPurchases ב-main.dart:
lib/main.dart
ChangeNotifierProvider<DashPurchases>(
create: (context) => DashPurchases(
context.read<DashCounter>(),
context.read<FirebaseNotifier>(),
),
lazy: false,
),
מוסיפים getter למשתמש ב-FirebaseNotifier, כדי שאפשר יהיה להעביר את מזהה המשתמש לפונקציית אימות הרכישה.
lib/logic/firebase_notifier.dart
Future<FirebaseFirestore> get firestore async {
var isInitialized = await _isInitialized.future;
if (!isInitialized) {
throw Exception('Firebase is not initialized');
}
return FirebaseFirestore.instance;
}
User? get user => FirebaseAuth.instance.currentUser; // Add this line
Future<void> load() async {
// ...
מוסיפים את הפונקציה _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;
}
}
מתקשרים לפונקציה _verifyPurchase ב-_handlePurchase ממש לפני שמחילים את הרכישה. צריך להחיל את הרכישה רק אחרי שהיא מאומתת. באפליקציה שנמצאת בסביבת הייצור, אפשר לציין את זה בצורה מפורטת יותר, למשל כדי להחיל מינוי לתקופת ניסיון כשהחנות לא זמינה באופן זמני. עם זאת, בדוגמה הזו, הרכישה תתבצע רק אם היא תאומת בהצלחה.
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(2000);
case storeKeyUpgrade:
_beautifiedDashUpgrade = true;
}
}
}
if (purchaseDetails.pendingCompletePurchase) {
await iapConnection.completePurchase(purchaseDetails);
}
}
באפליקציה, הכול מוכן עכשיו לאימות הרכישות.
הגדרת שירות הקצה העורפי
לאחר מכן, מגדירים את ה-backend לאימות רכישות ב-backend.
יצירת רכיבי handler של רכישות
תהליך האימות בשתי החנויות כמעט זהה, ולכן כדאי להגדיר מחלקה מופשטת PurchaseHandler עם הטמעות נפרדות לכל חנות.

מתחילים בהוספת קובץ purchase_handler.dart לתיקייה lib/, שבו מגדירים מחלקה מופשטת 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:המזהה של המשתמש שמחובר לחשבון, כדי שתוכלו לקשר רכישות למשתמש.productData:נתונים על המוצר. תגדירו את זה עוד מעט.token:האסימון שסופק למשתמש על ידי החנות.
בנוסף, כדי להקל על השימוש ב-handlers האלה של רכישות, מוסיפים שיטה 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 מכילות מידע בסיסי על המוצרים השונים שאפשר לרכוש, כולל מזהה המוצר (שנקרא לפעמים גם מק"ט) ו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,
),
};
לאחר מכן, מגדירים כמה הטמעות של placeholder לחנות 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 עבור שיטות הטיפול. נגיע אליהן בהמשך.
כפי ששמתם לב, ב-constructor יש מופע של IapRepository. ה-handler של הרכישה משתמש במופע הזה כדי לאחסן מידע על רכישות ב-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,
}) async {
return true;
}
@override
Future<bool> handleSubscription({
required String userId,
required ProductData productData,
required String token,
}) async {
return true;
}
}
מצוין! עכשיו יש לכם שני רכיבי handler של רכישות. לאחר מכן, יוצרים את נקודת הקצה ל-API לאימות הרכישה.
שימוש ב-purchase handlers
פותחים את bin/server.dart ויוצרים נקודת קצה ל-API באמצעות shelf_route:
bin/server.dart
import 'dart:convert';
import 'package:firebase_backend_dart/helpers.dart';
import 'package:firebase_backend_dart/products.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.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: מזהה משתמש מחובר -
source: החנות שבה נעשה שימוש,app_storeאוgoogle_play. -
productData: מתקבל מה-productDataMapשיצרתם קודם. -
token: מכיל את נתוני האימות שצריך לשלוח לחנויות.
-
- קריאה לשיטה
verifyPurchase, ל-GooglePlayPurchaseHandlerאו ל-AppStorePurchaseHandler, בהתאם למקור. - אם האימות בוצע בהצלחה, השיטה מחזירה
Response.okללקוח. - אם האימות נכשל, השיטה מחזירה
Response.internalServerErrorללקוח.
אחרי שיוצרים את נקודת קצה ל-API, צריך להגדיר את שני רכיבי ה-handler של הרכישות. לשם כך, צריך לטעון את המפתחות של חשבון השירות שהתקבלו בשלב הקודם ולהגדיר את הגישה לשירותים השונים, כולל Android Publisher API ו-Firebase Firestore API. לאחר מכן, יוצרים את שני רכיבי ה-handler של הרכישה עם התלויות השונות:
bin/server.dart
import 'dart:convert';
import 'dart:io'; // new
import 'package:firebase_backend_dart/app_store_purchase_handler.dart'; // new
import 'package:firebase_backend_dart/google_play_purchase_handler.dart'; // new
import 'package:firebase_backend_dart/helpers.dart';
import 'package:firebase_backend_dart/iap_repository.dart'; // new
import 'package:firebase_backend_dart/products.dart';
import 'package:firebase_backend_dart/purchase_handler.dart'; // new
import 'package:googleapis/androidpublisher/v3.dart' as ap; // new
import 'package:googleapis/firestore/v1.dart' as fs; // new
import 'package:googleapis_auth/auth_io.dart' as auth; // new
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.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: הטמעה של רכיב לטיפול ברכישות
לאחר מכן, ממשיכים להטמיע את רכיב ה-handler של הרכישות ב-Google Play.
Google כבר מספקת חבילות Dart לאינטראקציה עם ממשקי ה-API שנדרשים לאימות רכישות. הגדרתם אותם בקובץ server.dart ועכשיו אתם משתמשים בהם בכיתה GooglePlayPurchaseHandler.
הטמעה של רכישות שאינן מסוג מינוי:
lib/google_play_purchase_handler.dart
/// Handle non-subscription purchases (one time purchases).
///
/// Retrieves the purchase status from Google Play and updates
/// the Firestore Database accordingly.
@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 don't 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 don't 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;
}
}
מוסיפים את השיטה הבאה כדי להקל על ניתוח מזהי ההזמנות, וגם שתי שיטות לניתוח סטטוס הרכישה.
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 אמורות להיות מאומתות עכשיו ומאוחסנות במסד הנתונים.
לאחר מכן, עוברים לרכישות ב-App Store ל-iOS.
אימות רכישות ב-iOS: הטמעה של רכיב handler לרכישות
כדי לאמת רכישות ב-App Store, יש חבילת Dart של צד שלישי בשם app_store_server_sdk שמקלה על התהליך.
מתחילים ביצירת מופע ITunesApi. כדאי להשתמש בהגדרות ארגז החול וגם להפעיל רישום ביומן כדי לאתר באגים בשגיאות.
lib/app_store_purchase_handler.dart
final _iTunesAPI = ITunesApi(
ITunesHttpClient(ITunesEnvironment.sandbox(), loggingEnabled: true),
);
מעכשיו, בניגוד ל-Google Play APIs, ב-App Store נעשה שימוש באותם נקודות קצה של API גם למינויים וגם לפריטים שאינם מינויים. המשמעות היא שאפשר להשתמש באותה לוגיקה בשני ה-handlers. ממזגים אותם כך שהם קוראים לאותו יישום:
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 {
// See next step
}
עכשיו מטמיעים את 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) {
print('Successfully verified purchase');
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 אמורות להיות מאומתות ומאוחסנות עכשיו במסד הנתונים.
הרצת ה-Backend
בשלב הזה אפשר להריץ את הפקודה dart bin/server.dart כדי להציג את נקודת הקצה /verifypurchase.
$ dart bin/server.dart Serving at http://0.0.0.0:8080
11. מעקב אחרי רכישות
הדרך המומלצת לעקוב אחרי הרכישות של המשתמשים היא בשירות לקצה העורפי. הסיבה לכך היא שהקצה העורפי יכול להגיב לאירועים מהחנות, ולכן הוא פחות מועד לשימוש במידע לא מעודכן בגלל שמירה במטמון, וגם פחות רגיש לשיבוש.
קודם צריך להגדיר את העיבוד של אירועים בחנות בקצה העורפי באמצעות הקצה העורפי של Dart שאתם בונים.
עיבוד אירועים בחנות בקצה העורפי
חנויות יכולות להודיע לשרת העורפי על אירועי חיוב שמתרחשים, כמו חידוש מינויים. אתם יכולים לעבד את האירועים האלה בקצה העורפי כדי שהרכישות במסד הנתונים שלכם יהיו עדכניות. בקטע הזה, מגדירים את האפשרות הזו גם לחנות Google Play וגם לחנות האפליקציות של Apple.
עיבוד אירועי חיוב ב-Google Play
Google Play מספקת אירועי חיוב דרך מה שהם מכנים נושא 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 מוגדר לקרוא לשיטה _pullMessageFromPubSub כל עשר שניות. אפשר לשנות את משך הזמן לפי ההעדפות שלכם.
לאחר מכן, יוצרים את _pullMessageFromPubSub
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/$googleCloudProjectId/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/$googleCloudProjectId/subscriptions/$googlePlayPubsubBillingTopic-sub';
await pubsubApi.projects.subscriptions.acknowledge(
request,
subscriptionName,
);
}
הקוד שזה עתה הוספתם מתקשר עם נושא Pub/Sub מ-Google Cloud כל עשר שניות ומבקש הודעות חדשות. לאחר מכן, המערכת מעבדת כל הודעה בשיטה _processMessage.
השיטה הזו מפענחת את ההודעות הנכנסות ומקבלת את המידע המעודכן על כל רכישה, גם מינויים וגם רכישות חד-פעמיות, וקוראת ל-handleSubscription או ל-handleNonSubscription הקיימים אם צריך.
צריך לאשר כל הודעה באמצעות השיטה _askMessage.
לאחר מכן, מוסיפים את יחסי התלות הנדרשים לקובץ server.dart. מוסיפים את PubsubApi.cloudPlatformScope להגדרות פרטי הכניסה:
bin/server.dart
import 'package:googleapis/pubsub/v1.dart' as pubsub; // Add this import
final clientGooglePlay = await auth
.clientViaServiceAccount(clientCredentialsGooglePlay, [
ap.AndroidPublisherApi.androidpublisherScope,
pubsub.PubsubApi.cloudPlatformScope, // Add this line
]);
לאחר מכן, יוצרים את המופע PubsubApi:
bin/server.dart
final pubsubApi = pubsub.PubsubApi(clientGooglePlay);
ולבסוף, מעבירים אותו אל ה-constructor GooglePlayPurchaseHandler:
bin/server.dart
return {
'google_play': GooglePlayPurchaseHandler(
androidPublisher,
iapRepository,
pubsubApi, // Add this line
),
'app_store': AppStorePurchaseHandler(
iapRepository,
),
};
הגדרת Google Play
כתבתם את הקוד לצריכת אירועים שקשורים לחיוב מנושא Pub/Sub, אבל לא יצרתם את נושא Pub/Sub ולא פרסמתם אירועים שקשורים לחיוב. הגיע הזמן להגדיר את זה.
קודם כל, יוצרים נושא Pub/Sub:
- מגדירים את הערך של
googleCloudProjectIdב-constants.dartלמזהה של הפרויקט ב-Google Cloud. - נכנסים אל הדף Cloud Pub/Sub במסוף Google Cloud.
- מוודאים שאתם נמצאים בפרויקט Firebase ולוחצים על + יצירת נושא.

- נותנים לנושא החדש שם זהה לערך שהוגדר ל-
googlePlayPubsubBillingTopicב-constants.dart. בדוגמה הזו, נותנים את השםplay_billing. אם בוחרים משהו אחר, צריך לעדכן אתconstants.dart. יוצרים את הנושא.
- ברשימת הנושאים של Pub/Sub, לוחצים על סמל שלוש הנקודות האנכיות של הנושא שיצרתם ולוחצים על View permissions (הצגת הרשאות).

- בסרגל הצד משמאל, לוחצים על הוספת ישות ראשית.
- מוסיפים כאן את
google-play-developer-notifications@system.gserviceaccount.comומעניקים לו את התפקיד פרסום הודעות ב-Pub/Sub.
- שומרים את השינויים בהרשאות.
- מעתיקים את שם הנושא של הנושא שיצרתם.
- פותחים שוב את Play Console ובוחרים את האפליקציה מהרשימה כל האפליקציות.
- גוללים למטה אל מונטיזציה > הגדרת מונטיזציה.
- ממלאים את הנושא המלא ושומרים את השינויים.

כל אירועי החיוב ב-Google Play יפורסמו עכשיו בנושא.
עיבוד אירועי חיוב בחנות האפליקציות
לאחר מכן, מבצעים את אותה הפעולה לגבי אירועי חיוב ב-App Store. יש שתי דרכים יעילות להטמיע עדכונים בטיפול ברכישות ב-App Store. אחת מהן היא הטמעה של webhook שאתם מספקים ל-Apple, והם משתמשים בו כדי לתקשר עם השרת שלכם. הדרך השנייה, שמוסברת ב-Codelab הזה, היא להתחבר ל-App Store Server API ולקבל את פרטי המינוי באופן ידני.
הסיבה לכך שה-Codelab הזה מתמקד בפתרון השני היא שצריך לחשוף את השרת לאינטרנט כדי להטמיע את ה-Webhook.
בסביבת ייצור, מומלץ שיהיו לכם את שניהם. ה-webhook לקבלת אירועים מ-App Store, וממשק Server API למקרה שפספסתם אירוע או שאתם צריכים לבדוק שוב את סטטוס המינוי.
מתחילים בפתיחת lib/app_store_purchase_handler.dart ומוסיפים את התלות AppStoreServerAPI:
lib/app_store_purchase_handler.dart
final AppStoreServerAPI appStoreServerAPI; // Add this member
AppStorePurchaseHandler(
this.iapRepository,
this.appStoreServerAPI, // And this parameter
);
משנים את ה-constructor כדי להוסיף טיימר שיבצע קריאה ל-method _pullStatus. הטיימר הזה יפעיל את ה-method _pullStatus כל 10 שניות. אפשר לשנות את משך הזמן של הטיימר בהתאם לצרכים.
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
/// Request the App Store for the latest subscription status.
/// Updates all App Store subscriptions in the database.
/// NOTE: This code only handles when a subscription expires as example.
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,
),
);
}
}
}
}
השיטה הזו פועלת כך:
- מקבלים את רשימת המינויים הפעילים מ-Firestore באמצעות IapRepository.
- לכל הזמנה, המערכת שולחת בקשה לסטטוס המינוי אל App Store Server API.
- מקבל את העסקה האחרונה עבור רכישת המינוי.
- בודק את תאריך התפוגה.
- הסטטוס של המינוי מתעדכן ב-Firestore. אם תוקף המינוי פג, הוא יסומן ככזה.
לבסוף, מוסיפים את כל הקוד הנדרש כדי להגדיר את הגישה ל-API של השרת של חנות האפליקציות:
bin/server.dart
import 'package:app_store_server_sdk/app_store_server_sdk.dart'; // Add this import
import 'package:firebase_backend_dart/constants.dart'; // And this one.
// 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, // Add this argument
),
};
הגדרה של App Store
לאחר מכן, מגדירים את App Store:
- מתחברים ל-App Store Connect ובוחרים באפשרות Users and Access (משתמשים וגישה).
- עוברים אל Integrations > Keys > In-App Purchase (שילובים > מפתחות > רכישה באפליקציה).
- מקישים על סמל הפלוס כדי להוסיף עוד אחד.
- נותנים לו שם, למשל Codelab key.
- מורידים את קובץ ה-p8 שמכיל את המפתח.
- מעתיקים אותו לתיקיית הנכסים, עם השם
SubscriptionKey.p8. - מעתיקים את מזהה המפתח מהמפתח החדש שנוצר ומגדירים אותו כקבוע
appStoreKeyIdבקובץlib/constants.dart. - מעתיקים את מזהה המנפיק (Issuer ID) שמופיע בראש רשימת המפתחות, ומגדירים אותו כקבוע
appStoreIssuerIdבקובץlib/constants.dart.

מעקב אחרי רכישות במכשיר
הדרך הכי מאובטחת לעקוב אחרי הרכישות היא בצד השרת, כי קשה לאבטח את הלקוח. עם זאת, צריך למצוא דרך להעביר את המידע בחזרה ללקוח כדי שהאפליקציה תוכל לפעול בהתאם למידע על סטטוס המינוי. אחסון הרכישות ב-Firestore מאפשר לסנכרן את הנתונים עם הלקוח ולעדכן אותם באופן אוטומטי.
כבר כללת את IAPRepo באפליקציה. זהו מאגר Firestore שמכיל את כל נתוני הרכישות של המשתמש ב-List<PastPurchase> purchases. המאגר מכיל גם את hasActiveSubscription,, שהוא true אם יש רכישה עם productId storeKeySubscription עם סטטוס שלא פג. כשהמשתמש לא מחובר לחשבון, הרשימה ריקה.
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((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 ואתם קוראים ל-notifyListeners() בכל פעם שהרכישות ב-Firestore משתנות, השיטה purchasesUpdate() נקראת תמיד כשהמוצרים שנרכשו משתנים.
lib/logic/dash_purchases.dart
import '../repo/iap_repo.dart'; // Add this import
class DashPurchases extends ChangeNotifier {
DashCounter counter;
FirebaseNotifier firebaseNotifier;
StoreState storeState = StoreState.loading;
late StreamSubscription<List<PurchaseDetails>> _subscription;
List<PurchasableProduct> products = [];
IAPRepo iapRepo; // Add this line
bool get beautifiedDash => _beautifiedDashUpgrade;
bool _beautifiedDashUpgrade = false;
final iapConnection = IAPConnection.instance;
// Add this.iapRepo as a parameter
DashPurchases(this.counter, this.firebaseNotifier, this.iapRepo) {
final purchaseUpdated = iapConnection.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
iapRepo.addListener(purchasesUpdate);
loadPurchases();
}
Future<void> loadPurchases() async {
// Elided.
}
@override
void dispose() {
_subscription.cancel();
iapRepo.removeListener(purchasesUpdate); // Add this line
super.dispose();
}
void purchasesUpdate() {
//TODO manage updates
}
לאחר מכן, מספקים את IAPRepo ל-constructor ב-main.dart. אפשר לקבל את המאגר באמצעות context.read כי הוא כבר נוצר ב-Provider.
lib/main.dart
ChangeNotifierProvider<DashPurchases>(
create: (context) => DashPurchases(
context.read<DashCounter>(),
context.read<FirebaseNotifier>(),
context.read<IAPRepo>(), // Add this line
),
lazy: false,
),
לאחר מכן, כותבים את הקוד של הפונקציה purchaseUpdate(). בשיטות dash_counter.dart, ו-applyPaidMultiplierremovePaidMultiplier, המכפיל מוגדר ל-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.