Бесплатный реалтайм список онлайн юзеров (Parse.com + Pubnub)

от автора

Как-то раз написал мне знакомый задачу для практики: напиши приложение, где есть одна кнопка логина/разлогина и список онлайн пользователь. При этом, пользователи должны «жить» только 30 секунд. Как это всегда бывает, при первичном рассмотрении задачи я подумал: ха, что тут делать то? Используем облачное хранилище и сервер для юзеров, а дальше дело за малым… но не тут то было.

Под катом я расскажу, с какими проблемами при разработке бэкэнда на Parse.com мне пришлось столкнуться, почему пришлось использовать его в связке с Pubnub, и как это всё связать при разработке под Android.

То, что вышло в итоге:

Демонстрация

Строго говоря, темой связки Parse.com и Pubnub уже занимались на Хабре. Однако в отличие от той статьи, здесь я хочу более подробно остановить на облачном коде Parse.com, да и целевое приложение разрабатывает под Android, а не IOS.

Parse.com

Parse.com предоставляет обширный облачный функционал: тут и БД в красивой графической обертке, и серверный код, и аналитика, и даже пуш-уведомления! И всё бесплатно до тех пор, пока ты не перейдешь порог в 30 запросов в секунду, 20ГБ используемого объема хранилища и т.д.. Меня данные требования полностью устраивали (it’s free!), поэтому выбор пал именно на данный сервис.

Проблемы

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

  1. отсутствуют поля типа Timer для кастомных объектов
  2. сервис не предоставляет возможности лонг-пуллинга (либо я такого не обнаружил)
  3. стандартный класс User/Session не подходят для этой задачи

Решения

Поясню, почему это именно проблемы.
Тип Timer (или что-то типа того) планировалось использовать как поле expireAt, чтобы получать оповещения (в идеале, автоматически удалять) о том, когда юзер «умирает».

За отсутствием данного типа придётся использовать обычный тип Date и самому следить, когда юзера нужно «убить».

Планировалось использова лонг-пуллинг для отслеживания входящих/уходящих юзеров, однако сервис не предоставляет такой возможности из коробки.

Было принято решение использовать инсталляции. Вкратце, это глобальные каналы для передачи данных между кем угодно (сервер-клиент, клиент-клиент и т.д.). Таким образом, после логина/разлогина сервер должен посылать сообщение на канал, что такой-то пользователь вошел/вышел. Это обеспечивает реалтайм список пользователей. (однако это не совсем так, но об этом позже).

В SDK Parse.com встроены методы логина/разлогина, поэтому было бы крайне приятно использовать их при разработке. Однако, это оказалось невозможным.

Как было сказано выше, при логине/разлогине должно посылаться сообщение в канал о том, «жив» ли пользователь (или «мертв», если он разлогинился). Сервис предоставляет возможность создавать триггеры, например, AfterSave, beforeDelete и т.д. Проблема в том, что нет таких событий для Session. Это означает, что при каждом разлогине необходимо в прямом смысле удалять юзера с его сессией, что сводит на нет всё приемущество встроенных в SDK методов.

Поэтому было принято решение использовать кастомный класс IMH_Session, навесив на него триггеры afterDelete и afterSave, в которых происходит посылка оповещения в глобальный канал.

Нюансы

И тут бы пора праздновать и победно садиться за Android Studio, но… инсталляции основаны на дефолтных пуш-уведомлениях. Поясню для тех, кто, как и я, в танке. Пуш-уведомления не гарантируют ничего. Они работают по принципу Fire&Forget, то есть, послав пуш-уведомление, нет никакой уверенности в том, что оно дошло до адресата. Более того, невозможно даже сказать когда это произошло!

Так что ни о каком реалтайме говорить не приходится.

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

Pubnub

Несмотря на всю плачевность ситуации выход был найден. Этим выходом оказался Pubnub. Вообще, этот сервис из коробки предоставляет SDK для онлайн-чата, но, к сожалению, оно платно и называется аддоном.

