
Привет, Хабр! Это Юрий Волковский, техлид фронтенда в компании Friflex. Я работаю, в том числе, с мобильными приложениями на React Native. Разработка под HarmonyOS сразу заинтриговала меня тем, что ArkTS — это как бы TypeScript, но не совсем. И сам ArkUI сочетает в себе элементы и из React Native, и из Flutter, который мне тоже знаком.
Я решил: создам на Harmony OS базовое мобильное приложение и посмотрю, насколько дружелюбна эта платформа. Если вы мобильный разработчик (особенно с опытом Android или Flutter) и тоже хотите разобраться, что это за платформа и как с ней работать — вы по адресу. Создадим базовый плейлист по шагам.
Что такое HarmonyOS?
HarmonyOS — это операционная система, разработанная Huawei в ответ на санкции США, которые отрезали компанию от Google Mobile Services. Первая версия вышла в 2019 году, а уже в 2021 вышла HarmonyOS 2.0 с поддержкой смартфонов.
Сначала Huawei позиционировала HarmonyOS как единую экосистему для смартофнов, планшетов, умных телевизоров и часов, автомобильных систем и IoT-гаджетов. В отличие от Android, HarmonyOS изначально задумывалась как микроядерная ОС с акцентом на безопасность, низкие задержки и бесшовное взаимодействие между устройствами.
К 2023 году HarmonyOS стала третьей по популярности мобильной ОС в Китае. В отдельные кварталы она обгоняла iOS по доле рынка. В 2024 году Huawei анонсировала HarmonyOS Next — версию ОС, которая полностью отказывается от совместимости с APK. Это значит, что разработчикам нужно будет создавать нативные приложения под HarmonyOS NEXT, чтобы они работали в экосистеме Huawei.
На чем писать
ArkTS — это основной язык разработки под HarmonyOS, созданный на базе TypeScript (который, в свою очередь, расширяет JavaScript). Но это не просто TypeScript с другим названием — Huawei добавила в него специфичные для HarmonyOS фичи, оптимизированные под производительность и декларативную разработку UI, а также отключила некоторый функционал, который либо считается плохой практикой, либо может ухудшить скорость работы приложения. Полный список отключенных функций можно посмотреть здесь, но вот несколько примеров:
Запрещены типы any и unknown. Необходимо явно указать, какие типы должны быть у переменной или у поля. Это сделано в целях усиленной типизации и уменьшения ошибок.
Запрещено использовать var, можно использовать только let и const. var давно считается плохой практикой в JavaScript, однако в ArkTS он запрещен на синтаксическом уровне.
Отсутствие структурной типизации в целях производительности. Это означает, что если мы объявим два идентичных класса A и B, и соответственно объявим две переменные let a = new A() и let b = new B(), присвоения по типу a = b или b = a будут вызывать ошибку компилятора, потому как компилятор не может сравнить два типа и решить, идентичны они или нет.
Подготовка к работе
В этой статье покажу, как разработать базовый музыкальный плейлист с двумя экранами — экраном приветствия и экраном со списком треков, которые можно воспроизводить. Наша задача — создать очень простое приложение. Оно поможет понять, как вообще разрабатывать на ArkTS под HarmonyOS NEXT.
Перед тем как начать работу, убедитесь что у вас есть:
операционная система: Windows 10/11, или macOS (предпочтительны чипы Apple Silicon);
DevEco Studio версии не менее 5.0.0. DevEco Studio — это официальная IDE от Huawei для разработки под HarmonyOS. Она поддерживает ArkTS/JS/C++ и предлагает встроенные эмуляторы смартфонов, а также превьюеры для разных устройств с операционной системой HarmonyOS (например, для умных часов).
Без DevEco Studio вы не сможете начать работу — при ее установке также устанавливаются нужные SDK, ArkCompiler, эмуляторы, дополнительные инструменты, CLI и зависимости. Чтобы DevEco Studio корректно работал, регион установки должен быть определен как Китай (определяется из файла /Users/username/Library/Application Support/Huawei/DevEcoStudio5.0/options/country.region.xml), иначе некоторые компоненты могут не установиться.
Финальный вид нашего приложения:

Начало работы
Для начала создадим новый проект в DevEco Studio. В окне Welcome to DevEco Studio выберем Create Project, оставим шаблон Empty Ability по умолчанию, нажмем Next, выберем имя проекта и имя бандла, остальные настройки оставим по умолчанию. Нажимаем Finish, и получаем новый проект.

Чтобы разрабатывать приложение, нам понадобится эмулятор. Открываем это меню и выбираем Device Manager:

