Разработка простого приложения «шагомер» на ReactNative
Сегодня в кругах программистов почти каждый знает о библиотеке 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
В директории app/containers создаем launch.js. launch.js — обычный компонент c кнопкой для перехода к странице счетчика.
app/containers/launch.js
import { Actions } from ‘react-native-router-flux';
…
Counter
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;
…
{counter}
up
down
Обработчики событий и само значение счетчика передаются из компонента контейнера. Рассмотрим его ниже.
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 (
);
}
}
/* Подписываем компонент на событие изменения хранилища. Теперь в 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) => (
));
}
/* Функция для получения массива компонент подписей столбцов */
renderLabels() {
return this.state.data.map((value, index) => (
));
}
render() {
let labelStyles = {
fontSize: this.props.labelFontSize,
color: this.props.labelFontColor
};
return(
{this.state.maxValue}
{this.renderBars()}
{this.renderLabels()}
);
}
}
/* производим валидацию переданных данных */
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(
);
}
}
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(
{label}
);
}
В итоге мы получили простую гистограмму, реализованную с помощью стандартного набора компонентов.
Шагомер
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
#import "RCTBridgeModule.h"
@import HealthKit;
@interface BHealthKit : NSObject
@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
Даем знать приложению о наших сервисах, а также задаем ресивер, который будет запускать сервисы при включении смартфона.
…
Подключаем модуль к приложению и при запуске приложения запускаем сервисы.
android/app/src/main/java/com/awesomeproject/MainActivity.java
…
protected List getPackages() {
return Arrays.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 ‘health’;
React Native подключает нужный файл, исходя из префикса файлов. Теперь мы можем использовать данную функцию, не задумываясь, на IOS или Android выполняется приложение.
В результате, мы написали простенькое приложение-шагомер и рассмотрели основные моменты, которые вам предстоит пройти при разработке собственного приложения.
В конце хочется выделить преимущества и недостатки ReactNative.
Преимущества:
- разработчик, имеющий опыт разработки на JavaScript, легко может написать приложение;
- разрабатывая одно приложение, вы сразу получаете возможность выполнять его на Android и IOS;
- ReactNative имеет достаточно большой набор реализованных компонент, которые зачастую покроют все ваши требования;
- активное сообщество, которое быстрыми темпами пишет различные модули.
Недостатки:
- не всегда гладко один и тот же код работает на обеих платформах (зачастую проблемы с отображением);
- при специфической задаче зачастую нет реализованных модулей и придется их писать самому;
- производительность. В сравнении с PhoneGap и Cordova, react очень быстр, но все же нативное приложение будет быстрей.
Когда целесообразно выбрать ReactNative?
Если нужно разработать простое приложение для получения данных из сервера и их отображения, то выбор очевиден. Если же перед вами стоит задача реализации крутого дизайна, критически важна производительность, или же стоит задача, которую сложно решить с помощью готовых компонент, то здесь стоит задуматься. Так как большую часть придется писать на родных языках платформ, строить пирамиду из этого определенно не лучший вариант.
Спасибо за внимание.
Статью подготовили: greebn9k (Сергей Грибняк), boozzd (Дмитрий Шаповаленко), silmarilion (Андрей Хахарев)