PDM · DAW 2

Aula 28 · Semana 12 · Segunda, 23 de novembro de 2026 · 16:00 às 18:00

Platform channels, plugins e API v2

Teoria: platform channels, plugins e FFI; critérios para avaliar um package no pub.dev; versionamento de APIs e alterações incompatíveis. Prática: MethodChannel em Dart, Kotlin e Swift para ler o nível da bateria, e migração da app para a API v2 do curso.

Repo: pdm-aula-28v1 da API desliga a 09/12

No fim desta aula consegues

MethodChannel e EventChannel

platform channel «pdm/bateria» Dart MethodChannel('pdm/bateria') .invokeMethod('nivel') devolve um Future<int>: 87 sempre assíncrono Codec StandardMessageCodec pedido: nome + argumentos resposta: valor ou erro null, bool, int, double, String, List, Map, bytes Android · Kotlin BatteryManager → result.success(87) iOS · Swift UIDevice.batteryLevel → result(87) Seta de destaque: pedido invokeMethod('nivel'). Seta cinzenta: resposta, que chega ao Dart num Future. Sem handler nativo (web, desktop) invokeMethod lança MissingPluginException EventChannel: stream do nativo para o Dart receiveBroadcastStream().listen · sensores, posição
Pedido-resposta assíncrono entre o Dart e o código nativo, com os dados serializados em primitivos, listas e mapas.

Para os testes: MethodChannel é pedido-resposta assíncrono; EventChannel é um stream nativo → Dart; os dados passam como primitivos, listas e mapas.

Analogia Um platform channel é um intérprete entre duas pessoas que não falam a mesma língua: o Dart de um lado, o Kotlin ou o Swift do outro. As mensagens vão em pequenos pacotes normalizados (o codec), uma de cada vez, e a resposta demora, por isso chega num Future. Um plugin é um intérprete que alguém já contratou e partilhou no pub.dev.

FFI: chamar C diretamente

Para os testes: FFI chama C diretamente, sem serialização; platform channels trocam mensagens com Kotlin e Swift. FFI para bibliotecas C e desempenho; channels para APIs do sistema.

Plugins federados e platform views

Para os testes: plugin federado = interface + uma implementação por plataforma; platform view = widget nativo dentro da árvore Flutter, mais caro do que um widget Dart.

Avaliar um package no pub.dev

CritérioOnde verSinal de alarme
Pub points (até 160)Cartão do package: análise, docs, plataformas, null safetyAbaixo de 120, ou sem null safety
PopularidadeDownloads por semana e likesPoucos utilizadores e nenhuma alternativa conhecida
ManutençãoData da última versão; issues e PRs no GitHubMais de um ano sem release, dezenas de issues sem resposta
Quem publicaPublisher verificado, "Flutter Favorite"Nome sem site nem organização
LicençaSeparador LicenseGPL (contamina a app) ou sem licença

Para os testes: pub points, popularidade, data da última versão, issues abertas, publisher e licença; um package sem manutenção é uma dívida que vais pagar.

APIs mudam: versionar e migrar sem partir

Backend (API) v1 em produção GET /v1/forecast um único contrato v2 publicada, v1 ainda responde GET /v2/forecast v1 com headers Deprecation e Sunset: 09/12 v1 desligada 410 Gone só a v2 responde v2 sai 09/12: fim do prazo da v1 tempo App (Flutter) Só v1 fromJsonV1 a app que está na loja Feature flag: v1 e v2 lado a lado fromJsonV1 · fromJsonV2 testes com amostras reais das duas versões Só v2 forced update (aula 30) para quem ficou na v1 As apps instaladas não atualizam quando queres Há quem fique meses numa versão: o backend serve v1 e v2 durante o prazo e a app ignora campos novos.
Duas versões em paralelo durante o prazo de deprecação; a app migra com uma feature flag e só depois a v1 desliga.

Para os testes: versionar por URL, deprecar com prazo, e nunca assumir que todos os utilizadores estão na versão nova da app.

Analogia Versionar uma API é mudar as regras de um formulário público. Não se altera o formulário antigo debaixo dos pés de quem ainda o usa: publica-se o novo ao lado (/v2/), anuncia-se um prazo para o antigo (Sunset) e só depois se deixa de o aceitar (410 Gone).

Prática: bateria por MethodChannel e migração para a v2

git clone https://github.com/pdm-daw-2026/pdm-aula-28.git
cd pdm-aula-28/starter
flutter pub get
flutter run

O starter/ tem o Tempo Açores na v1, um ecrã de definições com espaço para a bateria, e TODOs numerados de 1 a 8. Em test/ há testes a falhar: bateria_service_test.dart (com o canal simulado) e previsao_v2_test.dart. O solucao/ tem o estado final nas duas plataformas e com a flag.

