إنشاء تطبيق Dart بالحزمة الكاملة باستخدام وظائف Firebase السحابية

1. مقدمة

في هذا الدرس التطبيقي حول الترميز، ستنشئ تطبيق عدّاد متعدد اللاعبين. ستتعرّف على كيفية استخدام Dart لكلّ من واجهة Flutter الأمامية والخلفية في Firebase.

ستتعرّف أيضًا على كيفية مشاركة نماذج البيانات بين تطبيقك وخادمك، ما يغنيك عن تكرار المنطق.

يمكنك الاطّلاع على رمز المصدر الكامل لهذا الدرس التطبيقي حول الترميز في مستودع نماذج "وظائف Firebase السحابية".

أهداف الدورة التعليمية

  • استخراج منطق النشاط التجاري المشترك إلى حزمة Dart مستقلة
  • كتابة وظائف Firebase السحابية ونشرها بلغة Dart
  • استفِد من ميزة التجميع المسبق (AOT) في Dart لتقليل عمليات التشغيل المتأخِّر على البارد في البيئات غير الخادمة.
  • اختبِر حزمة التطبيقات على جهازك باستخدام "مجموعة أدوات المحاكاة المحلية لـ Firebase".

2. المتطلبات الأساسية

  • حزمة تطوير البرامج (SDK) في Flutter (أحدث إصدار ثابت)
  • Firebase CLI (يجب توفّر الإصدار 15.15.0 أو إصدار أحدث).
  • أداة تعديل الرموز، مثل Antigravity أو Visual Studio Code أو IntelliJ أو استوديو Android، مع تثبيت المكوّنات الإضافية لـ Dart وFlutter
  • يجب أن تكون على دراية أساسية بمنصتَي Flutter وFirebase.

3- لماذا يجب استخدام Dart في الخلفية؟

تستخدم العديد من تطبيقات السحابة الإلكترونية Dart لواجهة المستخدم الأمامية ولغة أخرى، مثل TypeScript أو Python أو Go، للخلفية. ويتطلّب ذلك الاحتفاظ بمجموعتَين منفصلتَين من نماذج البيانات. عندما يتغيّر مخطط قاعدة البيانات، عليك تعديل كلتا قاعدتَي الرموز.

ملاحظة: يتيح لك استخدام Dart في الخلفية الجمع بين تجربة المستخدم المتجاوبة في Flutter على العميل وعملية التحقّق الآمنة على الخادم، بدون تكرار الرمز.

4. إنشاء تطبيق Flutter

أنشئ تطبيق Flutter عاديًا:

flutter create my_counter
cd my_counter
# Run the app to see the default counter example
flutter run

في تطبيق Flutter عادي، يتعامل الرمز lib/main.dart مع حالة العداد محليًا:

int _counter = 0;

void _incrementCounter() {
  setState(() {
    _counter++;
  });
}

يعمل هذا الأسلوب مع الحالة المحلية، ولكنّه لا يتناسب مع تطبيق متعدد اللاعبين يجب أن يعمل فيه الخادم كمصدر للحقيقة. لإتاحة اللعب لعدة لاعبين، سننقل هذه المنطق إلى الخلفية في الخطوات التالية.

5- إنشاء الحزمة المشترَكة

لتجنُّب تكرار النماذج في الواجهة الأمامية والخلفية، أنشِئ حزمة Dart مشترَكة داخل مستودع مشروعك. يعتمد كلّ من تطبيق Flutter ووظائف Firebase على هذه الحزمة.

من جذر مشروع my_counter، نفِّذ الأوامر التالية:

mkdir -p packages
cd packages
dart create -t package shared

إضافة حِزم معتمدة

في packages/shared/pubspec.yaml، أضِف أدوات تسلسل JSON:

dependencies:
  json_annotation: ^4.11.0

dev_dependencies:
  build_runner: ^2.13.1
  json_serializable: ^6.13.1

تحديد النماذج المشترَكة

إنشاء packages/shared/lib/src/models.dart يحدّد هذا الملف بنية البيانات التي يستخدمها كل من التطبيق والخادم.

import 'package:json_annotation/json_annotation.dart';

part 'models.g.dart';

@JsonSerializable()
class IncrementResponse {
  final bool success;
  final String? message;
  final int? newCount;

  const IncrementResponse({required this.success, this.message, this.newCount});

  factory IncrementResponse.fromJson(Map<String, dynamic> json) =>
      _$IncrementResponseFromJson(json);

  Map<String, dynamic> toJson() => _$IncrementResponseToJson(this);
}

// Store the function name as a constant to ensure consistency between client and server.
const incrementCallable = 'increment';

في packages/shared/lib/shared.dart، صدِّر نماذجك باتّباع الخطوات التالية:

library shared;

export 'src/models.dart';

في الدليل packages/shared، شغِّل أداة إنشاء الإصدار لإنشاء رمز تسلسل JSON:

dart run build_runner build

6. إعداد وظائف Firebase السحابية

