Разработка простого приложения «шагомер» на ReactNative

image
Сегодня в кругах программистов почти каждый знает о библиотеке 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.


Преимущества:

  1. разработчик, имеющий опыт разработки на JavaScript, легко может написать приложение;
  2. разрабатывая одно приложение, вы сразу получаете возможность выполнять его на Android и IOS;
  3. ReactNative имеет достаточно большой набор реализованных компонент, которые зачастую покроют все ваши требования;
  4. активное сообщество, которое быстрыми темпами пишет различные модули.


Недостатки:

  1. не всегда гладко один и тот же код работает на обеих платформах (зачастую проблемы с отображением);
  2. при специфической задаче зачастую нет реализованных модулей и придется их писать самому;
  3. производительность. В сравнении с PhoneGap и Cordova, react очень быстр, но все же нативное приложение будет быстрей.


Когда целесообразно выбрать ReactNative?

Если нужно разработать простое приложение для получения данных из сервера и их отображения, то выбор очевиден. Если же перед вами стоит задача реализации крутого дизайна, критически важна производительность, или же стоит задача, которую сложно решить с помощью готовых компонент, то здесь стоит задуматься. Так как большую часть придется писать на родных языках платформ, строить пирамиду из этого определенно не лучший вариант.

Спасибо за внимание.

Статью подготовили: greebn9k (Сергей Грибняк), boozzd (Дмитрий Шаповаленко), silmarilion (Андрей Хахарев)

© Habrahabr.ru