Tạo một ứng dụng Dart full-stack bằng Cloud Functions cho Firebase

1. Giới thiệu

Trong lớp học lập trình này, bạn sẽ tạo một ứng dụng bộ đếm nhiều người chơi. Bạn sẽ tìm hiểu cách sử dụng Dart cho cả giao diện người dùng Flutter và phần phụ trợ Firebase.

Bạn cũng sẽ tìm hiểu cách chia sẻ các mô hình dữ liệu giữa ứng dụng và máy chủ của mình, nhờ đó không cần phải sao chép logic.

Kiến thức bạn sẽ học được

  • Trích xuất logic nghiệp vụ dùng chung vào một gói Dart độc lập.
  • Viết và triển khai Cloud Functions cho Firebase một cách tự nhiên bằng Dart.
  • Tận dụng tính năng biên dịch Trước khi thực thi (AOT) của Dart để giảm số lượt khởi động nguội không máy chủ.
  • Kiểm thử ngăn xếp của bạn trên thiết bị bằng Bộ công cụ mô phỏng Firebase.

2. Điều kiện tiên quyết

  • Flutter SDK (phiên bản ổn định mới nhất).
  • Giao diện dòng lệnh (CLI) của Firebase (bắt buộc phải là phiên bản 15.15.0 trở lên).
  • Một trình soạn thảo mã, chẳng hạn như Antigravity, Visual Studio Code, IntelliJ hoặc Android Studio, đã cài đặt các trình bổ trợ Dart và Flutter.
  • Hiểu biết cơ bản về Flutter và Firebase.

3. Tại sao nên sử dụng Dart cho phần phụ trợ?

Nhiều ứng dụng đám mây sử dụng Dart cho giao diện người dùng và một ngôn ngữ khác, chẳng hạn như TypeScript, Python hoặc Go, cho phần phụ trợ. Điều này đòi hỏi bạn phải duy trì 2 tập hợp mô hình dữ liệu riêng biệt. Khi giản đồ cơ sở dữ liệu thay đổi, bạn phải cập nhật cả hai cơ sở mã.

Lưu ý: Việc sử dụng Dart trên phần phụ trợ cho phép bạn kết hợp trải nghiệm người dùng thích ứng của Flutter trên ứng dụng với quy trình xác thực an toàn trên máy chủ mà không cần sao chép mã.

4. Tạo ứng dụng Flutter

Tạo một ứng dụng Flutter tiêu chuẩn:

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

Trong một ứng dụng Flutter tiêu chuẩn, lib/main.dart xử lý trạng thái bộ đếm cục bộ:

int _counter = 0;

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

Phương pháp này phù hợp với trạng thái cục bộ, nhưng không mở rộng được cho ứng dụng nhiều người chơi, trong đó máy chủ phải đóng vai trò là nguồn đáng tin cậy. Để hỗ trợ nhiều người chơi, chúng ta sẽ chuyển logic này sang phần phụ trợ trong các bước sau.

5. Tạo gói dùng chung

Để tránh trùng lặp các mô hình trên giao diện người dùng và phụ trợ, hãy tạo một gói Dart dùng chung bên trong kho lưu trữ dự án của bạn. Cả ứng dụng Flutter và các hàm cho Firebase đều phụ thuộc vào gói này.

Từ gốc của dự án my_counter, hãy chạy các lệnh sau:

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

Thêm phần phụ thuộc

Trong packages/shared/pubspec.yaml, hãy thêm các công cụ chuyển đổi tuần tự JSON:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

Xác định các mô hình được chia sẻ

Tạo packages/shared/lib/src/models.dart. Tệp này xác định cấu trúc dữ liệu mà cả ứng dụng và máy chủ đều sử dụng.

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';

Trong packages/shared/lib/shared.dart, hãy xuất các mô hình của bạn:

library shared;

export 'src/models.dart';

Trong thư mục packages/shared, hãy chạy trình chạy bản dựng để tạo mã chuyển đổi tuần tự JSON:

dart run build_runner build

6. Thiết lập Cloud Functions cho Firebase