Сам сервис предоставляет всё необходимое для реалтайм приложений. Но нас интересует лишь одно — широковещательные реалтайм каналы. Они бесплатны, просты в истопользовании и, что, пожалуй, самое главное, имеют разграничение доступа! Как раз то, что нам нужно, чтобы не заморачиваться с верификацией.

Разграничение происходит благодаря двум отдельным ключам: publish_key и subscribe_key. Как вы уже наверное догадались, первый ключ уходит на сервер, а воторой на клиентское приложение. Если оставлять первым ключ в секрете, никто не заспамит канал и любому указанному в нём сообщению можно верить. Идеально!

p.s. Я пишу Parse.com, но Pubnub (без `.com`) просто потому, что мне так привычнее. Надеюсь, это никому не режет глаз.

Бэкэнд — Parse.com

Теперь было необходимо приступить к организации API сервера и его реализации. Напомню, что идея пайплайна такова:

  1. кастомный (через API) login()
  2. Parse.com cloud code
  3. создание юзера в БД
  4. триггер afterSave(), оповещающий Pubnub канал о логине юзера
  5. возвращение текущего пользователя в ответ на login()

Для этого были созданы следующие API:

  • Login
  • Logout
  • GetOnlineUsers
  • GetNow

Поясню насчет последнего. Я планировал использовать его для синхронизации клиентского времени с серверным. Сам API я реализовал, но вот до использования в клиенте так руки и не дошли. Однако само API я решил оставить.

Заранее прошу прощения за запах кода. Ни разу не js-разработчик. Учту любые пожелания по его организации:

Серверный код

