
Попробую новый формат — реализация проекта за 3 часа. #3часаКода. Что-то вроде микро-хакатона на одну конкретную тему. Надеюсь, что у меня получится пощупать много самых разных технологий и инструментов. Буду рад, если вам такой формат тоже понравится и вы найдёте что-то новое для себя.
На этой неделе будет приложение, которое делает из Telegram ленту. Первая версия была реализована на Go, но в этом проекте всё написано на Flutter. Для Go есть отличная библиотека для работы с tdlib — доступная на GitHub библиотека gotd. Но нет ничего лучше Flutter для написания интерфейсов.
Так как я сильно ограничен по времени, то приложение будет без супердизайна и будет работать только под macOS. Но зачатки приложений для iOS и Android я тоже сделаю.
Для работы с API Telegram я нашёл библиотеку tdlib-dart. Но её всё равно пришлось форкать и править под мои нужды.
Приложение Telegram
Приложение для Telegram создаётся на страничке my.telegram.org. Мне нужны значения api_id
и api_hash
, которые я буду использовать в своём приложении.
Значения будем прокидывать при запуске приложения через флаг --dart-define=
. Я пользуюсь VSCode, и мой конфиг для запуска свежесозданного Flutter-приложения выглядит так:
{
"version": "0.2.0",
"configurations": [
{
"name": "lenta",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"toolArgs": [
"--dart-define",
"API_ID=api_id",
"--dart-define",
"API_HASH=api_hash"
]
}
]
}
Этого достаточно для старта. Flutter-приложение я создал для всех платформ. Библиотеку tdlib соберу только для macOS, Android и iOS. А интерфейс реализую только для macOS.
Время не ждёт, пора компилировать!
Собираем
Собирать tdlib несложно, но иногда раздражают мелкие проблемы, с которыми сталкиваешься постоянно.
Начнём сборку для платформы iOS и macOS. Для этого клонируем репозиторий, переходим в папку example/ios и внимательно читаем README.md. Для сборки понадобится всего несколько команд:
Подготовка:
cd <path to TDLib sources>
mkdir native-build
cd native-build
cmake -DTD_GENERATE_SOURCE_FILES=ON ..
cmake --build .
Теперь нужно собрать OpenSSL специально для tdlib:
cd <path to TDLib sources>/example/ios
./build-openssl.sh
Для сборки будет использоваться пропатченый Python-Apple-support. К сожалению, скрипты из Python-Apple-support используют устаревший формат target triple для iOS-симулятора (добавляют лишний -simulator
). Поэтому, если вы получите ошибку, в которой будет указано на проблемы сборки под таргет с названием, где фигурирует -simulator-simulator
, то вам нужно вручную залезть в Python-Apple-support/Makefile и там поправить нужные таргеты. Только обратите внимание, что перед каждой сборкой в скрипте ./build-openssl.sh
заново патчится Makefile, поэтому после первого запуска и исправления Makefile закомментируйте строки в файле ./build-openssl.sh
:
# git clone https://github.com/beeware/Python-Apple-support
# cd Python-Apple-support
# git checkout 6f43aba0ddd5a9f52f39775d0141bd4363614020 || exit 1
# git reset --hard || exit 1
# git apply ../Python-Apple-support.patch || exit 1
# cd ..
Если всё прошло успешно, то теперь можно собирать XCFramework:
cd <path to TDLib sources>/example/ios
./build.sh
В результате сборки должен получиться набор библиотек в папке ios/tdjson и файл libtdjson.xcframework. Теперь нужно закинуть библиотеки в нужные папки для iOS и для macOS. Для macOS я использовал файл libtdjson.1.8.49.dylib из директории ios/tdjson/macOS/lib, закинул его во Flutter-проект в папку macos и добавил как фреймворк в настройках проекта через Xcode.
А для iOS использовал libtdjson.xcframework, который закинул в папку ios и тоже добавил как фреймворк в настройках проекта через Xcode.
Погнали собирать под Android. Тут у меня было значительно меньше проблем, всё собирается запуском нескольких скриптов:
./check-environment.sh
./fetch-sdk.sh
./build-openssl.sh
./build-tdlib.sh
Но важно обратить внимание на то, какой интерфейс библиотеки ожидается. Библиотека tdlib-dart работает с JSON-интерфейсом библиотеки tdlib. В документации есть важное упоминание:
You can also build TDLib with JSON interface instead of Java interface by passing "JSON" as the fifth parameter to the script ./build-tdlib.sh.
Необходимо указать правильный параметр при запуске скрипта ./build-tdlib.sh
. Я просто поменял значение по умолчанию на:
TDLIB_INTERFACE=${5:-JSON}
В результате сборки у меня получился набор библиотек под архитектуры arm64-v8a, armeabi-v7a, x86_64, x86. Выглядит это вот так:
Закидываем их в правильные папки: android/app/src/main/jniLibs
. Всего у вас должно получиться 4 папки под каждую архитектуру:
└── example
└── android
└── app
└── main
└── jniLibs
└── arm64-v8a
│ └── libtdjsonandroid.so
└── armeabi-v7a
│ └── libtdjsonandroid.so
└── x86
│ └── libtdjsonandroid.so
└── x86_64
└── libtdjsonandroid.so
Для библиотеки tdlib-dart важно, чтобы название каждой библиотеки было именно таким — libtdjsonandroid
. Это нужно, чтобы отличить библиотеки под Android от аналогичных библиотек под Linux.
На этом закончился первый час работы над 3-часовым проектом. Конечно, время сборки tdlib не входит в это время — она собирается ооочень долго, даже на моём не самом днищевом MacBook.
Авторизация и первые события
Всё готово к началу разработки. Как шаблон возьмём пример из библиотеки tdlib-dart.
Принцип работы tdlib довольно прост — приложение подписывается на все события, которые прилетают из библиотеки. Чтобы подключить эту библиотеку, необходимо импортировать модули:
import 'package:tdlib/td_api.dart' as td;
import 'package:tdlib/td_client.dart';
После этого мы можем инициализировать клиента и начать прослушивать поток событий от tdlib:
class _AppState extends State<MyApp> {
Client? _client;
StreamSubscription<td.TdObject>? _eventsSubscription;
void _initialize() {
if (_client != null) {
return;
}
final Client cl = Client.create();
_client = cl;
_eventsSubscription?.cancel();
_eventsSubscription = cl.updates.listen(_onNewEvent);
cl.initialize();
}
}
Делаем прилично и публикуем
Окей, у нас есть база для получения и обработки сообщений. Реализуем логику авторизации пользователя. Для этого нужно реализовать несколько экранов: ввод номера телефона, ввод проверочного кода, ввод пароля.
Начнём с использования MultiProvider
, в котором нужно инициализировать клиента tdlib и сделать его доступным для всех наших сервисов:
MultiProvider(
providers: [
Provider(create: (context) => Client.create()..initialize()),
// ...
]
)
Чтобы навигация работала как надо, будем использовать go_router
. Нужно организовать все наши экраны и передать список роутов в приложение:
class App extends StatefulWidget {
const App({super.key});
static const title = 'Лента';
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
@override
Widget build(BuildContext context) {
return MaterialApp.router(routerConfig: _router);
}
}
final _router = GoRouter(
routes: [
GoRoute(
path: '/',
// если нет авторизации, то отправляемся на логин
builder: (context, state) => HomeScreen(),
),
// login
GoRoute(path: '/phone', builder: (context, state) => PhoneScreen()),
GoRoute(path: '/code', builder: (context, state) => CodeScreen()),
GoRoute(path: '/password', builder: (context, state) => PasswordScreen()),
GoRoute(path: '/profile', builder: (context, state) => ProfileScreen()),
// messages
GoRoute(path: '/posts', builder: (context, state) => PostsScreen()),
],
);
Для организации состояния в приложении будет использоваться flutter_state_notifier
. Внутри StateNotifier
ов будет использоваться final Client client
, с помощью которого будут совершаться запросы к tdlib, и через него будем подписываться на обновления, но уже внутри стейт-нотифаеров.
Дальше реализуем базовый экран HomeScreen
— на этом экране у нас будет логика обработки событий UpdateAuthorizationState
внутри HomeStateNotifier
. Для этого подписываемся на события внутри нотифаера:
class HomeStateNotifier extends StateNotifier<HomeState> {
final Client client;
HomeStateNotifier({required this.client}) : super(HomeState(loading: false, state: null));
void subscribe() {
client.updates.listen((TdObject event) async {
if (event is UpdateAuthorizationState) {
await authorization(event.authorizationState);
}
});
}
Future<void> authorization(AuthorizationState val) async {
if (val is AuthorizationStateWaitTdlibParameters) {
await client.send(
SetTdlibParameters(
systemVersion: '',
useTestDc: false,
useSecretChats: false,
useMessageDatabase: true,
useFileDatabase: true,
useChatInfoDatabase: true,
filesDirectory: await getFilesDirectory(),
databaseDirectory: await getDatabaseDirectory(),
systemLanguageCode: 'en',
deviceModel: 'unknown',
applicationVersion: '1.0.0',
apiId: getApiId(),
apiHash: getApiHash(),
databaseEncryptionKey: '',
),
);
} else {
state = state.copyWith(state: val);
}
}
}
А в самом виджете HomeScreen
будем обрабатывать изменение состояния. Это удобно, потому что для навигации используется виджет go_router
, а для него нужен контекст виджета, и его неудобно использовать из нотифаера.
Как выглядит обработка состояния:
@override
Widget build(BuildContext context) {
final state = context.watch<HomeState>().state;
if (state is td.AuthorizationStateWaitPhoneNumber) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/phone');
});
} else if (state is td.AuthorizationStateWaitCode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/code');
});
} else if (state is td.AuthorizationStateWaitPassword) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/password');
});
} else if (state is td.AuthorizationStateReady) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/posts');
});
} else if (state is td.AuthorizationStateLoggingOut) {
// navigate to login screen
} else if (state is td.AuthorizationStateClosing) {
// navigate to login screen
} else if (state is td.AuthorizationStateClosed) {
// navigate to login screen
}
return Base(child: const Center(child: Text("Home")));
}
В зависимости от состояния, которое получили из tdlib, выполняем навигацию на нужный экран.
Первый шаг, как правило, — это ввод номера телефона. Поэтому нам нужен экран PhoneScreen
, на котором мы сможем указать номер телефона и вызвать нужный метод клиента. Вместе с экраном я создам PhoneState
и PhoneStateNotifier
и для PhoneStateNotifier
добавлю метод phone(String value)
:
Future<td.TdObject> phone(String value) async {
state = state.copyWith(phone: value);
return client.send(td.SetAuthenticationPhoneNumber(phoneNumber: value));
}
Остальные 2 экрана для указания кода и пароля создаются аналогично.
В финале авторизации мы должны получить стейт td.AuthorizationStateReady
, и после этого редиректим пользователя на экран, где уже можно будет просматривать сообщения из каналов — PostsScreen
. Также заводим PostsState
и PostsStateNotifier
. Внутри PostsStateNotifier
подписываемся на обновления tdlib:
class PostsStateNotifier extends StateNotifier<PostsState> {
final Client client;
PostsStateNotifier({
required this.client
}) : super(PostsState(loading: false, message: ""));
void subscribe() {
client.updates.listen((TdObject event) async {
if (event is UpdateNewMessage) {
if (event.message.isChannelPost) {
state = state.copyWith(message: "${event.message.toJson()}");
}
}
});
}
}
Пара объяснений:
if (event is UpdateNewMessage)
— тут мы получаем только обновления с новыми сообщениями.if (event.message.isChannelPost)
— а тут оставляем только сообщения из каналов. Личные сообщения нам не нужны.
Логика очень простая — мы выводим только последнее сообщение на экран. Сам код виджета PostsScreen
:
class PostsScreen extends StatefulWidget {
const PostsScreen({super.key});
@override
State<PostsScreen> createState() => _PostsScreenState();
}
class _PostsScreenState extends State<PostsScreen> {
@override
Widget build(BuildContext context) {
return Base(
child: SingleChildScrollView(
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(context.watch<PostsState>().message),
],
),
),
),
);
}
}
Запускаем и наслаждаемся сообщениями из каналов в чистом JSON-формате.
Завершение
Фух, это было непросто. На самом деле, чтобы написать чуть-чуть Flutter-кода, пришлось неплохо так заморочиться со сборкой библиотек и ковырянии в документации. Если захотите попробовать, то весь код и собранные библиотеки доступны у меня на Gitflic:
Если вам понравился такой формат, то дайте мне знать в комментариях или в канале t.me/Код и Капуста