В открывшемся меню выбираем + New Emulator, все настройки оставляем по умолчанию, жмем Next, Next и Finish. После этого мы сможем выбрать в этом меню наш эмулятор и с помощью кнопки «Запустить» — установить на нем наше приложение, чтобы его проверять. Hot Module Reloading довольно ограниченный, поэтому когда мы будем вносить изменения, будем перезапускать установку приложения.
Файлы и папки
В проекте у нас будет следующая структура файлов:
├── AppScope/
├── entry/
│ ├── build/
│ ├── src/
│ │ ├── main/
│ │ ├── ets/
│ │ │ ├── entryability/
│ │ │ │ └── EntryAbility.ets
│ │ │ ├── entrybackupability/
│ │ │ │ └── EntryBackupAbility.ets
│ │ │ └── pages/
│ │ │ └── Index.ets
│ │ ├── resources/
│ │ │ ├── base/
│ │ │ │ ├── element/
│ │ │ │ │ ├── color.json
│ │ │ │ │ └── string.json
│ │ │ │ ├── media/
│ │ │ │ └── profile/
│ │ │ ├── dark/
│ │ │ ├── en_US/
│ │ │ ├── rawfile/
│ │ │ ├── zh_CN/
│ │ │ └── module.json5
│ │ ├── mock/
│ │ ├── ohosTest/
│ │ └── test/
│ ├── .gitignore
│ ├── build-profile.json5
│ ├── hvigorfile.ts
│ ├── obfuscation-rules.txt
│ ├── oh-package.json5
│ └── patch.json
├── hvigor/
├── .gitignore
├── build-profile.json5
├── code-linter.json5
├── hvigorfile.ts
├── local.properties
├── oh-package.json5
└── oh-package-lock.json5
Файлы ArkTS имеют расширение .ets, остальные же файлы в исходной структуре проекта являются конфигурационными:
entry/src/main/ets/entryability/EntryAbility.ets. Это входная точка нашего приложения, в которой настраивается внутреннее окно мобильного приложения и глобальные настройки окна приложения.
entry/src/main/ets/pages/Index.ets. Это экран приложения, на данный момент единственный. Он открывается при запуске МП, именно здесь мы пишем компоненты для UI.
entry/src/main/resources/{base|en_US|zh_CN}/element/string.json. Здесь для трех языков можно объявлять строковые значения, необходимые, например, для указания, зачем нам нужно то или иное разрешение, а также название и описание нашего приложения.
entry/src/main/resources/profile/. Здесь мы будем хранить наши конфигурационные файлы, например, route_map.json для подключения других экранов.
entry/src/main/resources/media/. Здесь мы можем добавлять наши медиафайлы, например, SVG и растровые картинки.
entry/src/main/module.json5. Это файл с конфигурацией нашего приложения, здесь мы можем настроить разрешения, подключить навигацию, настроить иконку, название и многое другое.
Подключение навигации и запросов по сети
Для начала настроим рекомендуемый Huawei новый способ навигации между экранами. В entry/src/main/module.json5 подключим разрешение на использование интернета, а также router_map:
Подключаем разрешение на использование интернета
{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:module_desc",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone",
"tablet",
"2in1"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"action.system.home"
]
}
]
}
],
"extensionAbilities": [
{
"name": "EntryBackupAbility",
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
"type": "backup",
"exported": false,
"metadata": [
{
"name": "ohos.extension.backup",
"resource": "$profile:backup_config"
}
],
}
],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:internet_permission_usage_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
],
"routerMap": "$profile:route_map",
}
}
Здесь мы добавили два новых поля: requestPermissions и routerMap. requestPermissions позволяет нам добавить необходимые разрешения для приложения, а routerMap — подключает файл со списком вторичных экранов. Чтобы использовать $string:internet_permission_usage_reason, добавим в entry/src/main/resources/base/element.string.json новую запись:
{
"string": [
{
"name": "module_desc",
"value": "module description"
},
{
"name": "EntryAbility_desc",
"value": "description"
},
{
"name": "EntryAbility_label",
"value": "label"
},
{
"name": "internet_permission_usage_reason",
"value": "Internet is required for correct functioning of the app"
}
]
}
И аналогично добавим строку выше для en_US и zh_CN. Затем создадим новый файл, который мы указали в routerMap: entry/src/main/resources/base/profile/route_map.json:
{
"routerMap": [
]
}
Пока что оставим файл без дополнительных страниц, мы добавим их чуть позже. Но сначала на примере файла module.json5 рассмотрим, как работает получение ресурсов в конфигурациях. Ссылки на ресурсы имеют следующий формат: $тип_ресурса:имя_ресурса. Примеры из module.json5:
"description": "$string:module_desc",
"label": "$string:EntryAbility_label"
Эти значения берутся из файла resources/base/element/string.json.
"icon": "$media:layered_image",
"startWindowIcon": "$media:startIcon"
Эти изображения берутся из resources/base/media/.
"startWindowBackground": "$color:start_window_background"
Этот цвет берется из файла в resources/base/element/color.json.
"pages": "$profile:main_pages",
"metadata": {"resource": "$profile:backup_config"}
Ссылаются на JSON-файлы в resources/base/profile/.
Это является важной частью работы с ArkTS и его конфигурациями, потому как далее мы сможем подключать такие ресурсы непосредственно из кода в файлах ArkTS, через функцию $r.
Настройка светлой/темной темы
Стартовый пользовательский интерфейс, сгенерированный по умолчанию, имеет лишь текст Hello World по центру экрана. Его код выглядит следующим образом:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize(50)
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
}
.height('100%')
.width('100%')
}
}
Мы можем видеть, что в файлах .ets (расширение языка ArkTS) у нас есть несколько особых элементов синтаксиса. Давайте разберем их по порядку:
Код @Entry и @Component — это декораторы, которые определяют структуру компонента. @Entry указывает, что Index — это корневой компонент приложения, а @Component помечает класс как переиспользуемый UI-компонент.
Поле message объявлено с декоратором @State, что делает его реактивным — при изменении значения интерфейс автоматически обновится.
Метод build — это обязательный метод, возвращающий UI-структуру. Здесь используется RelativeContainer для позиционирования дочерних элементов (например, Text) относительно контейнера или других элементов. Синтаксис данного метода отличается, здесь определяются компоненты, и в фигурных скобках для компонента передаются дочерние компоненты, а также настраиваются стили компонента (например, .height(“100%”).width(“100%”)).
Синтаксис ArkTS почти полностью совпадает с синтаксисом TypeScript, поэтому чтобы чуть лучше понять данный язык программирования, рекомендую (если не было до этого опыта с этим языком), почитать введения из документации Huawei в ArkTS и ArkUI.
ArkUI является достаточно объемным, поэтому можно изучить полностью лишь секцию Learning ArkTS, а вот по ArkUI посмотреть только первые несколько подсекций — этого хватит для начала работы.
Для начала в нашем приложении будет две темы: светлая и темная. Чтобы не перенастраивать системные цвета вручную, мы воспользуемся интерфейсом CustomTheme из ArkUI (почитать о нем можно здесь). Для тех, кто уже работал с веб-фреймворками, React Native или Flutter, наш следующий подход будет уже знакомым.
Мы создадим контекст, который будет хранить две реактивных переменных: текущее значение выбранной темы (светлая/темная, значения из enum), а также саму тему. Она будет объектом с перечислением названий цветов и их значений для заданной темы, и с помощью Provide будет передавать эти значения дочерним компонентам вне зависимости от их уровня вложенности.
Для начала определим сами темы в файле entry/src/main/ets/feature/shared/theme.ets:
Определяем темы
import { CustomColors, CustomTheme } from '@kit.ArkUI'
export class AppDarkColors implements CustomColors {
backgroundPrimary: ResourceColor = '#121212';
backgroundSecondary: ResourceColor = '#1e1e1e';
brand: ResourceColor = '#7b2cbf';
fontPrimary: ResourceColor = '#ffffff';
fontSecondary: ResourceColor = '#b3b3b3';
fontOnTertiary: ResourceColor = '#282828';
}
export class AppLightColors implements CustomColors {
backgroundPrimary: ResourceColor = '#f5f5f5';
backgroundSecondary: ResourceColor = '#ffffff';
brand: ResourceColor = '#7b2cbf';
fontPrimary: ResourceColor = '#121212';
fontSecondary: ResourceColor = '#4a4a4a';
fontOnTertiary: ResourceColor = '#f0f0f0';
export class AppDarkTheme implements CustomTheme {
public colors: AppDarkColors = new AppDarkColors()
}
export class AppLightTheme implements CustomTheme {
public colors: AppLightColors = new AppLightColors()
}
export enum SelectedTheme {
Light = "Light",
Dark = "Dark",
}
export const themesRecord: Record<SelectedTheme, CustomTheme> = {
[SelectedTheme.Light]: new AppLightTheme(),
[SelectedTheme.Dark]: new AppDarkTheme(),
};
export const oppositeThemes: Record<SelectedTheme, SelectedTheme> = {
[SelectedTheme.Light]: SelectedTheme.Dark,
[SelectedTheme.Dark]: SelectedTheme.Light,
};
Здесь мы в наших классах реализовали интерфейс CustomColors, который содержит названия всех системных цветов из ArkUI, и интерфейс CustomTheme. Затем, чтобы было удобно работать с несколькими темами и можно было из них выбирать, мы создали enum и два Record. Они позволяют нам быстро найти противоположную тему и выбрать сам набор цветов в зависимости от темы.
Далее реализуем контекст, который будет передавать нам эти цвета (entry/src/main/ets/feature/context/ThemeContext/ThemeContext.ets):
Реализуем контекст
import { window } from "@kit.ArkUI";
import { SelectedTheme, themesRecord } from "../../shared/theme";
@Component
export struct ThemeContext {
@BuilderParam content: () => void
@StorageLink('SelectedTheme') @Watch('updateTheme') _selectedTheme: SelectedTheme = SelectedTheme.Light;
@Provide activeTheme: CustomTheme = themesRecord[this._selectedTheme];
aboutToAppear(): void {
this._updateStatusBarColors();
}
updateTheme(_propName: string): void {
this.activeTheme = themesRecord[this._selectedTheme];
this._updateStatusBarColors();
}
private _updateStatusBarColors() {
const windowClass = AppStorage.get<window.Window>('windowClass') as window.Window;
windowClass.setWindowSystemBarProperties({
statusBarColor: this.activeTheme.colors?.backgroundPrimary?.toString(),
statusBarContentColor: this.activeTheme.colors?.fontPrimary?.toString(),
});
}
build() {
WithTheme({ theme: this.activeTheme }) {
this.content()
}
}
}
Здесь остановимся чуть подробнее. Этот компонент представляет собой контекст для управления темой приложения. Тема хранится в AppStorage под ключом SelectedTheme. При ее изменении компонент обновляет активную тему, а также синхронизирует цвета системной панели статуса (status bar) с текущей темой. Основная логика работы заключается в отслеживании изменений выбранной темы через декоратор @Watch. Когда тема меняется, компонент обновляет activeTheme, передавая новую тему в дочерние системные компоненты через WithTheme, а через @Provide передает ее пользовательским дочерним компонентам.
В ArkUI @StorageLink связывает свойство с AppStorage — глобальным хранилищем, сохраняющим данные даже после закрытия приложения. Когда пользователь меняет тему, значение SelectedTheme записывается в постоянное хранилище устройства. При следующем запуске приложения @StorageLink автоматически восстанавливает последнее выбранное значение, и тема применяется снова.
Импорты могут работать не только по названию файла, а еще и по названию папки, если в ней есть файл index.ets. Создадим файл entry/src/main/ets/feature/context/ThemeContext/index.ets для удобства работы с импортами:
export { ThemeContext } from "./ThemeContext";
Разработка пользовательского интерфейса
Начнем с разработки страницы приветствия, код для которой расположен в файле entry/src/main/ets/pages/Index.ets:
Страница приветствия
import { ThemeContext } from "../feature/context/ThemeContext";
import { SelectedTheme } from "../feature/shared/theme";
PersistentStorage.persistProp('SelectedTheme', SelectedTheme.Light);
@Component
struct IndexPage {
@StorageProp('SelectedTheme') selectedTheme: SelectedTheme = SelectedTheme.Light;
@Consume('activeTheme') activeTheme: CustomTheme;
private _pathStack: NavPathStack = new NavPathStack();
build() {
Navigation(this._pathStack) {
Column() {
Column() {
Text("Welcome to Harmony")
.fontSize(32)
.fontWeight(FontWeight.Bold)
.textAlign(TextAlign.Center)
.margin({ bottom: 12 })
Text("Your personal music companion")
.fontSize(18)
.textAlign(TextAlign.Center)
.margin({ bottom: 40 })
Column({ space: 20 }) {
}
.width("100%")
.margin({ bottom: 40 })
Button('Next')
.fontSize(30)
.type(ButtonType.Capsule)
.width('33%')
.height('7.5%')
.onClick(() => {
this._pathStack.pushPathByName("Playlist", null);
})
}
.width('100%')
.height("100%")
.justifyContent(FlexAlign.Center)
.padding({ bottom: 56 })
}
.height('100%')
.backgroundColor(this.activeTheme.colors?.backgroundPrimary)
.expandSafeArea([SafeAreaType.SYSTEM],
[SafeAreaEdge.TOP, SafeAreaEdge.END, SafeAreaEdge.BOTTOM, SafeAreaEdge.START])
}
.mode(NavigationMode.Stack)
.hideToolBar(true)
}
}
@Entry
@Component
struct Index {
@Builder _buildPage() {
IndexPage()
}
build() {
Column() {
ThemeContext({
content: this._buildPage,
})
}
}
}
Мы разделили наш входной компонент, в котором мы настраиваем тему, и компонент, в котором мы отображаем сам пользовательский интерфейс. Через ThemeContext создаем наш контекст, и через @Consume(‘activeTheme’) получаем текущие цвета. Заметим, что здесь мы получаем и значение самой темы (SelectedTheme) не через контекст, а через @StorageProp. Разница между @StorageLink/@StorageProp, @Link/@Prop и так далее простая: в @Prop мы получаем readonly-значение, которое не можем изменить, а в @Link мы можем менять значение непосредственно, через оператор присваивания.
В компоненте Index, мы подключаем дочерние элементы через метод _buildPage, обернутый декоратором @Builder. Помните свойство content в ThemeContext, обернутое декоратором @BuilderParam? Эти два декоратора работают с связке: декоратор @BuilderParam получает метод или функцию, которая возвращает ArkTS компоненты, а @Builder создает функцию или метод, которая может использовать особой синтаксис ArkTS, который позволяет создавать компоненты. Исключением является метод build(), потому как его декорировать не нужно.
В компоненте IndexPage, мы создаем новый NavPathStack, который позволяет нам переключаться между экранами. Как именно подключить второй экран, рассмотрим чуть позже. Пока что мы создаем компонент Navigation, передаем туда стек и при клике на кнопку мы с помощью строки this._pathStack.pushPathByName("Playlist", null); открываем второй экран. Вообще, навигация в таком формате может сначала показаться непривычной, однако она является довольно мощной.
В ArkUI существует две парадигмы для навигации между страницами: Router и Navigation. В данном проекте мы будем использовать Navigation, потому как наша идея заключается в создании простого, но расширяемого приложения. Navigation является более мощным решением, а Router — более простым, поэтому в новых проектах рекомендую использовать Navigation. Navigation работает по следующему принципу: мы объявляем в route_map.json список объектов таким образом:
{
"routerMap": [
{
"name": "SecondPage",
"pageSourceFile": "src/main/ets/pages/SecondPage.ets",
"buildFunction": "SecondPageBuilder"
}
]
}
В routerMap в списке объектов мы указываем название экрана, исходный файл экрана, и функцию которая позволяет создать экран. Здесь очень важно отметить, что при работе с Router каждая страница декорируется @Entry, а в нашем случае — лишь одна: вторая страница будет не столько полноценным экраном, сколько компонентом, который наследует настройки главной страницы, либо может их перезаписать. Поэтому контекст, который мы объявили один раз для основной страницы, также будет объявлен и для второго экрана.
По сути, навигация между экранами происходит не на уровне окна, а на уровне компнонента Navigation. Такой подход является хоть и вначале более непростым, но более производительным и гибким, потому как позволяет показывать несколько экранов одновременно, а также передавать состояния через контекст из одного экрана в другой.
Разработка переиспользуемых компонентов
В нашем приложении у нас будет хедер и список достоинств. Мы хотим, чтобы хедер был отдельным компонентом, по нескольким причинам. Во-первых, логика экрана может быть загромождена лишни кодом, если не разбивать пользовательский интерфейс на компоненты. Во-вторых, для каждой страницы хедер должен быть свой, а потому он являются частью каждого экрана, а не навигации в целом.
Также у нас есть список фич приложения на главной странице: каждый элемент тоже можно вынести в отдельный компонент, в который мы будем передавать название преимущества и файла иконки с изображением с помощью @Prop. Также в хедере нам нужно учесть возможность стрелки назад, а потому мы будем передавать в хедер-колбэк функции для действий при нажатии кнопки «Назад».
Начнем с разработки элемента преимущества, назовем компонент Feature (entry/src/main/ets/feature/components/Feature/Feature.ets):
@Component
export struct Feature {
@Consume('activeTheme') activeTheme: CustomTheme;
@Prop assetIconPath: string;
@Prop text: string;
build() {
Row() {
Image($r(this.assetIconPath)).width(24).height(24).margin({ right: 15 })
Text(this.text).fontSize(16).fontWeight(FontWeight.Medium)
}
.alignItems(VerticalAlign.Center)
.padding(15)
.borderRadius(10)
.border({
width: {
left: 4,
},
color: this.activeTheme.colors?.brand,
})
.backgroundColor(this.activeTheme.colors?.backgroundSecondary)
.width(300)
}
}
Здесь мы используем ранее упомянутую функцию $r для получения реального пути к ссылке из приложения и также получаем текущую тему. Отмечу новый декоратор @Prop: он позволяет в одностороннем порядке получать значение, переданное родительским компонентом, как props в React/Vue.
entry/src/main/ets/feature/components/Feature/index.ets:
export { Feature } from "./Feature";
Теперь перейдем к более сложному компоненту, Header. Передать через @Prop функцию нельзя, но зато можно передать класс, который будет содержать функции в качестве свойств.
entry/src/main/ets/feature/components/Header/Header.types.ets:
interface IHeaderCallbacksParams {
navigateBack?: () => void;
}
export class HeaderCallbacks implements IHeaderCallbacksParams {
navigateBack?: () => void;
constructor(params: IHeaderCallbacksParams) {
this.navigateBack = params.navigateBack;
}
}
Здесь мы можем видеть, что HeaderCallbacks является по сути data-классом для передачи данных.
entry/src/main/ets/feature/components/Header/Header.ets:
import { oppositeThemes, SelectedTheme } from "../../shared/theme";
import { HeaderCallbacks } from "./Header.types";
const iconThemeButtons: Record<SelectedTheme, string> = {
[SelectedTheme.Light]: "app.media.dark_mode",
[SelectedTheme.Dark]: "app.media.light_mode",
};
@Component
export struct Header {
@StorageLink('SelectedTheme') selectedTheme: SelectedTheme = SelectedTheme.Light;
@Consume('activeTheme') activeTheme: CustomTheme;
@Prop headerCallbacks: HeaderCallbacks = new HeaderCallbacks({});
private _toggleTheme() {
this.selectedTheme = oppositeThemes[this.selectedTheme];
}
build() {
Row() {
if (this.headerCallbacks.navigateBack) {
Button() {
Image($r("app.media.back_arrow"))
.width(24)
.height(24)
.margin({ right: 8 })
}
.type(ButtonType.Circle)
.width(32)
.height(32)
.padding(4)
.backgroundColor(this.selectedTheme === SelectedTheme.Light ? this.activeTheme.colors?.brand : "transparent")
.onClick(() => {
this.headerCallbacks.navigateBack?.();
})
}
Text("Harmony").fontSize(20).fontWeight(FontWeight.Bold)
Button() {
Image($r(iconThemeButtons[this.selectedTheme])).width(24).height(24)
}
.type(ButtonType.Circle)
.width(32)
.height(32)
.backgroundColor(this.selectedTheme === SelectedTheme.Light ? this.activeTheme.colors?.brand : "transparent")
.onClick(() => {
this._toggleTheme();
})
}
.width("100%")
.justifyContent(FlexAlign.SpaceBetween)
.alignItems(VerticalAlign.Center)
.padding({ top: 10, left: 20, bottom: 15, right: 20 })
.border({
width: {
bottom: 1,
},
color: `${this.activeTheme.colors?.fontPrimary}${this.selectedTheme === SelectedTheme.Light ? "CC" : ""}`,
style: BorderStyle.Solid,
})
.shadow({ radius: 10, color: Color.Gray })
}
}
Мы получаем через @Prop наш data-класс, в котором и хранятся колбэки. В хедере мы также используем получение темы через @Consume, но вот значение, которое как раз таки используется ThemeContext для определения выбранной темы, @StorageLink('SelectedTheme'), мы получаем именно через @StorageLink, чтобы отразить изменения.
entry/src/main/ets/feature/components/Header/index.ets:
export { Header } from "./Header";
export { HeaderCallbacks } from "./Header.types";
Теперь подключим эти компоненты к нашему главному экрану:
Подключаем компоненты
import { Feature } from "../feature/components/Feature";
import { Header } from "../feature/components/Header";
import { ThemeContext } from "../feature/context/ThemeContext";
import { SelectedTheme } from "../feature/shared/theme";
PersistentStorage.persistProp('SelectedTheme', SelectedTheme.Light);
@Component
struct IndexPage {
@StorageProp('SelectedTheme') selectedTheme: SelectedTheme = SelectedTheme.Light;
@Consume('activeTheme') activeTheme: CustomTheme;
private _pathStack: NavPathStack = new NavPathStack();
build() {
Navigation(this._pathStack) {
Column() {
Header()
Column() {
Text("Welcome to Harmony")
.fontSize(32)
.fontWeight(FontWeight.Bold)
.textAlign(TextAlign.Center)
.margin({ bottom: 12 })
Text("Your personal music companion")
.fontSize(18)
.textAlign(TextAlign.Center)
.margin({ bottom: 40 })
Column({ space: 20 }) {
Feature({
assetIconPath: "app.media.note",
text: "Discover new music",
})
Feature({
assetIconPath: "app.media.sync",
text: "Sync across all your devices",
})
Feature({
assetIconPath: "app.media.palette",
text: "Customizable interface",
})
}
.width("100%")
.margin({ bottom: 40 })
Button('Next')
.fontSize(30)
.type(ButtonType.Capsule)
.width('33%')
.height('7.5%')
.onClick(() => {
this._pathStack.pushPathByName("Playlist", null);
})
}
.width('100%')
.height("100%")
.justifyContent(FlexAlign.Center)
.padding({ bottom: 56 })
}
.height('100%')
.backgroundColor(this.activeTheme.colors?.backgroundPrimary)
.expandSafeArea([SafeAreaType.SYSTEM],
[SafeAreaEdge.TOP, SafeAreaEdge.END, SafeAreaEdge.BOTTOM, SafeAreaEdge.START])
}
.mode(NavigationMode.Stack)
.hideToolBar(true)
}
}
@Entry
@Component
struct Index {
@Builder _buildPage() {
IndexPage()
}
build() {
Column() {
ThemeContext({
content: this._buildPage,
})
}
}
}
Замечу, что мы передаем путь к изображениям через app.media.название, потому как синтаксис для получения ресурсов внутри приложения и в конфигурационных файлах отличается. На данном этапе имеем следующий вид мобильного приложения:

Экран с плейлистом
Теперь приступим к разработке экрана с плейлистом. Для этого создадим новый файл ArkTS entry/src/main/ets/pages/Playlist.ets и наполним его базовым содержимым:
entry/src/main/ets/pages/Playlist.ets
import { Header, HeaderCallbacks } from "../feature/components/Header";
import { SelectedTheme } from "../feature/shared/theme";
PersistentStorage.persistProp('SelectedTheme', SelectedTheme.Light);
@Component
struct PlaylistPage {
@StorageProp('SelectedTheme') selectedTheme: SelectedTheme = SelectedTheme.Light;
@Consume('activeTheme') activeTheme: CustomTheme;
private _pathStack: NavPathStack = new NavPathStack()
private _headerCallbacks = new HeaderCallbacks({
navigateBack: () => {
this._pathStack.pop();
}
});
build() {
NavDestination() {
Column() {
Header({ headerCallbacks: this._headerCallbacks })
}
.height('100%')
.backgroundColor(this.activeTheme.colors?.backgroundPrimary)
.expandSafeArea([SafeAreaType.SYSTEM],
[SafeAreaEdge.TOP, SafeAreaEdge.END, SafeAreaEdge.BOTTOM, SafeAreaEdge.START])
}
.hideTitleBar(true)
.onReady((context: NavDestinationContext) => {
this._pathStack = context.pathStack;
})
}
}
@Component
struct Playlist {
@Builder _buildPage() {
PlaylistPage()
}
build() {
this._buildPage()
}
}
@Builder
export function PlaylistBuilder() {
Playlist()
}
Здесь у нас есть обычный компонент Playlist и функция, которая возвращает компонент (PlaylistBuilder). За исключением того, что у нас нет @Entry, а вместо @Entry есть функция PlaylistBuilder, идея в целом такая же. Однако здесь вместо компонента Navigation мы используем NavDestination. При подходе с Navigation можно грубо разбить компоненты навигации на два типа: те, которые объявляют стек (навигаторы), и те, которые являются экранами.
Подключим данный экран через entry/src/main/resources/base/profile/route_map.json:
{
"routerMap": [
{
"name": "Playlist",
"pageSourceFile": "src/main/ets/pages/Playlist.ets",
"buildFunction": "PlaylistBuilder"
}
]
}
Учитывая, что у нас будет список треков, разработаем компонент для конкретного трека. У него будет один основный колбэк: при нажатии на кнопку воспроизвести или поставить на пауз. Передавать отдельно данные о каждом треке через ряд @Prop — плохо скажется на переиспользуемости, поэтому создадим для начала новую модель (обычный, ничем не примечательный класс).
entry/src/main/ets/feature/models/Track.ets:
export interface ITrack {
id: string;
title: string;
artist: string;
duration: string;
url: string;
albumArt: string;
}
Создадим data-класс для колбэков конкретного трека.
entry/src/main/ets/feature/components/Track/Track.types.ets:
import { ITrack } from "../../models/Track";
interface ITrackCallbacks {
onTrackSelected?: (track: ITrack) => void;
}
export class TrackCallbacks implements ITrackCallbacks {
onTrackSelected?: (track: ITrack) => void;
constructor(params: ITrackCallbacks) {
this.onTrackSelected = params.onTrackSelected;
}
}
И теперь разработаем пользовательский интерфейс этого компонента:
Пользовательский интерфейс компонента
import { ITrack } from "../../models/Track";
import { SelectedTheme } from "../../shared/theme";
import { TrackCallbacks } from "./Track.types";
@Component
export struct TrackItem {
@StorageLink('SelectedTheme') selectedTheme: SelectedTheme = SelectedTheme.Light;
@Consume('activeTheme') activeTheme: CustomTheme;
@Prop track: ITrack;
@Prop isCurrent: boolean = false;
@Prop isPlaying: boolean = false;
@Prop trackCallbacks: TrackCallbacks = new TrackCallbacks({});
build() {
Row() {
Column() {
Text(this.track.title)
.fontSize(16)
.fontColor(this.activeTheme.colors?.fontPrimary)
.margin({ bottom: 8 })
Text(`${this.track.artist} • ${this.track.duration}`)
.fontSize(12)
.fontColor('#757575')
}.flexGrow(1)
Button() {
Image($r(this.isCurrent && this.isPlaying ? 'app.media.pause' : 'app.media.play'))
.width(24)
.height(24)
.onClick(() => {
this.trackCallbacks.onTrackSelected?.(this.track);
})
}
.type(ButtonType.Circle)
.width(32)
.height(32)
.padding(4)
.backgroundColor(this.selectedTheme === SelectedTheme.Light ? this.activeTheme.colors?.brand : "transparent")
.onClick(() => {
this.trackCallbacks.onTrackSelected?.(this.track);
})
}
.width("100%")
.padding(12)
.margin({ top: 12, bottom: 12 })
.backgroundColor(this.activeTheme.colors?.backgroundSecondary)
.onClick(() => {
this.trackCallbacks.onTrackSelected?.(this.track);
})
}
}
Сюда передаем четыре @Prop:
track: класс ITrack, содержит автора, длительность, ID, URL аудио-файла);
isCurrent: является ли текущим выбранным треком;
isPlaying: воспроизводится ли сейчас этот трек;
trackCallbacks: data-класс с функцией обратного вызова, который вызывается при нажатии на кнопку воспроизведения/паузы.
entry/src/main/ets/feature/components/Track/index.ets:
export { TrackItem } from "./Track";
export { TrackCallbacks } from "./Track.types";
И теперь добавим саму логику воспроизведения звуковых дорожек. Чтобы проигрывать звуковые дорожки по сети (ранее мы уже дали приложению разрешение на сетевые запросы) в потоковом формате, воспользуемся встроенным в Media Kit аудиоплеером AVPlayer (почитать про плеер подробнее можно здесь):
Логика воспроизведения
import { media } from "@kit.MediaKit";
import { BusinessError } from "@kit.BasicServicesKit";
import { audio } from "@kit.AudioKit";
import { Header, HeaderCallbacks } from "../feature/components/Header";
import { TrackCallbacks, TrackItem } from "../feature/components/Track";
import { ITrack } from "../feature/models/Track";
import { SelectedTheme } from "../feature/shared/theme";
PersistentStorage.persistProp('SelectedTheme', SelectedTheme.Light);
@Component
struct PlaylistPage {
@StorageProp('SelectedTheme') selectedTheme: SelectedTheme = SelectedTheme.Light;
@Consume('activeTheme') activeTheme: CustomTheme;
@State playlist: ITrack[] = [
{
"id": "1",
"title": "Phantom Skating",
"artist": "The Flex Collective",
"duration": "3:58",
"url": "http://sample.vodobox.net/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8",
"albumArt": "app.media.skate_album",
},
{
"id": "2",
"title": "Adaptive Test Groove",
"artist": "Longtail Beats",
"duration": "4:22",
"url": "http://playertest.longtailvideo.com/adaptive/wowzaid3/playlist.m3u8",
"albumArt": "app.media.adaptive_album",
},
{
"id": "3",
"title": "Brazilian Sample Waves",
"artist": "RBS Sessions",
"duration": "5:15",
"url": "http://cdn-fms.rbs.com.br/vod/hls_sample1_manifest.m3u8",
"albumArt": "app.media.brazil_album",
}
];
@State currentTrack: ITrack | undefined = undefined;
@State isPlaying: boolean = false;
private _pathStack: NavPathStack = new NavPathStack()
private _headerCallbacks = new HeaderCallbacks({
navigateBack: () => {
this._pathStack.pop();
}
});
private _trackCallbacks = new TrackCallbacks({
onTrackSelected: async (track: ITrack) => {
if (this._avPlayer) {
if (this.currentTrack?.id === track.id) {
this.currentTrack = undefined;
this.isPlaying = false;
await this._avPlayer.reset();
} else {
this.currentTrack = track;
this.isPlaying = true;
this._avPlayer.url = track.url;
await this._avPlayer.play();
}
}
},
});
private _avPlayer?: media.AVPlayer;
async aboutToAppear(): Promise<void> {
this._avPlayer = await media.createAVPlayer();
this.setAVPlayerCallback(this._avPlayer);
}
setAVPlayerCallback(avPlayer: media.AVPlayer) {
avPlayer.on('error', (err: BusinessError) => {
console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`);
avPlayer.reset();
});
avPlayer.on('stateChange', async (state: string, reason: media.StateChangeReason) => {
switch (state) {
case 'initialized':
avPlayer.audioRendererInfo = {
usage: audio.StreamUsage.STREAM_USAGE_MUSIC,
rendererFlags: 0
};
avPlayer.prepare();
break;
case 'prepared':
avPlayer.play();
break;
}
});
}
build() {
NavDestination() {
Column() {
Header({ headerCallbacks: this._headerCallbacks })
List({ space: 8 }) {
ForEach(this.playlist, (item: ITrack) => {
ListItem() {
TrackItem({
track: item,
isCurrent: this.currentTrack?.id === item.id,
isPlaying: this.isPlaying && this.currentTrack?.id === item.id,
trackCallbacks: this._trackCallbacks
})
}
})
}
.layoutWeight(1)
.padding({ top: 24, left: 24, right: 24 })
.width('100%')
}
.height('100%')
.backgroundColor(this.activeTheme.colors?.backgroundPrimary)
.expandSafeArea([SafeAreaType.SYSTEM],
[SafeAreaEdge.TOP, SafeAreaEdge.END, SafeAreaEdge.BOTTOM, SafeAreaEdge.START])
}
.hideTitleBar(true)
.onReady((context: NavDestinationContext) => {
this._pathStack = context.pathStack;
})
}
}
@Component
struct Playlist {
@Builder _buildPage() {
PlaylistPage()
}
build() {
this._buildPage()
}
}
@Builder
export function PlaylistBuilder() {
Playlist()
}
Здесь мы при создании данного экрана с плейлистом создаем новый AVPlayer (проигрыватель, который позволяет воспроизводить аудио в разных форматах). Затем мы настраиваем функции обратного вызова для данного плеера: мы будем отслеживать, нет ли никаких ошибок (обработчик error) и когда изменилось внутреннее состояние плеера. Мы будем это делать, чтобы указать плееру, что мы будем проигрывать аудио в потоковом формате для максимальной производительности.
Этот плеер работает таким образом, что он может по указанному адресу аудиофайла (будь то по сети, или из хранилища телефона) воспроизводить его и ставить на паузу, через методы play/pause. А потому при нажатии на кнопку конкретного трека мы будем смотреть, играет ли сейчас этот трек. Если да, то будем ставить его на паузу, иначе начнется воспроизведение другого аудио (плеер может проигрывать только одно аудио за раз, поэтому при изменении адреса аудиофайла проигрывание прошлого аудио будет прервано).
Заключение
На этом этапе мы получили полностью рабочее приложение, реализующее следующий функционал:
Два экрана с нативной и производительной навигацией;
Две темы для приложения: светлая и темная, с сохранением темы при выходе из приложения и возможностью переключения темы в приложении;
Переиспользуемые компоненты, хедер на двух экранах приложения с возможностью вернуться назад;
Приветственный экран;
Экран с плейлистом и возможностью воспроизводить аудио-файлы по сети.
Итого, данное приложение является достаточно простым, однако использует рекомендуемые архитектурные подходы, а потому будет очень легко расширяться. Несмотря на то, что ArkTS может показаться непривычным из-за большого упора на производительность, изучив базовые шаблоны можно начинать делать масштабные мобильные приложения, ведь язык прост в изучении, особенно если уже есть опыт с TypeScript или Flutter.
Репозиторий — здесь.
Лично мне разработка под HarmonyOS NEXT очень понравилась:
Удобная, легкая и быстрая разработка UI. Сама по себе разработка пользовательских интерфейсов может сначала быть похожей на Flutter. Но лично я бы сказал, что разработка UI по парадигме скорее ближе к SwiftUI или React Native, потому как в отличие от Flutter стили не привязаны к конкретному типу компонента, есть множество общих стилей почти для всех компонентов. Благодаря данному немного другому подходу можно достичь такой же гибкости, как и с Flutter.
Гибкая и мощная система компонентов. Система компонентов построенная на ООП является гибкой, и дополнительным плюсом является нормальная поддержка функционального программирования в силу поддержки большей части функционала TypeScript, а также дополнительно функционала специфичного для HarmonyOS NEXT. По личному опыту напоминает подход библиотеки Lit из веб-разработки — основной упор на построение компонентов на ООП и наследования, декораторы, система внутреннего и внешнего состояния.
Превосходная документация с большим количеством примеров и обширным API, очень многое доступно из коробки. Ознакомиться с документацией можно по этой ссылке.
Упор на производительность в HarmonyOS NEXT и заточенный под него линтинг, конечно, немного усложняет разработку, потому как необходимо использовать определенные разрешенные паттерны, которые эффективны и с точки зрения производительности, и с точки зрения памяти. Также появляется много новых подходов, создаются новые API заменяющие старые, а потому нужно быть готовым к тому что какой-то API могут заменить более новым и эффективным, ведь технологию активно развивают.
Уже сейчас очевидно, что HarmonyOS NEXT будет занимать все большую и большую долю рынка. Поэтому, думаю, что хорошо начинать сейчас изучать ArkTS, чтобы быть готовым разрабатывать под него приложения.
Надеюсь, статья вам понравилась:) Если уже пробовали работать с HarmonyOS, напишите в комментариях — интересно, какой опыт и впечатления.