/*global Parse:false, $:false, jQuery:false */  // Importas var _ = require('underscore'); // jshint ignore:line var moment = require('moment'); // jshint ignore:line  // Constants var sessionObjName = "IMH_Session"; var sessionLifetimeSec = 13;  var channelName = "events"; var publishKey = "pub-c-6271f363-519a-432d-9059-e65a7203ce0e",     subscribeKey = "sub-c-a3d06db8-410b-11e5-8bf2-0619f8945a4f",     httpRequestUrl = 'http://pubsub.pubnub.com/publish/' + publishKey + '/' + subscribeKey + '/0/' + channelName + '/0/';   // Utils function Log(obj, tag) {     "use strict";      var loggingString = "Cloud_code: ";     if (tag != null) { // jshint ignore:line         loggingString += "[" + tag + "] ";     }     loggingString += JSON.stringify(obj) + "\n";      console.log(loggingString); // jshint ignore:line }  function GetNow() {     "use strict";     return moment.utc(); }  // Supporting var baseSession = {udid: "", loginedAt: GetNow(), aliveTo: GetNow()};  var errorHandler = function(error) {     "use strict";     Log(error.message, "error"); };  function DeleteSession(obj) { 	obj.set("loginedAt", obj.get("aliveTo")); 	SendEvent(obj);      obj.destroy(); }  function DeleteDeadSessions() {     "use strict";      var query = new Parse.Query(sessionObjName); // jshint ignore:line     var promise = query.lessThanOrEqualTo("aliveTo", GetNow().toDate())         .each(function(obj)          {              Log(obj, "Delete dead session"); 			 DeleteSession(obj);          }     ); 	return promise; }  function NewSession(udid) {     "use strict";      var session = _.clone(baseSession);     session.udid = udid;     session.loginedAt = GetNow();     session.aliveTo = GetNow().add({seconds: sessionLifetimeSec});      return session; }  function GetSessionQuery() {     "use strict";     var objConstructor = Parse.Object.extend(sessionObjName); // jshint ignore:line 	var query = new Parse.Query(objConstructor);     //query.select("udid", "loginedAt", "aliveTo"); //not work for some reason 	return query; }  function IsUserOnline(udid, onUserOnlineHanlder, onUserOfflineHanlder, onError) { 	"use strict";  	var userAlive = false; 	var query = GetSessionQuery(); 	query.equalTo("udid", udid).greaterThanOrEqualTo("aliveTo", GetNow().toDate()); 	query.find({ 		success: function(result) 		{ 			if (result.length == 0) { 				onUserOfflineHanlder(); 			} 			else { 				onUserOnlineHanlder(result); 			} 		}, 		error: onError 	}); }  function NewParseSession(session) {     "use strict";      var objConstructor = Parse.Object.extend(sessionObjName); // jshint ignore:line     var obj = new objConstructor();      obj.set({         udid: session.udid,         loginedAt: session.loginedAt.toDate(),         aliveTo: session.aliveTo.toDate()         }     );      return obj; }  function SendEvent(session) {     "use strict";      Parse.Cloud.httpRequest({ // jshint ignore:line         url: httpRequestUrl + JSON.stringify(session),          success: function(httpResponse) {},         error: function(httpResponse) {             Log('Request failed with response code ' + httpResponse.status);         }     }); }  // API functions var API_GetNow = function(request, response) {     "use strict";  	var onUserOnline = function(result) { 		response.success( GetNow().toDate() ); 	};  	var onUserOffline = function(error) { 		response.error(error); 	};  	var onError = function(error) { 		response.error(error); 	};  	IsUserOnline(request.params.udid, onUserOnline, onUserOffline, onError); };  var API_GetOnlineUsers = function(request, response) {     "use strict";  	var onUserOnline = function(result) { 		var query = GetSessionQuery() 		    .addDescending("aliveTo"); 		query.find({ 			success: function(result) 			{ 				response.success( JSON.stringify(result) ); 			}, 			error: errorHandler 		}); 	};  	var onUserOffline = function(error) { 		response.error(error); 	};  	var onError = function(error) { 		response.error(error); 	};      DeleteDeadSessions().always( function() { 	    IsUserOnline(request.params.udid, onUserOnline, onUserOffline, onError);     }); };  var API_Login = function(request, response) {     "use strict";      var userUdid = request.params.udid;     var session = NewSession(userUdid);     var parseObject = NewParseSession(session);  	Parse.Cloud.run("Logout", {udid: userUdid}).always( function() { 		parseObject.save(null, { 			success: function(obj) { 				Log(obj, "Login:save"); 				response.success( JSON.stringify(parseObject) ); 			}, 			error: function(error) { 				errorHandler(error); 				response.error(error); 			} 		}); 	}); };  var API_Logout = function(request, response) {     "use strict";      var userUdid = request.params.udid;     var query = GetSessionQuery()         .equalTo("udid", userUdid);      query.each( function(obj) {         Log(obj, "Logout:destroy"); 		DeleteSession(obj);     }).done( function() {response.success();} ); };   // Bindings Parse.Cloud.afterSave(sessionObjName, function(request) { // jshint ignore:line     "use strict";  	SendEvent(request.object); });  // API definitions Parse.Cloud.define("GetNow", API_GetNow); // jshint ignore:line  Parse.Cloud.define("GetOnlineUsers", API_GetOnlineUsers); // jshint ignore:line  Parse.Cloud.define("Login", API_Login); // jshint ignore:line Parse.Cloud.define("Logout", API_Logout); // jshint ignore:line 

Как можно заметить, я обошелся без afterDelete() триггера. Причина в том, что с afterDelete() у меня возникали гонки. С одной стороны, только что вышедший пользователь сейчас удаляется и скоро пошлет оповещение в канал. С другой стороны он в ту же секунду пытается залогиниться снова.
В итоге, в канале будет видно нечто вроде «Х зашел», «Х зашел», «Х вышел». Последние два сообщения не на своих местах. Из-за подобного на клиенте бывали ситуации, когда вроде бы юзер ещё «жив» и вообще только-только зашел, но в онлайн списке не отображается, ведь если верить каналу, то он «мертв».

Больше нюансов!