‫Cloud Functions for Firebase هو إطار عمل بدون خادم يتيح لك تشغيل الرموز البرمجية للخلفية تلقائيًا بدون الحاجة إلى إدارة خوادمك وتوسيع نطاقها. تُعدّ Dart خيارًا مناسبًا لأنّها تُحوّل الرمز البرمجي إلى رمز ثنائي مسبقًا (AOT)، ولا تتطلّب بيئة وقت تشغيل كبيرة مثل Node.js أو Java. يؤدي ذلك إلى تقليل أوقات التشغيل على البارد لوظائفك بشكل كبير.

انتقِل إلى جذر مشروعك وابدأ وظائف Firebase السحابية:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • عندما يُطلب منك اختيار اللغة، انقر على Dart.

في functions/pubspec.yaml، أضِف مسارًا نسبيًا إلى الحزمة المشترَكة:

dependencies:
  firebase_admin_sdk: ^0.5.6
  firebase_functions: ^0.8.0
  google_cloud_firestore: ^0.5.5
  shared:
    path: ../packages/shared

7. كتابة الدالة

لكتابة منطق الخلفية، افتح functions/bin/server.dart واستبدِل المحتوى بالرمز التالي:

import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart'
    show FieldValue;
import 'package:shared/shared.dart';

void main() {
  runFunctions((firebase) {

    // Listen for calls to the http request and name defined in the shared package.
    firebase.https.onRequest(name: incrementCallable, (request) async {

      // In a production app, verify the user with request.auth?.uid here.
      print('Incrementing counter on the server...');

      // Get firestore database instance
      final firestore = firebase.adminApp.firestore();

      // Get a reference to the counter document
      final counterDoc = firestore.collection('counters').doc('global');

      // Get the current snapshot for the count data
      final snapshot = await counterDoc.get();

      // Increment response we will send back
      IncrementResponse incrementResponse;

      // Check for the current count and if the snapshot exists
      if (snapshot.data() case {'count': int value} when snapshot.exists) {
        if (request.method == 'GET') {
          // Get the current result
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Read-only sync complete',
            newCount: value,
          );
        } else if (request.method == 'POST') {
          // Increment count by one
          final step =
              int.tryParse(request.url.queryParameters['step'] ?? '') ?? 1;
          await counterDoc.update({'count': FieldValue.increment(step)});
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Atomic increment complete',
            newCount: value + step,
          );
        } else {
          throw HttpResponseException.badRequest(
            message: 'only GET and POST requests are allowed',
          );
        }
      } else {
        // Create a new document with a count of 1
        await counterDoc.set({'count': 1});
        incrementResponse = const IncrementResponse(
          success: true,
          message: 'Cloud-sync complete',
          newCount: 1,
        );
      }

      // Return the response as JSON
      return Response(
        200,
        body: jsonEncode(incrementResponse.toJson()),
        headers: {'Content-Type': 'application/json'},
      );
    });

  });
}

8. الاختبار محليًا باستخدام "مجموعة أدوات المحاكاة المحلية لـ Firebase"

يمكنك تشغيل كلّ من الواجهة الأمامية والخلفية على الجهاز بدون نشر.

من جذر مشروعك، ابدأ تشغيل مجموعة أدوات محاكاة Firebase:

# Enable functions and firestore for the emulators
firebase init emulators
# Start the emulators and optionally open up the Admin UI
firebase emulators:start

في pubspec.yaml، أضِف مسارًا نسبيًا إلى الحزمة المشترَكة وأضِف حزمة http:

dependencies:
  http: ^1.6.0
  shared:
    path: packages/shared

في مشروع Flutter، افتح ملف lib/main.dart واستبدِل محتواه بالرمز التالي. يستخدم رمز الواجهة الأمامية فئة IncrementResponse نفسها المستخدَمة في الواجهة الخلفية.

import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';

