В данной статье речь пойдет о реализации мобильного клиента Flutter.
Какого именно мобильного клиента?
В предыдущей публикации описана система программных аксессуаров:
bobaoskit — аксессуары, dnssd и WebSocket.
Аналог программного аксессуара — реальный объект. Лампочка, переключатель, cd/кассетный проигрыватель, радио плеер, термостат, датчик температуры, датчик движения и т.д… Набор аксессуаров определяется фантазией и программным кодом. Можно реализовать хоть шахматную доску. Для такой доски надо иметь поле управления(control
) move
, принимающее объект { from: "e2", to: "e4" }
для примера и сервисные поля для сброса фигур и т.д… Скрипт аксессуара обработает запрос на управление полем move
, примет решение можно ли перемещать фигуру, и вернет(или нет) статус с положением фигур на всем поле.
На текущий момент поддерживаемые типы аксессуаров с минимальным функционалом следующие: "switch", "temperature sensor", "thermostat", "radio player".
Про шахматы далее речи не пойдет. Если интересно и в таком случае, добро пожаловать под кат.
Итак, bobaoskit.worker
запущен. Объекты аксессуаров существуют в памяти компьютера, можно прочитать информацию о них, можно вручную отправить JSON
запрос на WebSocket
порт, видеть входящие события.
Для управления я сделал простейшее мобильное приложение.
Почему Flutter?
Последнюю пару лет в голове активно жила идея изучить программирование под мобильные устройства. Поскольку пишу на JavaScript
, изучал решения, позволяющие не изучать новый язык программирования.
Appcelerator
. Начинал изучение с него. Если не изменяет память, SDK открыто, а вот IDE с различными тарифами.
NativeScript
. Тут уже создал простое приложение, показывающее список с картинками. Дальше дело не пошло.
ReactNative
. Самый длинный штурм из перечисленных до этого момента фреймворков. Самая большая сложность — начать. Посмотрел курс. Поначалу понятно, интересно, получается. А вот redux
и после осилить не удалось. Потом регулярно пытался начать писать, но redux
упорно не давал себя осилить.
В итоге ни к какому решению я тогда(конец 2016го года) я не привязался. Возможно, потому что не было конкретной задачи, возможно по другим причинам.
Ближе к осени прошлого(2018го) уже шла работа над sdk для программных аксессуаров. Естественно, нужно мобильное приложение. Все началось с mdns. Как-то в свободную минуту обновил ReactNative, нашел плагин react-native-zeroconf, создал приложение. По инструкции установил, сделал link
, запустил. Запустилось приложение Expo для отладки, которое не поддерживает нативные модули, и, соответственно, плагин для mdns не работал. К этому моменту свободного времени не хватало на то, чтобы создать чистое(без expo) react-native приложение и протестировать с ним. Работа была отложена на пару месяцев.
В это же время все больше и больше материалов появлялось про flutter
в сети. Установил себе. Установка проста: git clone
и добавить в PATH
. Остальное — уже настройка Android SDK/Xcode(в моем случае Android SDK уже давно был настроен. Для iOS разрабатывать не могу, поскольку не являюсь пользователем macOS) и Dart SDK(можно установить отдельно, но не обязательно, поскольку есть в составе flutter).
Принцип/схема работы
- При запуске приложение ищет сервисы
_bobaoskit._tcp
в локальной сети с помощью плагина flutter_mdns. Есть несколько версий этого плагина, все берут корни от опубликованного, но он не совместим с новыми версиями Dart SDK, соответственно, многие форкнули и добавили совместимость. Я выбрал именно эту версию, поскольку другие не определяли(resolve) хосты сразу нескольких обнаруженных сервисов.
При обнаружении и определении(onResolve), хост добавляется в список.
Страница со списком обнаруженных сервисов —StatefulWidget
, соответственно, при обнаружении/потере сервисов вызываетсяsetState() {...}
- При выборе хоста из списка создается новая страница(также
StatefulWidget
), которой передаетсяhost
иport
выбранного сервиса.
Создается объектBobaosKit
, ответственный за коммуникации. Ответы обрабатываются посредством коллбеков, т.к. пока асинхронный dart я сильно не изучал. Но, судя по просмотренной документации,Futures
— аналогPromise
-ов в JS.
Регистрируются функции для входящий событий(не ответов). Здесь искалEventEmitter
для Dart. Написал очень простой свой.
void registerListener(String name, Function cb) {
this._events.add(new BobaosKitCallback(name, cb));
}
void removeAllListeners() {
this._events = [];
}
void emitEvent(String name, dynamic params) {
// call all listeners
List<BobaosKitCallback> foundCallbacks =
this._events.where((t) => t.name == name).toList();
foundCallbacks.forEach((f) => f.cb(params));
}
...
...
void listen() {
this.ws.listen((text) {
var json = jsonDecode(text);
if (json.containsKey('response_id')) {
....
} else {
// без поля response_id - событие
this.emitEvent(json['method'], json['payload']);
}
});
}
Входящие события — если аксессуар был удален, добавлен, обновил статус. Либо если все аксессуары удалены(clear accessories
).
Регистрируемые функции — для обновления списков, виджетов по этим событиям.
- Отправляется запрос на получение информации о всех аксессуарах.
Для каждого аксессуара создается объект AccessoryInfo
:
import 'package:scoped_model/scoped_model.dart';
// AccessoryInfo extends Model
// so, when accessory value is updated it descends down to
// all widgets inside ScopedModelDescendant
class AccessoryInfo extends Model {
dynamic id;
dynamic type;
String name;
String job_channel;
List control;
List status;
bool selected;
Map<dynamic, dynamic> currentState;
AccessoryInfo(Map<dynamic, dynamic> obj) {
this.id = obj['id'];
this.type = obj['type'];
this.name = obj['name'];
this.job_channel = obj['job_channel'];
this.control = obj['control'];
this.status = obj['status'];
this.currentState = {};
}
void updateCurrentState(key, value) {
currentState[key] = value;
notifyListeners();
}
void notify() {
notifyListeners();
}
}
этот объект уже — модель. Изначально я писал везде StatefulWidget
и setState() {}
, но setState() {}
работает только для виджета, внутри которого регистрировались слушатели. Но для детального управления аксессуаром я создавал изначально новые Stateful
страницы, и заметил, что статус не обновляется. Как решение — использовал ScopedModel
.
После того как список аксессуаров получен, для каждого из них отправляем запрос на получение состояния и добавляем в список List <AccessoryInfo>
. Вызываем setState() {}
, таким образом добавляя поддерживаемый аксессуар в интерфейс. Поддерживаемые типы аксессуаров определены в ListView.builder
и в ./lib/widgets/*.dart
. Пока поддерживаются switch/temperature sensor/radio player/thermostat
. Основная работа впереди — добавлять новые, улучшать существующие виджеты.
- Теперь о том как создаются отдельные элементы для каждого аксессуара. Рассмотрим для примера переключатель(switch).
@override
Widget build(BuildContext context) {
return new ScopedModel<AccessoryInfo>(
model: info,
child: ScopedModelDescendant<AccessoryInfo>(
builder: (context, child, model) {
var cardColor = Theme.of(context).cardColor;
dynamic switchState = model.currentState['state'];
if (switchState is bool) {
if (switchState) {
cardColor = Colors.deepPurple;
} else {
cardColor = Theme.of(context).cardColor;
}
}
return Card(
color: cardColor,
child: ListTile(
selected: false,
leading: new Icon(Icons.lightbulb_outline),
title: new Text("${model.name}"),
onTap: () {
// to control accessory value
// get status value at first
bobaos.getStatusValue(
model.id, "state", (bool err, Object payload) {
if (err) {
return print('error ocurred $payload');
}
if (payload is Map) {
dynamic currentValue = payload['status']['value'];
bool newValue;
if (currentValue is bool) {
// invert
newValue = !currentValue;
} else {
newValue = false;
}
// then send new value
bobaos.controlAccessoryValue(
model.id, {"state": newValue}, (bool err, Object payload) {
if (err) {
return print('error ocurred $payload');
}
});
}
});
},
onLongPress: () {
// TODO: dialog with additional funcs
},
));
}));
}
Для аксессуара типа switch
создается элемент в общем списке аксессуаров, при взаимодействии с которым(onTap) отправляется запрос на получение текущего значения, затем на переключение этого значения. ScopedModel
позволяет перерисовать виджет при входящих обновлениях статуса.
Для данного аксессуара не реализован обработчик длинного нажатия.
Для радио проигрывателя оно выглядит так:
onLongPress: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AccRadioPlayerControl(
info: info,
bobaos: bobaos,
)));
},
Открывается страница AccRadioPlayerControl
, которая также использует ScopedModel
для управления состоянием.
На этом весь описание алгоритма работы программы исчерпывается. Никаких дополнительных возможностей, таких как запоминание последнего хоста, сортировки аксессуаров по категориям/комнатам не реализовано. На текущий момент все просто.
Проблемы
Опишу основную проблему, которая есть сейчас. Так и не понял как обнаружить разрыв WebSocket
соединения.
Использую: WebSocket class.
При длительном нахождении приложения во сне/устройства в заблокированном режиме соединение разрывается. Приходится возвращаться на самую первую страницу и заново открывать обнаруженный сервис.
Послесловие
С одной стороны, Flutter довольно быстр в изучении и разработке. ScopedModel для меня оказалось понятнее redux.
Dart оказался похожим на привычный JavaScript. Типизация + динамичные типы позволят каждому писать как удобно.
Сложности при написании кода: большая вложенность виджетов. На всем известный callback hell после flutter-а смотришь уже по другому. Vim-mode и %
будут полезны.
Теперь немного мыслей насчет IoT. В последнее время все больше умных устройств/сервисов, которые требуют регистрации в облаке. Китайские розетки, для пользования которыми необходимо установить приложение, создать аккаунт, и лишь после этого можно пользоваться.
Голосовые помощники. Алиса от Яндекса требует своего облака в которое отправляется распознанный текст. Alexa от Amazon работает похожим образом.
Удачнее всего, по моему мнению, сделан Apple HomeKit в связке с Siri. Облако используется для распознавания текста. Взаимодействие с устройствами — в локальной сети.
Мое мнение состоит в том, что облако должно существовать для своей цели: удаленное управление, обновление, и т.д… Если устройством можно управлять в локальной сети, то надо так и делать.
Ссылки
- Репозиторий приложения
- Документация по bobaoskit — описано как установить bobaoskit.worker и запустить аксессуар
radio player
.