Как отмечалось ранее, Parse.com вынуждает использовать Date, вместо какого-нибудь Timer’а для организации expireAt (в нашем случае, aliveTo). Но вот вопрос — а когда же проверять всех юзеров на то, «живы» ли они или уже «мертвы»?

Одно из решений — использовать Job и удалять неактивных пользователей каждые 5-10 секунд. Но строго говоря, это уже не совсем реалтайм. Я хотел, чтобы пользователи «умирали» мгновенно, вне зависимости от какой-то бэкграуд-Job’ы (кстати, у неё ограничение на максимальное время выполнения — 15 минут. Так что её пришлось бы пересоздавать постоянно). Поэтому был реализован иной подход.

Как выглядит обычная жизнь юзера:

Login -> GetOnlineUsers -> Logout

или

Login -> GetOnlineUsers -> свернул приложение, то есть, пропустил сообщения в канале -> GetOnlineUsers -> Logout

Было решено удалять «мертвых» юзеров в тот момент, когда кто-нибудь запрашивает GetOnlineUsers. Это означает, что, по факту, в БД могут храниться «мертвые» юзеры хоть сколько долго до тех пор, пока кто-нибудь не запросит список «живых». В этот момент удалятся все мертвые пользователи (в лучших традициях ленивых вычислений).

Таким образом, за «жизнью» юзеров придётся следить локально на клиенте. Оповещение в канале о смерти юзера придёт только в том случае, если он разлогинился сам. В противном случае, юзер считается живым вечно.

Android

Pubnub

Pubnub SDK, а точнее его бесплатную часть, очень легко использовать. Для начала была сделана обёртка над Pubnub, чтобы, если что, можно было использовать любой другой сервис:

Обертка над Pubnub — Channel

public class PubnubChannel extends Channel { 	static private final String CHANNEL_NAME = "events"; 	static private final String SUBSCRIBE_KEY = "sub-c-a3d06db8-410b-11e5-8bf2-0619f8945a4f";  	Pubnub pubnub = new Pubnub("", SUBSCRIBE_KEY); 	Callback pubnubCallback = new Callback() { 		@Override 		public void connectCallback(String channel, Object message) { 			if (listener != null) { 				listener.onConnect(channel, "Connected: " + message.toString()); 			} 		}  		@Override 		public void disconnectCallback(String channel, Object message) { 			if (listener != null) { 				listener.onDisconnect(channel, "Disconnected: " + message.toString()); 			} 		}  		@Override 		public void reconnectCallback(String channel, Object message) { 			if (listener != null) { 				listener.onReconnect(channel, "Reconnected: " + message.toString()); 			} 		}  		@Override 		public void successCallback(String channel, Object message, String timetoken) { 			if (listener != null) { 				listener.onMessageRecieve(channel, message.toString(), timetoken); 			} 		}  		@Override 		public void errorCallback(String channel, PubnubError error) { 			if (listener != null) { 				listener.onErrorOccur(channel, "Error occured: " + error.toString()); 			} 		} 	};  	public PubnubChannel() { 		setName(CHANNEL_NAME); 	}  	@Override 	public void subscribe() throws ChannelException { 		try { 			pubnub.subscribe(CHANNEL_NAME, pubnubCallback); 		} catch (PubnubException e) { 			e.printStackTrace(); 			throw new ChannelException(ChannelException.CONNECT_ERROR, e); 		} 	}  	@Override 	public void unsubscribe() { 		pubnub.unsubscribeAll(); 	} } 

Затем была сделана обёртка над обёреткой (да-да), чтобы отслеживать не какие-то сообщения в канале, а контретных юзеров:

Обертка на Channel — ServerChannel