/// Get from emulator output when running or when deploying:
/// ✔ functions[us-central1-increment]: http function initialized
///  (http://127.0.0.1:5001/demo-no-project/us-central1/increment).
const incrementUrl = 'FIREBASE_FUNCTIONS_URL_HERE';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
    home: const CounterPage(),
  );
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});
  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    // Fetch the current count
    _increment(readOnly: true).ignore();
  }

  Future<void> _increment({bool readOnly = false}) async {
    setState(() => _loading = true);
    try {
       // Call the Dart function.
      final uri = Uri.parse(incrementUrl);
      final response = readOnly ? await http.get(uri) : await http.post(uri);

      // Parse the response back into the shared Dart object.
      final responseData = jsonDecode(response.body);
      final incrementResponse = IncrementResponse.fromJson(responseData);

      if (incrementResponse.success) {
        setState(() => _count = incrementResponse.newCount ?? _count);
      }
    } catch (e) {
      print("Error calling function: $e");
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Multiplayer Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('You have pushed the button this many times:'),
            Text(
              '$_count',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _loading ? null : _increment,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

شغِّل تطبيق Flutter. عند النقر على زر الإجراء الرئيسي، يستدعي التطبيق الخلفية المحلية لـ Dart، ويسترد العدد الجديد، ويعدّل واجهة المستخدم.

9. النشر على Firebase

في هذا الدرس التطبيقي حول الترميز، تمكّنت من تجربة الوظائف بدون مشروع Firebase أو حساب فوترة باستخدام "مجموعة أدوات المحاكاة المحلية لـ Firebase". إذا كنت تريد استخدام الوظائف في بيئة التشغيل الفعلي (مثل بيئة الإنتاج)، فعليك إعداد مشروع Firebase وإعداد الفوترة.

إنشاء مشروع Firebase

  1. سجِّل الدخول إلى وحدة تحكّم Firebase باستخدام حساب Google.
  2. انقر على الزر لإنشاء مشروع جديد، ثم أدخِل اسم المشروع.
  3. انقر على متابعة.
  4. إذا طُلب منك ذلك، راجِع بنود Firebase واقبلها، ثمّ انقر على متابعة.
  5. (اختياري) فعِّل ميزة "الدعم المستند إلى الذكاء الاصطناعي" في "وحدة تحكّم Firebase" (المعروفة باسم "Gemini في Firebase").
  6. في هذا الدرس العملي، لا تحتاج إلى "إحصاءات Google"، لذا أوقِف خيار "إحصاءات Google".
  7. انقر على إنشاء مشروع، وانتظِر إلى أن يتم توفير مشروعك، ثم انقر على متابعة.

ترقية خطة أسعار Firebase

لاستخدام خدمات Firebase في هذا الدرس العملي، يجب أن يكون مشروعك على Firebase ضمن خطة التسعير "الدفع حسب الاستخدام" (Blaze)، ما يعني أنّه مرتبط بحساب فوترة على Cloud.

لترقية مشروعك إلى خطة Blaze، اتّبِع الخطوات التالية:

  1. في "وحدة تحكّم Firebase"، اختَر ترقية خطتك.
  2. اختَر خطة Blaze. اتّبِع التعليمات الظاهرة على الشاشة لربط حساب فوترة على Cloud بمشروعك.
    • إذا كنت تستخدم أرصدة Google Cloud لهذا الدرس التطبيقي، من المحتمل أن يكون اسم حساب الفوترة هو Google Cloud Platform Trial Billing Account أو My Billing Account.
    • إذا كان عليك إنشاء حساب فوترة في Cloud كجزء من عملية الترقية هذه، قد تحتاج إلى الرجوع إلى مسار الترقية في وحدة تحكّم Firebase لإكمال عملية الترقية.

النشر في مشروع Firebase

لنشر الخلفية المستندة إلى Dart، نفِّذ الأمر التالي باستخدام Firebase CLI:

firebase use <PROJECT_ID>
firebase deploy --only functions

بعد تنفيذ الأمر، انسخ عنوان URL واستبدِل FIREBASE_FUNCTIONS_URL_HERE في الرمز المصدر لتطبيق Flutter الذي أضفناه سابقًا.

10. تحديد المشاكل وحلّها

firebase: command not found

تأكَّد من تثبيت Firebase CLI وتحديث PATH. يمكنك تثبيته باستخدام npm: npm install -g firebase-tools.

عدم توفّر Dart في نماذج دوال init

لكي تظهر Dart كقائمة خيارات يمكن استخدامها ونشرها وإنشاء رمز النموذج عند تنفيذ firebase init functions، يجب ضبط علامة تجريبية من خلال تنفيذ firebase experiments:enable dartfunctions.

يتعذّر ربط محاكي الدوال

تأكَّد من استخدام localhost والمنفذ 5001. إذا كنت تجري الاختبار على "محاكي Android"، لن يتمكن الجهاز من حلّ localhost إلى جهازك المضيف. عدِّل إعدادات المحاكي في main.dart لاستخدام 10.0.2.2.

لم يتم العثور على الحزمة المشترَكة

تحقَّق من المسار النسبي في functions/pubspec.yaml. إذا كان هيكل المجلد يختلف عن codelab، عدِّل path: ../packages/shared للإشارة إلى الدليل الصحيح.

هل يجب استخدام json_serializable؟

على الرغم من أنّ استخدام json_serializable ليس مطلوبًا بشكل صارم، إلا أنّه يمنع حدوث أخطاء ناتجة عن كتابة طريقتَي fromJson وtoJson يدويًا. ويضمن أن يتوقّع كلّ من الواجهة الأمامية والخلفية تنسيق البيانات نفسه تمامًا.

11. تهانينا

لقد أنشأت تطبيق Dart متكاملاً بنجاح. من خلال الاحتفاظ بنماذج البيانات في حزمة مشترَكة، يمكنك التأكّد من بقاء استجابات واجهة برمجة التطبيقات وواجهة المستخدم للعميل متزامنتَين، وذلك باستخدام لغة برمجة واحدة في جميع حزماتك.

للاطّلاع على الرمز الكامل لهذا المشروع، يمكنك الرجوع إلى نموذج الدرس التطبيقي حول الترميز في مستودع firebase/functions-samples.