
Сегодня в кругах программистов почти каждый знает о библиотеке Facebook – React.
В основе React лежат компоненты. Они схожи с DOM элементами браузера, только написаны не на HTML, а при помощи JavaScript. Использование компонентов, по словам Facebook, позволяет один раз написать интерфейс и отображать его на всех устройствах. В браузере все понятно (данные компоненты преобразуются в DOM элементы), а что же с мобильными приложениями? Тут тоже предсказуемо: React компоненты преобразовываются в нативные компоненты.
В данной статье я хочу рассказать, как разработать простое приложение-шагомер. Будет показана часть кода, отображающая основные моменты. Весь проект доступен по ссылке на GitHub.
Итак, начнем.
Требования
Для разработки под iOS вам будет необходима OS X с Xcode. С Android все проще: можно выбирать из Linux, OS X, Windows. Также придется установить Android SDK. Для боевого тестирования будут необходимы iPhone и любой Android смартфон с Lollipop на борту.
Создание структуры проекта
Для начала создадим структуру проекта. Для манипуляции с данными в приложении будем использовать идею flux, а именно Redux как его реализацию. Также нужен будет роутер. В качестве роутера я выбрал react-native-router-flux, так как он из коробки поддерживает Redux.
Пару слов о Redux. Redux – это простая библиотека, которая хранит состояние приложения. На изменение состояния можно навешать обработчики события, включая рендеринг отображения. Ознакомиться с redux рекомендую по видеоурокам.
Приступим к реализации. Установим react-native-cli с помощью npm, с помощью которого будем выполнять в дальнейшем все манипуляции с проектом.
npm install -g react-native-cli
Далее создаем проект:
react-native init AwesomeProject
Устанавливаем зависимости:
npm install
В результате в корне проекта создались папки ios и android, в которых находятся “нативные” файлы под каждую из платформ соответственно. Файлы index.ios.js и index.android.js являются точками входа приложения.
Установим необходимые библиотеки:
npm install —save react-native-router-flux redux redux-thunk react-redux lodash
Создаем структуру директорий:
app/ actions/ components/ containers/ constants/ reducers/ services/
В папке actions будут находиться функции, описывающие, что происходит с данными в store.
components, исходя из названия, будет содержать компоненты отдельных элементов интерфейса.
containers содержит корневые компоненты каждой из страниц приложения.
constants – название говорит само за себя.
В reducers будут находиться так называемые “редюсеры”. Это функции, которые изменяют состояние приложение в зависимости от полученных данных.
В папке app/containers создадим app.js. В качестве корневого элемента приложения выступает обертка redux. Все роуты прописываются в виде обычных компонентов. Свойство initial говорит роутеру, какой роут должен отработать при инициализации приложения. В свойство component роута передаем компонент, который будет показан при переходе на него.
app/containers/app.js <Provider store={store}> <Router hideNavBar={true}> <Route name="launch" component={Launch} initial={true} wrapRouter={true} title="Launch"/> <Route name="counter" component={CounterApp} title="Counter App"/> </Router> </Provider>
В директории app/containers создаем launch.js. launch.js – обычный компонент c кнопкой для перехода к странице счетчика.
app/containers/launch.js import { Actions } from ‘react-native-router-flux'; … <TouchableOpacity onPress={Actions.counter}> <Text>Counter</Text> </TouchableOpacity>
Actions – объект, в котором каждому роуту соответствует метод. Имена таких методов берутся из свойства name роута.
В файле app/constants/actionTypes.js опишем возможные события счетчика:
export const INCREMENT = 'INCREMENT'; export const DECREMENT = 'DECREMENT';
В папке app/actions создаем файл counterActions.js с содержимым:
app/actions/counterActions.js import * as types from '../constants/actionTypes'; export function increment() { return { type: types.INCREMENT }; } export function decrement() { return { type: types.DECREMENT }; }
Функции increment и decrement описывают происходящее действие редюсеру. В зависимости от действия, редюсер изменяет состояние приложения. initialState – описывает начальное состояние хранилища. При инициализации приложения счетчик будет установлен на 0.
app/reducers/counter.js import * as types from '../constants/actionTypes'; const initialState = { count: 0 }; export default function counter(state = initialState, action = {}) { switch (action.type) { case types.INCREMENT: return { ...state, count: state.count + 1 }; case types.DECREMENT: return { ...state, count: state.count - 1 }; default: return state; } }
В файле counter.js располагаются две кнопки для уменьшения и увеличения значения счетчика, а также отображается текущее значение.
app/components/counter.js const { counter, increment, decrement } = this.props; … <Text>{counter}</Text> <TouchableOpacity onPress={increment} style={styles.button}> <Text>up</Text> </TouchableOpacity> <TouchableOpacity onPress={decrement} style={styles.button}> <Text>down</Text> </TouchableOpacity>
Обработчики событий и само значение счетчика передаются из компонента контейнера. Рассмотрим его ниже.
app/containers/counterApp.js import React, { Component } from 'react-native'; import {bindActionCreators} from 'redux'; import Counter from '../components/counter'; import * as counterActions from '../actions/counterActions'; import { connect } from 'react-redux'; class CounterApp extends Component { constructor(props) { super(props); } render() { const { state, actions } = this.props; return ( <Counter counter={state.count} {...actions} /> ); } } /* Подписываем компонент на событие изменения хранилища. Теперь в props.state будет текущее состояние счетчика */ export default connect(state => ({ state: state.counter }), /* Привязываем действия к компоненту. Теперь доступны события манипуляции счетчиком props.actions.increment() и props.actions.decrement() */ (dispatch) => ({ actions: bindActionCreators(counterActions, dispatch) }) )(CounterApp);
В итоге мы получили простое приложение, которое включает в себя необходимые компоненты. Данное приложение можно взять за основу любого приложения, разработанного с помощью ReactNative.
Диаграмма
Так как мы разрабатываем приложение-шагомер, соответственно нам нужно отобразить результаты измерений. Наилучшим способом, как мне кажется, является диаграмма. Таким образом, разработаем простую столбчатую диаграмму (bar chart): ось Y показывает количество шагов, а X – время.
ReactNative из коробки не поддерживает canvas и, к тому же, для использования canvas необходимо использовать webview. Таким образом, остается два варианта: писать нативный компонент под каждую из платформ или использовать стандартный набор компонент. Первый вариант наиболее трудозатратный, но, в резу��ьтате, получим производительное и гибкое решение. Остановимся на втором варианте.
Для отображения данных будем передавать их компоненту в виде массива объектов:
[ { label, // отображаемая данные на оси X value, // значение color // цвет столбца } ]
Создаем три файла:
app/components/chart.js app/components/chart-item.js app/components/chart-label.js
Ниже код основного компонента диаграммы:
app/components/chart.js import ChartItem from './chart-item'; import ChartLabel from './chart-label'; class Chart extends Component { constructor(props) { super(props); let data = props.data || []; this.state = { data: data, maxValue: this.countMaxValue(data) } } /* Функция для подсчета максимального значения из переданных данных.*/ countMaxValue(data) { return data.reduce((prev, curn) => (curn.value >= prev) ? curn.value : prev, 0); } componentWillReceiveProps(newProps) { let data = newProps.data || []; this.setState({ data: data, maxValue: this.countMaxValue(data) }); } /* Функция для получения массива компонент столбцов */ renderBars() { return this.state.data.map((value, index) => ( <ChartItem value={value.value} color={value.color} key={index} barInterval={this.props.barInterval} maxValue={this.state.maxValue}/> )); } /* Функция для получения массива компонент подписей столбцов */ renderLabels() { return this.state.data.map((value, index) => ( <ChartLabel label={value.label} barInterval={this.props.barInterval} key={index} labelFontSize={this.props.labelFontSize} labelColor={this.props.labelFontColor}/> )); } render() { let labelStyles = { fontSize: this.props.labelFontSize, color: this.props.labelFontColor }; return( <View style={[styles.container, {backgroundColor: this.props.backgroundColor}]}> <View style={styles.labelContainer}> <Text style={labelStyles}> {this.state.maxValue} </Text> </View> <View style={styles.itemsContainer}> <View style={[styles.polygonContainer, {borderColor: this.props.borderColor}]}> {this.renderBars()} </View> <View style={styles.itemsLabelContainer}> {this.renderLabels()} </View> </View> </View> ); } } /* производим валидацию переданных данных */ Chart.propTypes = { data: PropTypes.arrayOf(React.PropTypes.shape({ value: PropTypes.number, label: PropTypes.string, color: PropTypes.string })), // массив отображаемых данных barInterval: PropTypes.number, // расстояние между столбцами labelFontSize: PropTypes.number, // размер шрифта для подписи данных labelFontColor: PropTypes.string, // цвет шрифта для подписи данных borderColor: PropTypes.string, // цвет оси backgroundColor: PropTypes.string // цвет фона диаграммы } export default Chart;
Компонент реализующий столбец графика:
app/components/chart-item.js export default class ChartItem extends Component { constructor(props) { super(props); this.state = { /* Используем анимацию появления столбцов, задаем начальное значение позиции */ animatedTop: new Animated.Value(1000), /* Получаем отношение текучего значения к максимальному */ value: props.value / props.maxValue } } componentWillReceiveProps(nextProps) { this.setState({ value: nextProps.value / nextProps.maxValue, animatedTop: new Animated.Value(1000) }); } render() { const { color, barInterval } = this.props; /* В момент рендера компонента начинаем выполнение анимации */ Animated.timing(this.state.animatedTop, {toValue: 0, timing: 2000}).start(); return( <View style={[styles.item, {marginHorizontal: barInterval}]}> <Animated.View style={[styles.animatedElement, {top: this.state.animatedTop}]}> <View style={{flex: 1 - this.state.value}} /> <View style={{flex: this.state.value, backgroundColor: color}}/> </Animated.View> </View> ); } } const styles = StyleSheet.create({ item: { flex: 1, overflow: 'hidden', width: 1, alignItems: 'center' }, animatedElement: { flex: 1, left: 0, width: 50 } });
Код компонента подписи данных:
app/components/chart-label.js export default ChartLabel = (props) => { const { label, barInterval, labelFontSize, labelColor } = props; return( <View style={[{marginHorizontal: barInterval}, styles.label]}> <View style={styles.labelWrapper}> <Text style={[styles.labelText, {fontSize: labelFontSize, color: labelColor}]}> {label} </Text> </View> </View> ); }
В итоге мы получили простую гистограмму, реализованную с помощью стандартного набора компонентов.
Шагомер
ReactNative – довольно молодой проект, который имеет только основной набор инструментов для создания простого приложения, которое берет из сети данные и отображает их. Но, когда стоит задача генерации данных на самом устройстве, придется поработать с написанием модулей на родных для платформ языках.
На данном этапе нам предстоит написать свой педометр. Не зная objective-c и java, а также api устройств, сделать это сложно, но можно, – все упирается во время. Благо существуют такие проекты, как Apache Cordova и Adobe PhoneGap. Они уже достаточно давно присутствуют на рынке, и сообщество написало много модулей под них. Эти модули легко портировать под react. Вся логика остается неизменной, нужно только переписать интерфейс (bridge).
В iOS для получения данных активности есть замечательное api – HealthKit. Apple имеет хорошую документацию, в которой даже присутствуют реализации обычных простых задач. С Android другая ситуация. Все, что есть у нас, – набор датчиков. Причем в документации написано, что, начиная с api 19, есть возможность получать данные датчика шагов. На Android работает огромное количество устройств, и добросовестные китайские производители и не только (включая достаточно именитые бренды) устанавливают лишь основной набор датчиков: акселерометр, датчик освещенности и датчик приближения. Таким образом, придется отдельно писать код для устройств с Android 4.4+ и с датчиком шагов (а также для более старых устройств). Это позволит улучшить точность измерений.
Приступим к реализации.
Сразу оговорюсь. Прошу прощение за качество кода. Я впервые столкнулся с данными языками программирования и пришлось разбираться на интуитивном уровне, так как времени было в обрез.
iOS
Создаем два файла c содержимым:
ios/BHealthKit.h #ifndef BHealthKit_h #define BHealthKit_h #import <Foundation/Foundation.h> #import "RCTBridgeModule.h" @import HealthKit; @interface BHealthKit : NSObject <RCTBridgeModule> @property (nonatomic) HKHealthStore* healthKitStore; @end #endif /* BHealthKit_h */ ios/BHealthKit.m #import "BHealthKit.h" #import "RCTConvert.h" @implementation BHealthKit RCT_EXPORT_MODULE(); - (NSDictionary *)constantsToExport { NSMutableDictionary *hkConstants = [NSMutableDictionary new]; NSMutableDictionary *hkQuantityTypes = [NSMutableDictionary new]; [hkQuantityTypes setValue:HKQuantityTypeIdentifierStepCount forKey:@"StepCount"]; [hkConstants setObject:hkQuantityTypes forKey:@"Type"]; return hkConstants; } /* Метод для запроса прав на получение данных из HealthKit */ RCT_EXPORT_METHOD(askForPermissionToReadTypes:(NSArray *)types callback:(RCTResponseSenderBlock)callback){ if(!self.healthKitStore){ self.healthKitStore = [[HKHealthStore alloc] init]; } NSMutableSet* typesToRequest = [NSMutableSet new]; for (NSString* type in types) { [typesToRequest addObject:[HKQuantityType quantityTypeForIdentifier:type]]; } [self.healthKitStore requestAuthorizationToShareTypes:nil readTypes:typesToRequest completion:^(BOOL success, NSError *error) { /* Если все ок, то мы вызываем callback с аргументом null, отвечающим за ошибку */ if(success){ callback(@[[NSNull null]]); return; } /* Иначе передаем в callback сообщение ошибки */ callback(@[[error localizedDescription]]); }]; } /* Метод для получения количества шагов в промежуток времени. Первым аргументом передаем начальное время, вторым – конечное время измерений, а третьим – callback */ RCT_EXPORT_METHOD(getStepsData:(NSDate *)startDate endDate:(NSDate *)endDate cb:(RCTResponseSenderBlock)callback){ NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; NSLocale *enUSPOSIXLocale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; NSPredicate *predicate = [HKQuery predicateForSamplesWithStartDate:startDate endDate:endDate options:HKQueryOptionStrictStartDate]; [dateFormatter setLocale:enUSPOSIXLocale]; [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZZZ"]; HKSampleQuery *stepsQuery = [[HKSampleQuery alloc] initWithSampleType:[HKQuantityType quantityTypeForIdentifier:HKQuantityTypeIdentifierStepCount] predicate:predicate limit:2000 sortDescriptors:nil resultsHandler:^(HKSampleQuery *query, NSArray *results, NSError *error) { if(error){ /* Если при получении данных возникла ошибка, передаем ее описание в callback */ callback(@[[error localizedDescription]]); return; } NSMutableArray *data = [NSMutableArray new]; for (HKQuantitySample* sample in results) { double count = [sample.quantity doubleValueForUnit:[HKUnit countUnit]]; NSNumber *val = [NSNumber numberWithDouble:count]; NSMutableDictionary* s = [NSMutableDictionary new]; [s setValue:val forKey:@"value"]; [s setValue:sample.sampleType.description forKey:@"data_type"]; [s setValue:[dateFormatter stringFromDate:sample.startDate] forKey:@"start_date"]; [s setValue:[dateFormatter stringFromDate:sample.endDate] forKey:@"end_date"]; [data addObject:s]; } /* В случае успеха, вызываем callback, первым аргументом будет null, так как ошибки отсутствуют, а вторым – массив данных. */ callback(@[[NSNull null], data ]); }]; [self.healthKitStore executeQuery:stepsQuery]; }; @end
Далее эти файлы нужно добавить в проект. Открываем Xcode, правой кнопкой по корневому каталогу -> Add Files to “project name”. В разделе Capabilities включаем HealthKit. Далее в разделе General -> Linked Frameworks and Libraries жмем “+” и добавляем HealthKit.framework.
С нативной частью закончили. далее переходим непосредственно к получению данных в js части проекта.
Создаем файл app/services/health.ios.js:
app/services/health.ios.js /* Подключаем написанный нами модуль. BHealthKit содержит два метода, которые мы написали в BHealthKit.m */ const { BHealthKit } = React.NativeModules; let auth; // Функция для запроса прав function requestAuth() { return new Promise((resolve, reject) => { BHealthKit.askForPermissionToReadTypes([BHealthKit.Type.StepCount], (err) => { if (err) { reject(err); } else { resolve(true); } }); }); } // Функция получения данных. function requestData() { let date = new Date().getTime(); let before = new Date(); before.setDate(before.getDate() - 5); /* Так как процесс обращения к нативным модулям выполняется асинхронно, оборачиваем его в промис.*/ return new Promise((resolve, reject) => { BHealthKit.getStepsData(before.getTime(), date, (err, data) => { if (err) { reject(err); } else { let result = {}; /* Тут же производим процесс преобразования данных к нужному нам виду */ for (let val in data) { const date = new Date(data[val].start_date); const day = date.getDate(); if (!result[day]) { result[day] = {}; } result[day]['steps'] = (result[day] && result[day]['steps'] > 0) ? result[day]['steps'] + data[val].value : data[val].value; result[day]['date'] = date; } resolve(Object.values(result)); } }); }); } export default () => { if (auth) { return requestData(); } else { return requestAuth().then(() => { auth = true; return requestData(); }); } }
Android
Код получился объемный, поэтому я опишу принцип работы.
Android SDK не предоставляет хранилище, обращаясь к которому можно получить данные за определенный промежуток времени, а лишь возможность получения данных в реальном времени. Для этого используются сервисы, которые постоянно работают в фоне и выполняют нужные задачи. С одной стороны, это очень гибко, но допустим, что на устройстве установлено двадцать шагомеров и каждое приложение будет иметь свой сервис, который выполняет ту же задачу, что и остальные 19.
Реализуем два сервиса: для устройств с датчиком шагов и без. Это файлы android/app/src/main/java/com/awesomeproject/pedometer/StepCounterService.java и android/app/src/main/java/com/awesomeproject/pedometer/StepCounterOldService.java.
В файле android/app/src/main/java/com/awesomeproject/pedometer/StepCounterBootReceiver.java при запуске устройства описываем, какой из сервисов будет запускаться в зависимости от устройства.
В файлах android/app/src/main/java/com/awesomeproject/RNPedometerModule.java и RNPedometerPackage.java реализовуем связь приложения с react.
Получаем разрешение на использование датчиков, добавив строчки в android/app/src/main/AndroidManifest.xml
<uses-feature android:name="android.hardware.sensor.stepcounter" android:required="true"/> <uses-feature android:name="android.hardware.sensor.stepdetector" android:required=“true"/> <uses-feature android:name="android.hardware.sensor.accelerometer" android:required="true" /> Даем знать приложению о наших сервисах, а также задаем ресивер, который будет запускать сервисы при включении смартфона. <application> … <service android:name=".pedometer.StepCounterService"/> <service android:name=".pedometer.StepCounterOldService" /> <receiver android:name=".pedometer.StepCounterBootReceiver"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver> </application>
Подключаем модуль к приложению и при запуске приложения запускаем сервисы.
android/app/src/main/java/com/awesomeproject/MainActivity.java … protected List<ReactPackage> getPackages() { return Arrays.<ReactPackage>asList( new MainReactPackage(), new RNPedometerPackage(this) ); } … @Override public void onCreate(Bundle bundle) { super.onCreate(bundle); Boolean can = StepCounterOldService.deviceHasStepCounter(this.getPackageManager()); /* Если в устройстве есть датчик шагов, то запускаем сервис использующий его */ if (!can) { startService(new Intent(this, StepCounterService.class)); } else { /* Иначе запускаем сервис использующий акселерометр*/ startService(new Intent(this, StepCounterOldService.class)); } }
Получение данных javascript части. Создаем файл app/services/health.android.js
const Pedometer = React.NativeModules.PedometerAndroid;
export default () => { /* Получение данных происходит в асинхронном режиме, поэтому оборачиваем запрос в промис. */ return new Promise((resolve, reject) => { Pedometer.getHistory((result) => { try { result = JSON.parse(result); // Преобразовываем данные к нужному виду result = Object.keys(result).map((key) => { let date = new Date(key); date.setHours(0); return { steps: result[key].steps, date: date } }); resolve(result); } catch(err) { reject(err); }; }, (err) => { reject(err); }); }); }
В итоге мы получили два файла health.ios.js и health.android.js, которые получают статистику активности пользователя из нативных модулей платформ. Далее в любом месте приложения выражением:
import Health from ‘<path>health’;
React Native подключает нужный файл, исходя из префикса файлов. Теперь мы можем использовать данную функцию, не задумываясь, на IOS или Android выполняется приложение.
В результате, мы написали простенькое приложение-шагомер и рассмотрели основные моменты, которые вам предстоит пройти при разработке собственного приложения.
В конце хочется выделить преимущества и недостатки ReactNative.
Преимущества:
- разработчик, имеющий опыт разработки на JavaScript, легко может написать приложение;
- разрабатывая одно приложение, вы сразу получаете возможность выполнять его на Android и IOS;
- ReactNative имеет достаточно большой набор реализованных компонент, которые зачастую покроют все ваши требования;
- активное сообщество, которое быстрыми темпами пишет различные модули.
Недостатки:
- не всегда гладко один и тот же код работает на обеих платформах (зачастую проблемы с отображением);
- при специфической задаче зачастую нет реализованных модулей и придется их писать самому;
- производительность. В сравнении с PhoneGap и Cordova, react очень быстр, но все же нативное приложение будет быстрей.
Когда целесообразно выбрать ReactNative?
Если нужно разработать простое приложение для получения данных из сервера и их отображения, то выбор очевиден. Если же перед вами стоит задача реализации крутого дизайна, критически важна производительность, или же стоит задача, которую сложно решить с помощью готовых компонент, то здесь стоит задуматься. Так как большую часть придется писать на родных языках платформ, строить пирамиду из этого определенно не лучший вариант.
Спасибо за внимание.
Статью подготовили: greebn9k(Сергей Грибняк), boozzd(Дмитрий Шаповаленко), silmarilion(Андрей Хахарев)