public class ServerChannel { 	Logger l = LoggerFactory.getLogger(ServerChannel.class);  	JsonParser jsonParser; 	Channel serverChannel;  	ServerChannel.EventListener listener; 	private final Channel.EventListener listenerAdapter = new Channel.EventListener() { 		@Override 		public void onConnect(String channel, String greeting) {  		}  		@Override 		public void onDisconnect(String channel, String reason) { 			if (listener != null) { 				listener.onDisconnect(reason); 			} 		}  		@Override 		public void onReconnect(String channel, String reason) {  		}  		@Override 		public void onMessageRecieve(String channel, String message, String timetoken) { 			if (listener != null) { 				ServerChannel.this.onMessageRecieve(message, timetoken); 			} 		}  		@Override 		public void onErrorOccur(String channel, String error) { 			l.warn(String.format("%s : [error] %s", channel, error)); 			if (listener != null) { 				ServerChannel.this.unsubscribe(); 			} 		} 	};  	public ServerChannel(Channel serverChannel, JsonParser jsonParser) { 		this.serverChannel = serverChannel; 		this.jsonParser = jsonParser; 	}  	public final void setListener(@NonNull ServerChannel.EventListener listener) { 		this.listener = listener; 	}  	public final void clearListener() { 		listener = null; 	}  	public final void subscribe() throws ChannelException { 		try { 			serverChannel.setListener(listenerAdapter); 			serverChannel.subscribe(); 		} catch (ChannelException e) { 			e.printStackTrace(); 			serverChannel.clearListener(); 			throw e; 		} 	} 	public final void unsubscribe() { 		serverChannel.unsubscribe(); 		serverChannel.clearListener(); 	}  	public void onMessageRecieve(String userJson, String timetoken) { 		DyingUser dyingUser = jsonParser.fromJson(userJson, DyingUser.class); 		if (dyingUser != null) { 			if (dyingUser.isAlive()) { 				listener.onUserLogin(dyingUser); 			} else { 				listener.onUserLogout(dyingUser); 			} 		} 	}  	public interface EventListener { 		void onDisconnect(String reason); 		void onUserLogin(DyingUser dyingUser); 		void onUserLogout(DyingUser dyingUser); 	} } 

Parse.com

Опять же, ничего сложного. Вся логика хранится на сервере. Всё, что нам нужно — использовать API и парсить json в объекты.

AuthApi

public class AuthApi extends Api { 	static final String 			API_Login = "Login", 			API_Logout = "Logout";  	@Inject 	public AuthApi(JsonParser parser) { 		super(parser); 	}  	public DyingUser login(@NonNull final String udid) throws ApiException { 		DyingUser dyingUser; 		try { 			String jsonObject = ParseCloud.callFunction(API_Login, constructRequestForUser(udid)); 			dyingUser = parser.fromJson(jsonObject, DyingUser.class); 		} catch (ParseException e) { 			e.printStackTrace(); 			throw new ApiException(ApiException.LOGIN_ERROR, e); 		} 		return dyingUser; 	}  	public void logout(@NonNull final DyingUser dyingUser) { 		try { 			ParseCloud.callFunction(API_Logout, constructRequestForUser(dyingUser.getUdid())); 		} catch (ParseException e) { 			e.printStackTrace(); 		} 	} } 

UserApi

public class UserApi extends Api { 	static final String 			API_GetOnlineUsers = "GetOnlineUsers";  	@Inject 	public UserApi(JsonParser parser) { 		super(parser); 	}  	public final ArrayList<DyingUser> getOnlineUsers(@NonNull final DyingUser dyingUser) throws ApiException { 		ArrayList<DyingUser> users; 		try { 			String jsonUsers = ParseCloud.callFunction(API_GetOnlineUsers, constructRequestForUser(dyingUser.getUdid())); 			users = parser.fromJson(jsonUsers, new TypeToken<List<DyingUser>>(){}.getType()); 		} catch (ParseException e) { 			e.printStackTrace(); 			throw new ApiException(ApiException.GET_USERS_ERROR, e); 		} 		return users; 	} } 

Ну и базовый класс:

Api

