Aula 28 · Semana 12 · Segunda, 23 de novembro de 2026 · 16:00 às 18:00
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.
MethodChannel e EventChannel, e que tipos passam por eles.MethodChannel em Kotlin e Swift, e tratar as plataformas onde não existe.pdm/bateria) entre o Dart e o código nativo: Kotlin ou Java no Android, Swift ou Objective-C no iOS. As mensagens são serializadas pelo StandardMessageCodec.MethodChannel é pedido-resposta: o Dart chama invokeMethod('nivel'), o nativo responde com result.success(valor) ou result.error(...). Sempre assíncrono.EventChannel é um stream do nativo para o Dart: mudanças de bateria, sensores, posição. O nativo emite quando quiser; o Dart faz receiveBroadcastStream().listen.null, bool, int, double, String, Uint8List e outras listas tipadas, List, Map. Objetos não passam: serializa em Map.Para os testes: MethodChannel é pedido-resposta assíncrono; EventChannel é um stream nativo → Dart; os dados passam como primitivos, listas e mapas.
Future. Um plugin é um intérprete que alguém já contratou e partilhou no pub.dev.dart:ffi chama funções C sem canal nem serialização: o Dart carrega uma biblioteca (.so, .dylib) e chama a função com a assinatura declarada. Microssegundos em vez de milissegundos.ffigen gera as ligações Dart a partir de um header C; jnigen faz o mesmo para APIs Java e Kotlin do Android; para Swift há swift2objc, ainda experimental.flutter_rust_bridge).malloc e free do package:ffi, tipos C, compilar a biblioteca para cada arquitetura, e nada de hot reload na parte nativa.sqlite3 usa FFI e é mais rápido do que o sqflite, que usa um MethodChannel.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.
x_platform_interface), uma implementação por plataforma (x_android, x_ios, x_web) e o package que tu importas (x). Cada implementação regista-se sozinha.url_launcher, camera e shared_preferences estão feitos.flutter create --template=plugin --platforms=android,ios gera o esqueleto; o pubspec.yaml declara flutter: plugin: platforms: com a classe nativa de cada plataforma.google_maps_flutter e webview_flutter são platform views; o flutter_map da aula 21 não é: desenha os tiles em Dart.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.
| Critério | Onde ver | Sinal de alarme |
|---|---|---|
| Pub points (até 160) | Cartão do package: análise, docs, plataformas, null safety | Abaixo de 120, ou sem null safety |
| Popularidade | Downloads por semana e likes | Poucos utilizadores e nenhuma alternativa conhecida |
| Manutenção | Data da última versão; issues e PRs no GitHub | Mais de um ano sem release, dezenas de issues sem resposta |
| Quem publica | Publisher verificado, "Flutter Favorite" | Nome sem site nem organização |
| Licença | Separador License | GPL (contamina a app) ou sem licença |
flutter pub deps mostra o que o package traz consigo. Cada uma é uma manutenção que herdas.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.
/v1/, /v2/) é o mais comum em mobile: visível, cacheável, fácil de testar. Alternativas: header Accept-Version ou query ?v=2.Deprecation e Sunset. No curso: v1 até 09/12, depois 410 Gone.fromJsonV1, fromJsonV2), feature flag no repository, testes com amostras reais das duas versões, forced update quando a v1 desligar (aula 30).Para os testes: versionar por URL, deprecar com prazo, e nunca assumir que todos os utilizadores estão na versão nova da app.
/v2/), anuncia-se um prazo para o antigo (Sunset) e só depois se deixa de o aceitar (410 Gone).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.
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.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} %');
},
)
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)
}
}
}
}
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)
}
}
pdm/bateria nos três lados. Um erro de escrita dá MissingPluginException em runtime, não erro de compilação.invokeMethod<int> faz o cast: o Int do Kotlin e o Int do Swift chegam como int em Dart. Um Long também, porque o int do Dart tem 64 bits.result.error(code, message, details) chega ao Dart como PlatformException. Códigos em maiúsculas e estáveis, para o Dart poder fazer switch.MethodChannel por nome; o handler é o último registado. Regista-o no configureFlutterEngine e no didFinishLaunchingWithOptions, nunca num widget.TestDefaultBinaryMessenger.setMockMethodCallHandler responde ao canal com um valor fixo. O starter já traz esse teste.batteryState é unknown; é normal); no telemóvel aparece o valor. Corre flutter test test/bateria_service_test.dart.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
}
}
MissingPluginException: não há handler para o canal nesta plataforma. Não é um erro do utilizador; é uma funcionalidade que aqui não existe.PlatformException: o nativo respondeu com erro. code para decidir, message para os logs.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.v1 (/v1/forecast/{id}) | v2 (/v2/forecast/{id}) | Tipo de mudança |
|---|---|---|
locationId | location_id | Renomeado: toda a v2 usa snake_case |
tMin, tMax soltos | temperature: {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, precipProb | weather, precipitation_probability | Renomeados |
| — | issued_at | Novo, opcional: quando o IPMA emitiu a previsão |
X-Platform e X-App-Version não mudam. Os avisos mudam ao mesmo estilo: level passa a severity e as datas a ISO 8601.Deprecation: true e Sunset; depois, 410 Gone. O changelog completo está na página API.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.
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(),
);
}
}
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);
});
}
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.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();
}
}
--dart-define é uma flag de build: o CI corre os testes contra as duas. Uma flag de Remote Config (aula 26) troca sem novo build; a lógica é a mesma, muda a origem do valor.switch sobre o enum é exaustivo: quando aparecer uma ApiVersao.v3, o compilador obriga-te a tratá-la.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.
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).fromJsonV1 e fromJsonV2, repositories com a flag, testes verdes nas duas versões e CI verde.API_V2=true distribuído aos testers do grupo antes de ligar a v2 por defeito.MethodChannel é precisamente a bateria, em Kotlin, Java, Swift e Objective-C.ffigen e gestão manual de memória.