1. مقدمة
في هذا الدرس التطبيقي حول الترميز، ستنشئ تطبيق عدّاد متعدد اللاعبين. وستتعرّف على كيفية استخدام Dart لكلّ من واجهة Flutter الأمامية والخلفية في 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.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
لنشر الخلفية المستندة إلى Dart، شغِّل الأمر التالي:
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 متكاملاً بنجاح. من خلال الاحتفاظ بنماذج البيانات في حزمة مشترَكة، يمكنك التأكّد من أنّ ردود واجهة برمجة التطبيقات وواجهة المستخدم للعميل تظل متزامنة، وذلك باستخدام لغة برمجة واحدة في جميع أقسام التطبيق.