abstract class Api { 	final JsonParser parser;  	Api(JsonParser parser) { 		this.parser = parser; 	}  	protected Map<String, ?> constructRequestForUser(@NonNull final String udid) 	{ 		Map<String, String> result = new HashMap<>(); 		result.put("udid", udid); 		return result; 	} } 

Используя приведенные классы и их методы мы получаем доступ к логину, разлогину и получению онлайн списка пользователя.

Реалтайм

Обновление UI

Так как юзеры «умирают» и довольно быстро, было решено выводить их оставшееся время жизни. Так как время жизни измеряется в секундах да и цель задачи в обеспечении реалтайма, то и обновляться UI должно не реже, чем раз в секунду. Для этого был сделан класс TimeTicker, объект которого хранится в Activity. Фрагменты Activity во время onAttach() получают от Activity() объект TimeTicker (для этого служит интерфейс TimeTicker.Owner) и подписываются на его события.

TimeTicker

public class TimeTicker extends Listenable<TimeTicker.EventListener> { 	private static final long TICKING_PERIOD_MS_DEFAULT = 1000; 	private static final boolean DO_INSTANT_TICK_ON_START_DEFAULT = true; 	long tickingPeriodMs; 	boolean doInstantTickOnStart;  	final Handler uiHandler = new Handler(Looper.getMainLooper()); 	final Timer tickingTimer = new Timer(); 	TimerTask tickingTask;  	public TimeTicker() { 		this(DO_INSTANT_TICK_ON_START_DEFAULT); 	}  	public TimeTicker(boolean doInstantTickOnStart) { 		this.doInstantTickOnStart = doInstantTickOnStart; 		setTickingPeriodMs(TICKING_PERIOD_MS_DEFAULT); 	}  	public void setTickingPeriodMs(final long tickingPeriodMs) { 		this.tickingPeriodMs = tickingPeriodMs; 	}  	public synchronized void start() { 		if (tickingTask != null) { 			stop(); 		}  		tickingTask = new TimerTask() { 			@Override 			public void run() { 				uiHandler.post(new Runnable() { 					@Override 					public void run() { 						forEachListener(new ListenerExecutor<TimeTicker.EventListener>() { 							@Override 							public void run() { 								getListener().onSecondTick(); 							} 						}); 					} 				}); 			} 		};  		long delay = (doInstantTickOnStart) ? 0 : tickingPeriodMs; 		tickingTimer.scheduleAtFixedRate(tickingTask, delay, tickingPeriodMs); 	}  	public synchronized void stop() { 		if (tickingTask != null) { 			tickingTask.cancel(); 		} 		tickingTask = null; 		tickingTimer.purge(); 	}  	public interface EventListener extends Listenable.EventListener { 		void onSecondTick(); 	}  	public interface Owner { 		TimeTicker getTimeTicker(); 	} } 

Таким образом обеспечивается обновление UI раз в секунду, а значит, всё выглядит будто юзеры действительно постепенно умирают.

Список «умирающих» юзеров

Эта проблема мне показалась наиболее интересной из всех, связанных с данной задачей: у нас есть список юзеров, которые «умирают». Их время приближается к нулю, и когда это случается, юзер должен быть удален из списка.

Самая простая реализация — привязать таймер к каждому юзеру и удалять его при достижении «смерти». Однако это не особо интересное решение. Давайте извращаться! Вот такая реализация вышла у меня с применением одного таймера и возможностью pause/resume (если приложение свернуто, например, это очень пригождается).

Этот код я ни разу не рефакторил с тех пор, как написал его в первый раз, так что он может быть не особо хорошим:

TemporarySet

