Три месяца драйва и сверхурочной работы. Нервное напряжение порой зашкаливало, но оптимизм не иссякал. Мы ставили перед собой непростые задачи и пытались их решить нестандартным способом. И у нас получилось.
Меня зовут Алексей, я расскажу вам о нашем опыте участия в конкурсе ВК по разработке мобильных приложений для платформ Android, iOS и Windows Phone. Думаю, моя статья поможет новичкам трезво оценивать свои силы и знать, что их ожидает.
Условия конкурса, если интересно, здесь, и пара слов о нашем продукте. На конкурс мы решили выйти с проектом «Подсмотрено – город на ладони». У нас было около 50 пабликов «Подсмотрено» в разных городах, но нам хотелось создать что-то, объединяющее все городские новости в одном месте. И мы принялись за работу. Нужно было сделать функциональное мобильное приложение, в котором все события и новости города (агрегирующиеся из Вконтакте) были бы максимально доступны каждому пользователю.
За основной показатель успеха приложения «Подсмотрено» мы взяли реакцию пользователей (Retention) и понимание того, будет ли продукт формировать новую привычку людей пользоваться приложением.
На данный момент:
- Retention 1 дня на уровне 30% (не много, хорошая практика 50%),
- Retention 3 дня – 18%
- Retention 7 дня – 15%
Конечно, хочется более впечатляющих цифр, но для приложения, которому всего 4 месяца, это вполне хорошие результаты.
За эти три месяца, что мы «пилили» проект, естественно, возникали разные сложности. Наши разработчики поделятся с вами полученным опытом решения проблем.
Кейс от iOS разработчика
«Сложностей в работе над «Подсмотрено» возникало достаточно. Вот одна из них. Передо мной была поставлена задача засунуть вертикально скролящийся контент в категории, которые можно переключать скроллом. Не свайпом, а именно скроллом. Сначала было решено использовать UIPageViewController. Внутреннее чутье меня не подвело, через некоторое время появились подвисания при горизонтальной прокрутке. Пришлось переделать на UICollectionView, ячейками которой являются UIScrollView с контентом. Таким образом я добился плавного переключения между категориями, но вертикальный скролл не подавал признаков жизни. Пришлось переписать обработку касаний, чтобы жесты передавались дальше по иерархии.
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
if (!_isHorizontalScroll && !_isMultitouch) {
CGPoint point = [[touches anyObject] locationInView:self];
if (fabs(_originalPoint.x - point.x) > kThresholdX && fabs(_originalPoint.y - point.y) < kThresholdY) {
_isHorizontalScroll = YES;
[_currentChild touchesCancelled:[event touchesForView:self] withEvent:event];
}
}
if (_isHorizontalScroll){
[super touchesMoved:touches withEvent:event];
}else{
[_currentChild touchesMoved:touches withEvent:event];
}
}
Кейс от Android-разработчика
При выборе архитектуры приложения я остановился на Clean Architecture (https://github.com/android10/Android-CleanArchitecture). Данная архитектура построена на принципах, сформулированных Бобом Мартином. Нет смысла описывать саму архитектуру и преимущества, получаемые при её использовании, на эту тему написано много отличный статей (например: «Архитектура Android-приложений… Правильный путь?», и "Чистая архитектура"), но для понимания того, о чем пойдет речь далее, советую ознакомиться с ними. Сразу перейду к проблеме, возникшей при разработке нашего приложения.
“Подсмотрено — город на ладони” — своего рода площадка для просмотра актуальной информации о конкретном городе. Для того чтобы избавить пользователей от ручного поиска по большому списку городов, нам необходимо определять текущее местоположение пользователя. Сначала я работал по старинке: использовал системный LocationManager, из него получал список провайдеров, а из них определённую локацию. Стандартный способ решения проблемы, думаю, каждый знаком с ним. Всё работало отлично. Но было несколько проблем.
1. В Android API >=23 ввели Runtime Permossions. Это значит, что на девайсах с Android 6.0 нужно в рантайме запрашивать некоторые разрешения, в нашем случае на определение координат. У нас определение текущей локации производится сразу — на первом экране. Мы посчитали, что такой запрос может отпугнуть часть пользователей.
2. LocationManager лежал в слое presentation, что очень сильно противоречит принципам, заложенным в Clean Architecture.
Для решения первой проблемы я прибегнул к сервису от Яндекса: Локатор (https://tech.yandex.ru/locator/). С помощью данного сервиса можно определить текущее местоположение по ближайшим точкам доступа Wi-Fi и мобильным сотам, без использования GPS. Таким образом, мы избавимся от назойливого диалога. Но данный сервис, по различным причинам не всегда выдаёт правильный результат. На сайте самого сервиса написано:
> В некоторых случаях Яндекс.Локатор сообщает о точности 100000 метров, которая означает, что достоверно определить местоположение не удалось. Это происходит, если местоположение определяется не по IP-адресу мобильного устройства, а по IP-адресу какого-либо публичного сервера или прокси-сервера.
В этом случае полагаться на полученный результат мы не можем. Выходит, нам необходимо вернуться обратно к LocationManager. Таким образом алгоритм следующий: Сделали запрос к Яндекс локатору, если он ничего не вернул, либо у точность определённой локации >= 100000 метров, запрашиваем у системного LocationManagerа.
Перейду к решению второй проблемы: вынесем ответственность за выдачу текущей координаты в слой data, так как он отвечает за управление данными в приложении.
Но сначала перейдём в слой domain. Я создал интерфейс LocationService:
public interface LocationService {
Observable<LocationEntity> getCurrentLocation();
}
И использовал его в UseCase:
public class GetCurrentLocationUseCase extends UseCase<LocationEntity> {
public static final String CASE_NAME = "get_current_location";
private final LocationService mLocationService;
@Inject
public GetCurrentLocationUseCase(LocationService locationService, ThreadExecutor threadExecutor, PostExecutionThread postExecutionThread) {
super(threadExecutor, postExecutionThread);
mLocationService = locationService;
}
@Override
protected Observable<LocationEntity> buildUseCaseObservable() {
return mLocationService.getCurrentLocation();
}
}
Теперь этот UseCase можно легко «заинъектить» в Presenter, с помощью Dagger2, таким образом абстрагироваться от конкретной реализации сервиса.
public class YandexLocationService implements LocationService, Constants {
public static final int MAX_PRECISION = 100000;
private final YandexLocatorService mYandexLocatorService;
@Inject
public YandexLocationService() {
RestAdapter restAdapter = new RestAdapter.Builder()
.setEndpoint(BASE_URL_LOCATOR)
.setLogLevel(RETROFIT_LOG_LEVEL)
.build();
mYandexLocatorService = restAdapter.create(YandexLocatorService.class);
}
@Override
public Observable<LocationEntity> getCurrentLocation() {
return getCurrentLocationByIp()
.timeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
.filter(yandexLocation ->
yandexLocation.getPrecision() < MAX_PRECISION)
.map(LocationTransformer::transformToLocationEntity);
}
public Observable<YandexLocation> getCurrentLocationByIp() {
return mYandexLocatorService.getLocation(getLocatorRequestObject());
}
public YandexRequest getLocatorRequestObject() {
return new YandexRequest(new Common(LOCATOR_VERSION, LOCATOR_API_KEY));
}
}
В данном примере определение координат производится только по IP адресу. Здесь я фильтрую координаты с плохой точностью (>= 100000) и преобразую полученную сущность YandexLocation в LocationEntity. Далее перейдём к системному сервису определения координат. Для него немного сложнее, так как он использует Runtime Permissions, а, следовательно, должен запрашивать разрешения. Я сделал интерфейс:
public interface PermissionsRequester {
Observable<Boolean> request(String... permissions);
}
Реализовывать этот интерфейс будем в слое presentation с помощью библиотеки RxPermissions:
public class PermissionsRequesterImpl implements PermissionsRequester {
private final RxPermissions mRxPermissions;
public PermissionsRequesterImpl(Context context) {
mRxPermissions = RxPermissions.getInstance(context);
}
@Override
public Observable<Boolean> request(String... permissions) {
return mRxPermissions.request(permissions);
}
}
Теперь можно легко использовать данный интерфейс:
public class SystemLocationService implements LocationService {
private final LocationManager mLocationManager;
private final PermissionsRequester mPermissionsRequester;
@Inject
public SystemLocationService(LocationManager locationManager, PermissionsRequester permissionsRequester) {
mLocationManager = locationManager;
mPermissionsRequester = permissionsRequester;
}
@Override
public Observable<LocationEntity> getCurrentLocation() {
return getCurrentGpsLocation()
.map(LocationTransformer::transformToLocationEntity);
}
public Observable<Location> getCurrentGpsLocation() {
return mPermissionsRequester
.request(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
.flatMap(granted -> granted ? findLastLocation() : Observable.error(new RuntimeException()));
}
private Observable<Location> findLastLocation() {
return Observable.create(new Observable.OnSubscribe<Location>() {
@Override
public void call(Subscriber<? super Location> subscriber) {
Location lastLocation = null;
List<String> providers = mLocationManager.getAllProviders();
if (providers != null && !providers.isEmpty()) {
for (String provider : providers) {
if (mLocationManager.isProviderEnabled(provider)) {
Location auxLocation = mLocationManager.getLastKnownLocation(provider);
if (auxLocation != null) {
if (lastLocation == null) {
lastLocation = auxLocation;
} else if (auxLocation.getTime() > lastLocation.getTime()) {
lastLocation = auxLocation;
}
}
}
}
}
subscriber.onNext(lastLocation);
subscriber.onCompleted();
}
});
}
}
Таким образом, я сделал 2 сервиса. Но нужно использовать оба. Как решить эту проблему? Я создал CompositeLocationService, который получает несколько реализаций интерфейса LocationService и по очереди запускает каждый из них, пока не получит определённую локацию:
public class CompositeLocationService implements LocationService {
public static final int DEFAULT_TIMEOUT = 30;
private final LocationService[] mLocationServices;
@Inject
public CompositeLocationService(LocationService... services) {
if (services == null || services.length == 0) {
throw new CompositeLocationEmptyException();
}
mLocationServices = services;
}
@Override
public Observable<LocationEntity> getCurrentLocation() {
return Observable.concat(
Observable.from(mLocationServices)
.map(locationService ->
locationService
.getCurrentLocation()
.onErrorReturn(throwable -> null)
.timeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS, Observable.empty())
)
)
.first(location -> location != null);
}
}
В методе getCurrentLocation мы последовательно опрашиваем сервисы друг за другом, и если получен ненулевой результат выдаём его, игнорируя оставшиеся сервисы. Таким образом, мы можем использовать неограниченное количество сервисов. Далее, в слое presentation, объединим данные классы в модуле LocationModule, с помощью Dagger2:
@Module
public class LocationModule {
final PubApplication mPubApplication;
public LocationModule(PubApplication pubApplication) {
mPubApplication = pubApplication;
}
@Provides
@Singleton
Context providesApplicationContext() {
return mPubApplication;
}
@Provides
@Singleton
LocationManager providesLocationManager() {
return (LocationManager) mPubApplication.getSystemService(Context.LOCATION_SERVICE);
}
@Provides
@Singleton
PermissionsRequester providesRxPermissions(Context context) {
return new PermissionsRequesterImpl(context);
}
@Provides
@Singleton
YandexLocationService provideYandexLocationService() {
return new YandexLocationService();
}
@Provides
@Singleton
SystemLocationService provideSystemLocationService(LocationManager locationManager, PermissionsRequester permissionsRequester) {
return new SystemLocationService(locationManager, permissionsRequester);
}
@Provides
@Singleton
LocationService provideLocationService(YandexLocationService yandexLocationService, SystemLocationService systemLocationService) {
return new CompositeLocationService(yandexLocationService, systemLocationService);
}
}
Всё готово! Теперь можно спокойно использовать GetCurrentLocationUseCase в нашем презентере. Таким образом, пусть и частично, но некоторые проблемы с определением местоположения решены. Диалог с запросом местоположения будет появляться намного реже, и, следовательно, отпугнёт намного меньше пользователей. И с точки зрения архитектуры теперь всё намного лучше, слой presentation отвечает за отображение, data за информацию. Данный пример не идеален, но, думаю, позволяет понять общий принцип решения данных проблем в контексте «чистой архитектуры».
Кратко о технологиях
Чтобы было понятнее, вот еще несколько пояснений, какие технологии мы использовали. Серверная часть расположена на двух серверах DigitalOcean с конфигурацией 1 core/1Gb RAM.
Бэкенд написан на PHP7 c использованием фреймворка Yii2.
1 сервер — db-master, скрипт синхронизации с вконтакте.
2 сервер — db-slave, rest api, web клиент.
Web клиент написан с использованием фреймворка AngularJS v1.5.2.
Геолокация определяется при помощи сервиса Geolocator от Yandex.
И под конец немного статистики. На данный момент в системе около 3200 групп (пабликов), синхронизация с Вконтакте осуществляется в несколько потоков. При текущей конфигурации сервера, очередь на обновление всех групп в среднем составляет 6 минут. За последний месяц было загружено около 2 млн новых постов. Обновляем данные постов только при условии, что они не старше 1 месяца и не более 100 последних в группе
И да, мы гордимся тем, что среди почти 130 проектов наш «Подсмотрено» — один из немногих, представленных на 2 платформах.
Благодаря участию в конкурсе мы смогли проверить свои силы и более объективно взглянуть на свой проект. Испытали невероятный азарт и драйв и еще сильнее сплотили команду одной идеей.
Автор: Fortune777