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

1. مقدمة

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

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

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

  • استخراج منطق النشاط التجاري المشترك إلى حزمة Dart مستقلة
  • كتابة وظائف Cloud Functions for 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.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

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

إنشاء 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 السحابية

‫وظائف 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_functions:
  google_cloud_firestore:
  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(List<String> args) async {
  await fireUp(args, (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 = request.url.queryParameters['step'] as int? ?? 1;
          await counterDoc.update({'count': FieldValue.increment(step)});
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Atomic increment complete',
            newCount: value + step,
          );
        } else {
          throw FailedPreconditionError(
            '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. إذا كان هيكل المجلد يختلف عن هيكل الدرس التطبيقي حول الترميز، عدِّل path: ../packages/shared للإشارة إلى الدليل الصحيح.

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

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

11. تهانينا

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