Как создать полнофункциональное приложение Dart с помощью Cloud Functions for Firebase

1. Введение

В этой практической работе вы создадите многопользовательское приложение-счетчик. Вы научитесь использовать Dart как для интерфейса Flutter, так и для серверной части Firebase.

Вы также узнаете, как обмениваться моделями данных между приложением и сервером, чтобы не дублировать логику.

Полный исходный код этой практической работы можно найти в репозитории примеров Cloud Functions for Firebase.

Что вы узнаете

  • Вынесите общую бизнес-логику в отдельный пакет Dart.
  • Пишите и развертывайте Cloud Functions for Firebase на языке Dart.
  • Используйте компиляцию Ahead-of-Time (AOT) в Dart, чтобы сократить время холодных запусков бессерверных функций.
  • Протестируйте стек локально с помощью Firebase Emulator Suite.

2. Требования

  • Flutter SDK (последняя стабильная версия).
  • Интерфейс командной строки Firebase (требуется версия 15.15.0 или более поздняя).
  • Редактор кода, например Antigravity, Visual Studio Code, IntelliJ или Android Studio, с установленными плагинами 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. Как настроить Cloud Functions for Firebase

Cloud Functions для Firebase – это бессерверная платформа, которая позволяет автоматически запускать код на стороне сервера без необходимости управлять собственными серверами и масштабировать их. Dart отлично подходит для этой задачи, поскольку он компилируется в двоичный код заранее (AOT) и не требует сложной среды выполнения, такой как Node.js или Java. Это значительно сокращает время холодного запуска функций.

Перейдите в корневой каталог проекта и инициализируйте Cloud Functions for 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 Emulator Suite

Вы можете запустить и интерфейс, и серверную часть локально, не развертывая их.

В корневом каталоге проекта запустите Firebase Emulator Suite:

# 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

В этой практической работе вы смогли протестировать Functions без проекта Firebase и платежного аккаунта, используя Firebase Local Emulator Suite. Если вы хотите использовать функции в реальной среде (например, в рабочей среде), вам нужно настроить проект Firebase и оплату.

Создать проект Firebase

  1. Войдите в консоль Firebase, используя аккаунт Google.
  2. Нажмите кнопку, чтобы создать новый проект, а затем введите его название.
  3. Нажмите Продолжить.
  4. Если появится запрос, ознакомьтесь с условиями использования Firebase и примите их, а затем нажмите Продолжить.
  5. (Необязательно.) Включите помощь от ИИ в консоли Firebase (Gemini в Firebase).
  6. Для этой практической работы 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:

firebase use <PROJECT_ID>
firebase deploy --only functions

После выполнения команды скопируйте URL и замените им FIREBASE_FUNCTIONS_URL_HERE в исходном коде приложения Flutter, который мы добавили ранее.

10. Устранение неполадок

firebase: command not found

Убедитесь, что интерфейс командной строки Firebase установлен и ваш файл PATH обновлен. Установить его можно с помощью npm: npm install -g firebase-tools.

В шаблонах функций инициализации отсутствует код Dart

Чтобы 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. Храня модели данных в общем пакете, вы обеспечиваете синхронизацию ответов API и клиентского интерфейса, используя один язык программирования для всего стека.

Чтобы посмотреть полный код этого проекта, ознакомьтесь с образцом практической работы в репозитории firebase/functions-samples.