Quatro blocos: o canal em Dart, Kotlin e Swift; as plataformas sem canal; os modelos v2; a flag para correr v1 e v2 lado a lado.

Kotlin e Swift Exigem flutter run de novo depois de cada alteração: o hot reload só recarrega Dart. Quem não tem Mac faz só o Android; o Swift fica para ler.

1. O lado Dart do canal

import 'package:flutter/services.dart';

class BateriaService {
  BateriaService({MethodChannel? canal})
      : _canal = canal ?? const MethodChannel('pdm/bateria');
  final MethodChannel _canal;

  /// Nível da bateria em percentagem, de 0 a 100.
  Future<int> nivel() async {
    final valor = await _canal.invokeMethod<int>('nivel');
    if (valor == null) throw StateError('A plataforma não devolveu o nível');
    return valor;
  }
}

// No ecrã de definições
FutureBuilder<int>(
  future: context.read<BateriaService>().nivel(),
  builder: (context, snapshot) {
    if (snapshot.hasError) return const Text('Bateria indisponível');
    if (!snapshot.hasData) return const SizedBox.shrink();
    return Text('Bateria: ${snapshot.data} %');
  },
)

1. O lado Android, em Kotlin

package pt.uac.tempoacores

import android.content.Context
import android.os.BatteryManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "pdm/bateria")
            .setMethodCallHandler { call, result ->
                if (call.method != "nivel") { result.notImplemented(); return@setMethodCallHandler }
                val bm = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
                val nivel = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
                if (nivel == Integer.MIN_VALUE) {
                    result.error("INDISPONIVEL", "Nível de bateria não disponível", null)
                } else {
                    result.success(nivel)
                }
            }
    }
}

1. O lado iOS, em Swift

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller = window?.rootViewController as! FlutterViewController
    let canal = FlutterMethodChannel(name: "pdm/bateria", binaryMessenger: controller.binaryMessenger)
    canal.setMethodCallHandler { call, result in
      guard call.method == "nivel" else { result(FlutterMethodNotImplemented); return }
      let device = UIDevice.current
      device.isBatteryMonitoringEnabled = true
      if device.batteryState == .unknown {
        result(FlutterError(code: "INDISPONIVEL", message: "Nível de bateria não disponível", details: nil))
      } else {
        result(Int(device.batteryLevel * 100))
      }
    }
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

1. O canal: o que interessa

Exercício 1 TODOs 1 a 3: os três lados do canal e a linha "Bateria: N %" no ecrã de definições. No simulador iOS aparece "indisponível" (o batteryState é unknown; é normal); no telemóvel aparece o valor. Corre flutter test test/bateria_service_test.dart.

2. Onde o canal não existe: web, desktop, simulador

import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;

  /// null quando a plataforma não tem canal ou não sabe o nível.
  Future<int?> nivelSeguro() async {
    if (kIsWeb) return null;                     // na web não há código nativo
    try {
      return await _canal.invokeMethod<int>('nivel');
    } on MissingPluginException {
      return null;                               // desktop sem handler, ou nome errado
    } on PlatformException catch (e) {
      debugPrint('Bateria: ${e.code} ${e.message}');
      return null;                               // INDISPONIVEL no simulador iOS
    }
  }
Exercício 2 TODO 4: usa nivelSeguro() no ecrã e corre flutter run -d chrome: a app arranca e a linha da bateria desaparece. Teste: com o handler simulado a lançar PlatformException(code: 'INDISPONIVEL'), nivelSeguro() devolve null.

3. API v2: o que mudou

v1 (/v1/forecast/{id})v2 (/v2/forecast/{id})Tipo de mudança
locationIdlocation_idRenomeado: toda a v2 usa snake_case
tMin, tMax soltostemperature: {min, max}Estrutura: as temperaturas passam a um objeto
forecastDate: "2026-11-23"date: "2026-11-23T00:00:00-01:00"ISO 8601 completo, com hora e fuso dos Açores
weatherType, precipProbweather, precipitation_probabilityRenomeados
—issued_atNovo, opcional: quando o IPMA emitiu a previsão

3. Um elemento de forecast, nas duas versões

{
  "locationId": "3420300",
  "forecastDate": "2026-11-23",
  "tMin": 14.2,
  "tMax": 19.8,
  "weatherType": "rain",
  "precipProb": 65
}
{
  "location_id": "3420300",
  "date": "2026-11-23T00:00:00-01:00",
  "temperature": { "min": 14.2, "max": 19.8 },
  "weather": "rain",
  "precipitation_probability": 65,
  "issued_at": "2026-11-23T06:12:00-01:00"
}

Os dois excertos estão em test/amostras/ no starter, copiados da API real. É contra eles que os testes correm.