public class TemporarySet<TItem> extends Listenable<TemporarySet.EventListener> implements Resumable { 	protected final SortedSet<TemporaryElement<TItem>> sortedElementsSet = new TreeSet<>(); 	protected final List<TItem> list = new ArrayList<>();  	protected final Timer timer = new Timer(); 	protected TimerTask timerTask = null; 	protected TemporaryElement<TItem> nextElementToDie = null;  	boolean isResumed = false;  	public TemporarySet() { 		notifier = new TemporarySet.EventListener() { 			@Override 			public void onCleared() { 				for (TemporarySet.EventListener listener : getListenersSet()) { 					listener.onCleared(); 				} 			}  			@Override 			public void onAdded(Object item) { 				for (TemporarySet.EventListener listener : getListenersSet()) { 					listener.onAdded(item); 				} 			}  			@Override 			public void onRemoved(Object item) { 				for (TemporarySet.EventListener listener : getListenersSet()) { 					listener.onRemoved(item); 				} 			} 		}; 	}  	public boolean add(TItem object, DateTime deathTime) { 		TemporaryElement<TItem> element = new TemporaryElement<>(object, deathTime); 		return _add(element); 	}  	public boolean remove(TItem object) { 		TemporaryElement<TItem> element = new TemporaryElement<>(object); 		return _remove(element); 	}  	public void clear() { 		_clear(); 	}  	public final List<TItem> asReadonlyList() { 		return Collections.unmodifiableList(list); 	}  	private synchronized void _clear() { 		cancelNextDeath(); 		list.clear(); 		sortedElementsSet.clear();  		notifier.onCleared(); 	}   	private synchronized boolean _add(TemporaryElement<TItem> insertingElement) { 		boolean wasInserted = _insertElementUnique(insertingElement);  		if (wasInserted) { 			if (nextElementToDie != null && 					nextElementToDie.deathTime.isAfter(insertingElement.deathTime)) { 				cancelNextDeath(); 			}  			if (nextElementToDie == null) { 				openNextDeath(); 			}  			notifier.onAdded(insertingElement.object); 		}  		return wasInserted; 	}  	private synchronized boolean _remove(TemporaryElement<TItem> deletingElement) { 		boolean wasDeleted = _deleteElementByObject(deletingElement);  		if (wasDeleted) { 			if (nextElementToDie.equals(deletingElement)) { 				cancelNextDeath(); 				openNextDeath(); 			}  			notifier.onRemoved(deletingElement.object); 		}  		return wasDeleted; 	}  	private synchronized void openNextDeath() { 		cancelNextDeath(); 		if (sortedElementsSet.size() != 0) { 			nextElementToDie = sortedElementsSet.first(); 			timerTask = new TimerTask() { 				@Override 				public void run() { 					_remove(nextElementToDie); 				} 			};  			DateTime now = new DateTime(); 			Duration duration = TimeUtils.GetNonNegativeDuration(now, nextElementToDie.deathTime);  			timer.schedule(timerTask, duration.getMillis()); 		} 	}  	private synchronized void cancelNextDeath() { 		if (timerTask != null) { 			timerTask.cancel(); 		} 		timer.purge(); 		nextElementToDie = null; 		timerTask = null; 	}  	private synchronized Iterator<TemporaryElement<TItem>> findElement(TemporaryElement<TItem> searchingElement) { 		Iterator<TemporaryElement<TItem>> resultIterator = null; 		for (Iterator<TemporaryElement<TItem>> iterator = sortedElementsSet.iterator(); iterator.hasNext() && resultIterator == null;) { 			if (iterator.next().equals(searchingElement)) { 				resultIterator = iterator; 			} 		} 		return resultIterator; 	}  	private synchronized boolean _insertElementUnique(TemporaryElement<TItem> element) { 		boolean wasInserted = false;  		Iterator<TemporaryElement<TItem>> iterator = findElement(element); 		if (iterator == null) { 			wasInserted = true; 			sortedElementsSet.add(element); 			list.add(element.object); 		}  		return wasInserted; 	}  	private synchronized boolean _deleteElementByObject(TemporaryElement<TItem> element) { 		boolean wasDeleted = false;  		Iterator<TemporaryElement<TItem>> iterator = findElement(element); 		if (iterator != null) { 			wasDeleted = true; 			iterator.remove(); 			list.remove(element.object); 		}  		return wasDeleted; 	}  	@Override 	public void resume() { 		isResumed = true; 		openNextDeath(); 	}  	@Override 	public void pause() { 		cancelNextDeath(); 		isResumed = false; 	}  	@Override 	public boolean isResumed() { 		return isResumed; 	}  	public interface EventListener extends Listenable.EventListener { 		void onCleared(); 		void onAdded(Object item); 		void onRemoved(Object item); 	} } 