Cloud Functions cho Firebase là một khung không máy chủ, cho phép bạn tự động chạy mã phụ trợ mà không cần quản lý và mở rộng quy mô máy chủ của riêng mình. Dart là một lựa chọn phù hợp vì nó biên dịch Trước thời hạn (AOT) thành một tệp nhị phân, không yêu cầu môi trường thời gian chạy nặng như Node.js hoặc Java. Điều này giúp giảm đáng kể thời gian khởi động nguội cho các hàm của bạn.

Chuyển đến thư mục gốc của dự án rồi khởi động Cloud Functions cho Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • Khi được nhắc chọn ngôn ngữ, hãy chọn Dart.

Trong functions/pubspec.yaml, hãy thêm đường dẫn tương đối vào gói dùng chung:

dependencies:
  firebase_functions:
  google_cloud_firestore:
  shared:
    path: ../packages/shared

7. Viết hàm

Để viết logic phụ trợ, hãy mở functions/bin/server.dart rồi thay thế nội dung bằng đoạn mã sau:

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. Kiểm thử cục bộ bằng Bộ công cụ mô phỏng của Firebase

Bạn có thể chạy cả giao diện người dùng và phần phụ trợ cục bộ mà không cần triển khai.

Từ thư mục gốc của dự án, hãy khởi động Bộ công cụ mô phỏng Firebase:

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

Trong pubspec.yaml, hãy thêm một đường dẫn tương đối vào gói dùng chung và thêm gói http:

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

Trong dự án Flutter, hãy mở lib/main.dart rồi thay thế nội dung của tệp này bằng đoạn mã sau. Đoạn mã giao diện người dùng này sử dụng cùng một lớp IncrementResponse như phần phụ trợ.

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),
      ),
    );
  }
}

Chạy ứng dụng Flutter. Khi bạn nhấp vào nút hành động nổi, ứng dụng sẽ gọi phần phụ trợ Dart cục bộ, truy xuất số lượt truy cập mới và cập nhật giao diện người dùng.

9. Triển khai lên Firebase

Để triển khai phần phụ trợ Dart, hãy chạy lệnh sau:

firebase deploy --only functions

Sau khi chạy lệnh, hãy sao chép URL và thay thế FIREBASE_FUNCTIONS_URL_HERE trong mã nguồn ứng dụng Flutter mà chúng ta đã thêm trước đó.

10. Khắc phục sự cố

firebase: command not found

Đảm bảo bạn đã cài đặt Firebase CLI và PATH đã được cập nhật. Bạn có thể cài đặt bằng npm: npm install -g firebase-tools.

Thiếu Dart trong mẫu hàm init

Để Dart xuất hiện dưới dạng danh sách các lựa chọn triển khai và tạo mã mẫu khi chạy firebase init functions, bạn cần đặt cờ thử nghiệm bằng cách chạy firebase experiments:enable dartfunctions.

Trình mô phỏng hàm không kết nối

Xác minh rằng bạn đang sử dụng localhost và cổng 5001. Nếu bạn đang kiểm thử trên Trình mô phỏng Android, thì thiết bị sẽ không phân giải localhost thành máy chủ của bạn. Cập nhật cấu hình trình mô phỏng trong main.dart để sử dụng 10.0.2.2.

Không tìm thấy gói dùng chung

Xác minh đường dẫn tương đối trong functions/pubspec.yaml. Nếu cấu trúc thư mục của bạn khác với cấu trúc trong lớp học lập trình, hãy điều chỉnh path: ../packages/shared để trỏ đến thư mục chính xác.

Tôi có cần dùng json_serializable không?

Mặc dù không bắt buộc, nhưng việc sử dụng json_serializable sẽ ngăn chặn các lỗi do viết phương thức fromJsontoJson theo cách thủ công. Điều này đảm bảo giao diện người dùng và phần phụ trợ của bạn có cùng định dạng dữ liệu.

11. Xin chúc mừng

Bạn đã tạo thành công một ứng dụng Dart full-stack. Bằng cách duy trì các mô hình dữ liệu trong một gói dùng chung, bạn đảm bảo rằng các phản hồi API và giao diện người dùng của ứng dụng luôn được đồng bộ hoá, bằng cách sử dụng một ngôn ngữ lập trình duy nhất trên toàn bộ ngăn xếp.