3. Duas factories no mesmo modelo

class Previsao {
  // campos e construtor como na aula 25

  factory Previsao.fromJsonV1(Map<String, dynamic> json) => Previsao(
        localidadeId: json['locationId'] as String,
        dia: DateTime.parse(json['forecastDate'] as String),
        tMin: (json['tMin'] as num).toDouble(),
        tMax: (json['tMax'] as num).toDouble(),
        tipo: TipoTempo.fromApi(json['weatherType'] as String),
        probPrecipitacao: (json['precipProb'] as num?)?.toDouble(),
      );

  factory Previsao.fromJsonV2(Map<String, dynamic> json) {
    final temp = json['temperature'] as Map<String, dynamic>?;
    if (temp == null) throw const FormatException('v2: falta o objeto "temperature"');
    return Previsao(
      localidadeId: json['location_id'] as String,
      dia: DateTime.parse(json['date'] as String).toLocal(),
      tMin: (temp['min'] as num).toDouble(),
      tMax: (temp['max'] as num).toDouble(),
      tipo: TipoTempo.fromApi(json['weather'] as String),
      probPrecipitacao: (json['precipitation_probability'] as num?)?.toDouble(),
    );
  }
}

3. Os testes da v2

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:tempo_acores/models/previsao.dart';

const amostraV2 = '''
{"location_id": "3420300", "date": "2026-11-23T00:00:00-01:00",
 "temperature": {"min": 14.2, "max": 19.8}, "weather": "rain",
 "precipitation_probability": 65}
''';

void main() {
  final p = Previsao.fromJsonV2(jsonDecode(amostraV2) as Map<String, dynamic>);

  test('lê as temperaturas do objeto temperature', () {
    expect(p.tMin, 14.2);
    expect(p.tMax, 19.8);
    expect(p.amplitude, closeTo(5.6, 0.001));
  });

  test('converte a data ISO com fuso para hora local', () {
    expect(p.dia.isUtc, isFalse);
    expect(p.dia.toUtc(), DateTime.utc(2026, 11, 23, 1));   // 00:00 nos Açores é 01:00 UTC
  });

  test('lança FormatException se faltar temperature', () {
    expect(() => Previsao.fromJsonV2({'location_id': '1', 'date': '2026-11-23T00:00:00Z', 'weather': 'sun'}),
        throwsFormatException);
  });
}
Exercício 3 TODOs 5 e 6: renomeia o fromJson atual para fromJsonV1 (os testes da aula 25 mudam só o nome), escreve o fromJsonV2, e faz o mesmo para Aviso. Testes verdes nas duas versões.

4. v1 e v2 lado a lado, com uma feature flag

enum ApiVersao { v1, v2 }

class ApiConfig {
  /// flutter run --dart-define=API_V2=true liga a v2; por defeito fica na v1.
  static const versao = bool.fromEnvironment('API_V2') ? ApiVersao.v2 : ApiVersao.v1;
}

class PrevisaoRepositoryHttp implements PrevisaoRepository {
  PrevisaoRepositoryHttp(this._client, {this.versao = ApiConfig.versao});
  final ApiClient _client;
  final ApiVersao versao;

  @override
  Future<List<Previsao>> previsao(String localidadeId) async {
    final (caminho, parse) = switch (versao) {
      ApiVersao.v1 => ('/v1/forecast/$localidadeId', Previsao.fromJsonV1),
      ApiVersao.v2 => ('/v2/forecast/$localidadeId', Previsao.fromJsonV2),
    };
    final json = await _client.getJson(caminho);
    final itens = json['forecast'] as List<dynamic>;
    return itens.map((e) => parse(e as Map<String, dynamic>)).toList();
  }
}

4. A migração: o que interessa

Para os testes: migrar uma API é acrescentar a versão nova ao lado da antiga, trocar por flag e só depois remover; nunca substituir num só passo.

Exercício 4 TODOs 7 e 8: a flag em ApiConfig, o repository a escolher caminho e parser, e o mesmo no AvisosRepository. Corre a app com e sem --dart-define=API_V2=true e mostra "API v1" ou "API v2" no ecrã de definições. Teste: com versao: ApiVersao.v2, o repository pede /v2/forecast/3420300 (mock do ApiClient com mocktail e verify).

Até amanhã

Para fazer

  • Projeto migrado para a API v2: modelos com fromJsonV1 e fromJsonV2, repositories com a flag, testes verdes nas duas versões e CI verde.
  • Build de teste com API_V2=true distribuído aos testers do grupo antes de ligar a v2 por defeito.
  • Quem não distribuiu na sexta a três testers fora do grupo, fá-lo hoje: é requisito da sprint 3.

Para ler