Хочу заметить, что здесь есть неиспользуемый мною метод asReadonlyList. Раньше он применялся в качестве аргумента Adapter для ListFragment, что позволяло и вовсе не использовать никаких EventListener. Но позднее я решил отойти от этой затеи, а вот код решил оставить (для будущего себя, чтобы видеть, как делать не стоит).

Самая большая вакханалия в этом списке творится в методах findElement, _insertElementUnique и _deleteElementByObject. Причина в том, что SortedSet хранит объекты, отсортированные по дате и, соответственно, поиск происходит тоже по дате. Однако когда юзер «умирает», сервер посылает сообщение, в котором loginedAt == deathAt, что приводит к сумасшествию SortedSet и всего TemporarySet.

Так как в Java нет нормальных Pair<A,B>, была реализована обёртка:

TemporaryElement

class TemporaryElement<T> implements Comparable { 	protected final T object; 	protected final DateTime deathTime;  	public TemporaryElement(@NonNull T object, @NonNull DateTime deathTime) { 		this.deathTime = deathTime; 		this.object = object; 	}  	public TemporaryElement(@NonNull T object) { 		this(object, new DateTime(0)); 	}  	@Override 	public boolean equals(Object o) { 		if (this == o) return true; 		if (o == null || getClass() != o.getClass()) return false;  		TemporaryElement<?> that = (TemporaryElement<?>) o;  		return object.equals(that.object); 	}  	@Override 	public int hashCode() { 		return object.hashCode(); 	}  	@Override 	public int compareTo(@NonNull Object another) { 		TemporaryElement a = this, 				b = (TemporaryElement) another;  		int datesComparisionResult = a.deathTime.compareTo(b.deathTime); 		int objectsComparisionResult = a.hashCode() - b.hashCode(); 		return (datesComparisionResult != 0) ? datesComparisionResult : objectsComparisionResult; 	} } 

В итоге, реализованный TemporarySet позволяет добавлять/удалять юзеров со временем жизни, после чего останется лишь реализовать интерфейс TemporarySet.EventListener и ждать.

Заключение

Задачка оказалась сложнее, чем изначально планировалась. Я потратил тучу времени на разбор Parse.com Guide. Вот например один из ньюансов:

afterSave

Parse.Cloud.afterSave("Foo", function(request) {}); // custom Foo object Parse.Cloud.afterSave("User", function(request) {}); // custom(!) User object Parse.Cloud.afterSave(Parse.User, function(request) {}); // Parse.com User object Parse.Cloud.afterSave(Parse.Session, function(request) {}); // error! can't bind to Parse.Session 

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

Весь мой код можно посмотреть здесь.

Справедливости ради, хочется отметить, что было бы неплохо добавить к API что-то вроде GetUsersChangesAfterDate(), который позволял бы получить изменения в списке пользователей после указанной даты (то бишь, свернул приложение -> развернул -> GetUsersChangesAfterDate).

И в конце я бы хотел задать несколько вопросов читателю:

  1. Можно ли было это сделать проще, но так же бесплатно?
  2. Есть ли более простой способ обновления UI каждые N секунд?
  3. Что делать со временем жизни «0:0» у юзера? Следует ли искусственно добавить 1 секунду ко времени жизни, чтобы юзер «умирал» после «0:1»? Или это решается как-то иначе? Или оставить «0:0» — это нормально?

ссылка на оригинал статьи http://habrahabr.ru/post/266617/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *