Comments 27
Спасибо за обзор!
Тоже сделали соцсеть на базе Parse а-ля Instagram, но пока до лимитов на запросы она не добралась. Надеюсь, Parse работает над этой проблемой, иначе дорого.
Тоже сделали соцсеть на базе Parse а-ля Instagram, но пока до лимитов на запросы она не добралась. Надеюсь, Parse работает над этой проблемой, иначе дорого.
А как вы делаете UI? Используете ли сториборды, ксибы?
В этом проекте все было реализовано на ксибах. А вообще я не против использования сторибордов.
чем плохи сториборды? (спрашиваю как совсем новичек в этом деле, желаюсь поучиться) у меня вот в гриде три вида кастомных ячеек и я все сделал через сториборд, а не отдельными ксибами (я начинающий и приложение маленькое), догадываюсь что подход не правильный, но выглядит все не так уж плохо. Где правда? )
Я не хочу устраивать очередной холивар на эту тему, потому что ее уже разобрали по косточкам. Например тут: www.raywenderlich.com/51992/storyboards-vs-nibs-vs-code-the-great-debate
(см. комментарии)
(см. комментарии)
Если кратко — сториборд
1) тормозит. При достаточном колиестве контроллеров, айпадовский тормозит на топовых аймаках этого года
2) если верстать юай прямо в нем, то нужно будет все то же самое повторять для айпад-версии
НО — его можно использовать как карту контроллеров, а вьюхи контроллеров держать в ксибах — ммы например так и делаем.
1) тормозит. При достаточном колиестве контроллеров, айпадовский тормозит на топовых аймаках этого года
2) если верстать юай прямо в нем, то нужно будет все то же самое повторять для айпад-версии
НО — его можно использовать как карту контроллеров, а вьюхи контроллеров держать в ксибах — ммы например так и делаем.
Спасибо за ответ.
Вот именно как «карта контроллеров» меня и привлек сториборд, да и в учебниках «для начинающих» не упоминается о подходе с ксибами, хотя с подходом «чисто код» много примеров, а вот с ксибами нет (может проглядел, но не попадалось). А вот как начал копать глубже то уже и пошло кодом и ксибами. Попробую ваш подход в следущем приложении, если оно конечно будет :))
Сейчас меня просто вогнал в ступор подход к событям. После c# это просто какой-то ад) Еще и такие противоречивые мнение про эти штуки типа KVO и т.д. Думаю что бы такого нагородить, наверно в итоге будет какойто мрак типа делегатов с методами, которые будут менять состояние UI элементов.
Вот именно как «карта контроллеров» меня и привлек сториборд, да и в учебниках «для начинающих» не упоминается о подходе с ксибами, хотя с подходом «чисто код» много примеров, а вот с ксибами нет (может проглядел, но не попадалось). А вот как начал копать глубже то уже и пошло кодом и ксибами. Попробую ваш подход в следущем приложении, если оно конечно будет :))
Сейчас меня просто вогнал в ступор подход к событям. После c# это просто какой-то ад) Еще и такие противоречивые мнение про эти штуки типа KVO и т.д. Думаю что бы такого нагородить, наверно в итоге будет какойто мрак типа делегатов с методами, которые будут менять состояние UI элементов.
Не так все и сложно. А KVO используется не очень часто, в нашем довольно большом проекте — около 2х раз.
А расскажите пожалуйста попобробнее про «Callback hell» и чем помог async.js? Сам веду проект использующий Parse в качестве бэкенда. Очень интересен Ваш опыт в этом плане :)
На cloud code основная сложность в том, чтобы вызвать финальный callback только после выполнения всех необходимых операций. Все операции идут асинхронно. Самый простое решение, расположить вызовы функций таким образом:

И в конечном callback, самой последней функции, мы просто вызываем наш финальный callback, который возвращает ответ клиенту.
Поначалу я забивал на этот ужас, а потом заинтересовался как же все-таки можно хотя бы(!) уменьшить ширину кода, есть ли какой-нибудь syntactic sugar. Сначала, я начал везде пользоваться promises — они делают код чуть чуть читабельнее, все-таки это не было панацеей и тогда я нашел async.js, которая позволяет получать финальный callback, после завершения сколь угодно большого количества функций выполняющихся параллельно, последовательно, в цикле с разными параметрами и т.д.
У меня стояла такая задача: после того как пользователь удаляет свою картинку, нам нужно удалить ее или произвести другие действия со всеми таблицами в которых она упоминается. Сначала это выглядело так (заранее прошу прощения за грязный код, но пример очень иллюстративный):
А с async.parallel так:

И в конечном callback, самой последней функции, мы просто вызываем наш финальный callback, который возвращает ответ клиенту.
Поначалу я забивал на этот ужас, а потом заинтересовался как же все-таки можно хотя бы(!) уменьшить ширину кода, есть ли какой-нибудь syntactic sugar. Сначала, я начал везде пользоваться promises — они делают код чуть чуть читабельнее, все-таки это не было панацеей и тогда я нашел async.js, которая позволяет получать финальный callback, после завершения сколь угодно большого количества функций выполняющихся параллельно, последовательно, в цикле с разными параметрами и т.д.
У меня стояла такая задача: после того как пользователь удаляет свою картинку, нам нужно удалить ее или произвести другие действия со всеми таблицами в которых она упоминается. Сначала это выглядело так (заранее прошу прощения за грязный код, но пример очень иллюстративный):
функция очень большая, и читать не удобно.
exports.deletePictureFromUserProfile = function(deletingPictureId, customCallBack) {
var arrayForDelete = [];
picturesQuery = new Parse.Query("Picture");
picturesQuery.equalTo("objectId", deletingPictureId);
var query = new Parse.Query("LockSlot");
query.matchesQuery("picture", picturesQuery);
query.find().then(function(results){
if(results){
for(var i=0,l=results.length; i < l ; i++){
results[i].unset("picture");
results[i].save(null,{
success: function(myObject) {
return [];
},
error: function(myObject, error) {
console.error(error);
return [];
}
});
}
}else{
return [];
}
}).then(function(results){
var query = new Parse.Query("xxxxx");
query.matchesQuery("onePicture", picturesQuery);
query.find().then(function(results){
arrayForDelete.push.apply(arrayForDelete,results);
return arrayForDelete;
}).then(function(results){
var query = new Parse.Query("xxxx");
query.matchesQuery("onePicture", picturesQuery);
query.find().then(function(results){
arrayForDelete.push.apply(arrayForDelete,results);
return arrayForDelete;
}).then(function(results){
var query = new Parse.Query("xxxx");
query.matchesQuery("pictureFrom", picturesQuery);
query.find().then(function(results){
arrayForDelete.push.apply(arrayForDelete,results);
return arrayForDelete;
}).then(function(results){
var query = new Parse.Query("xxxxx");
query.matchesQuery("pictureTo", picturesQuery);
query.find().then(function(results){
arrayForDelete.push.apply(arrayForDelete,results);
return arrayForDelete;
}).then(function(results){
var query = new Parse.Query("xxxxx");
query.matchesQuery("picture", picturesQuery);
query.find().then(function(results){
arrayForDelete.push.apply(arrayForDelete,results);
return arrayForDelete;
}).then(function(results){
var query = new Parse.Query("xxxxx");
query.matchesQuery("picture", picturesQuery);
query.find().then(function(results){
customCallBack();
}).then(function(results){
var query = new Parse.Query("Picture");
query.equalTo("objectId", deletingPictureId);
query.first().then(function(image){
arrayForDelete.push(image);
if(arrayForDelete){
console.log("we got arrray for delete");
var num_of_deleted_objects = 0;
for(var i=0,l=arrayForDelete.length; i < l ; i++){
var object = arrayForDelete[i];
if(object){
object.destroy().then(function(results){
num_of_deleted_objects++;
if(num_of_deleted_objects==l){
customCallBack();
}
});
}
}
}
}, function(error) {
console.error(error);
customCallBack();
});
}, function(error) {
console.error(error);
customCallBack();
});
}, function(error) {
console.error(error);
customCallBack();
});
}, function(error) {
console.error(error);
customCallBack();
});
}, function(error) {
console.error(error);
customCallBack();
});
}, function(error) {
console.error(error);
customCallBack();
});
}, function(error) {
console.error(error);
customCallBack();
});
}, function(error) {
console.error(error);
customCallBack();
});
}
А с async.parallel так:
функция очень большая, но читать удобнее
function deletePictureObjFromUserProfile(picture, customCallBack){
var resultArrayForDelete = [];
resultArrayForDelete.push(picture);
async.parallel([
function(callback){
var query = new Parse.Query("xxxx");
query.equalTo("pict", picture);
query.find().then(function(results){
if(results.length>0){
var currentOperation =0;
var numOfOperation = results.length;
function enumCallBack(){
currentOperation++;
if(currentOperation==numOfOperation)
callback();
};
for(var i=0,l=results.length; i < l ; i++){
results[i].unset("pict");
results[i].save(null,{
success: function(myObject) {
enumCallBack();
},
error: function(myObject, error) {
console.error(error);
enumCallBack();
}
});
}
}else{
callback();
}
},
function(error){
console.error(error);
callback();
});
},
function(callback){
var query = new Parse.Query("xxxxx");
query.equalTo("onePict", picture);
query.find().then(function(results){
resultArrayForDelete.push.apply(resultArrayForDelete, results);
callback();
},
function(error){
console.error(error);
callback();
});
},function(callback){
var query = new Parse.Query("xxxxx");
query.equalTo("anotherPict", picture);
query.find().then(function(results){
resultArrayForDelete.push.apply(resultArrayForDelete, results);
callback();
},
function(error){
console.error(error);
callback();
});
},function(callback){
var query = new Parse.Query("xxxxxx");
query.equalTo("pictFrom", picture);
query.find().then(function(results){
resultArrayForDelete.push.apply(resultArrayForDelete, results);
callback();
},
function(error){
console.error(error);
callback();
});
},function(callback){
var query = new Parse.Query("xxxxxxx");
query.equalTo("pictTo", picture);
query.find().then(function(results){
resultArrayForDelete.push.apply(resultArrayForDelete,results);
callback();
},
function(error){
console.error(error);
callback();
});
},function(callback){
var query = new Parse.Query("xxxxxxxxxxx");
query.equalTo("pict", picture);
query.find().then(function(results){
resultArrayForDelete.push.apply(resultArrayForDelete, results);
callback();
},
function(error){
console.error(error);
callback();
});
},function(callback){
var query = new Parse.Query("xxxxxxx");
query.equalTo("pict", picture);
query.find().then(function(results){
resultArrayForDelete.push.apply(resultArrayForDelete,results);
},
function(error){
console.error(error);
callback();
});
}
],
function(error, results){
if(error)
console.error(error);
if(resultArrayForDelete.length>0){
Parse.Object.destroyAll(resultArrayForDelete, function(success, error) {
if(error)
console.error(error);
customCallBack();
},
function(error){
customCallBack();
});
}else{
customCallBack();
}
});
}
У стандартных Parse.Promises есть замечательная особенность: их можно выстраивать в цепочки. Т.е. если один из коллбеков then (success или error) вернет Promise, то Promise возвращенный сам then не будет завершен пока не завершится Promise из коллбека. В коде выглядит немного симпатичнее чем на словах :)
Чище всего выглядят тесты, поэтому приведу кусок оттуда. Я использую для тестирования JQUnit. ok() и equal() — это функции проверок этого фреймворка, а start(); — функция завершающая асинхронный тест.
Прокомментировал в коде пару трюков.
Я так понимаю что для любой CommonJS совместимой реализации Promises этот код будет выглядеть примерно так же. После того как я освоил эти трюки мне сильно захотелось что-то похожее получить на стороне клиента (IOS). Смотрел в сторону ReactiveCocoa. Но почему-то там все так красиво не выходит.
А еще мне сильно упростило код использование расширения классов. Выглядит у меня это примерно так:
С этой библиотечкой все становится еще красивее: github.com/icangowithout/parse-ph
Для декларации Cloud функций я сделал свою обертку, которая ждет из функции реализующей логику Promise и уже его результат интерпретирует как:
Прошу прощения за огрызок. Код слегка не универсален и ждет совего рефакторинга.
Как я написал выше, для тестов мне приглянулся JQUnit. Я просто размещаю тесты в каталоге public и открываю как обычные веб страницы. Причем я использую отдельное приложение для тестирования, без или с минимумом ограничений прав (по тесту это видно :)). И планирую использовать еще одно для тестирования с установленными ограничениями. И одно для публикации рабочей версии приложения. Пока даже не могу до конца оценить насколько эта мысль была удачной. Явных проблем еще не выявил.
Поддержание симметричной структуры хранилищ в Parse это боль. Эту мысль я полностью поддерживаю :) Не знаю, что с этим делать. Радует только то, что прогон тестов по хранилищу с отключенными ограничениями генерирует большую часть модели данных :)
Есть еще маленькое ноухау по поводу тестирования. Но пока все на уровне экспериментов. Возможно когда-нибудь напишу статью :)))
PS: Спасибо за статью! Было очень приятно краем глаза взглянуть на устройство чужих проектов использующих этот backend. В сети пока не так уж много интересных решений на этот счет. Слохно найти хороший пример.
Чище всего выглядят тесты, поэтому приведу кусок оттуда. Я использую для тестирования JQUnit. ok() и equal() — это функции проверок этого фреймворка, а start(); — функция завершающая асинхронный тест.
asyncTest("Register and activate account", function() {
var password = "SECRET",
user, newUser,
deed, registrationId;
Parse.Cloud.run("register", {
params: {
firstName: "REMOVEME",
lastName: "REMOVEME"
},
password: password
}).then(function(id) {
registrationId = id;
return Parse.User.signUp("testUser", "testPassword").then(function() {
return Parse.Promise.as();
}, function(error) {
return Parse.User.logIn("testUser", "testPassword");
});
}).then(function() {
return Parse.Cloud.run("activate", {
id: registrationId,
password: password
}).then(function() {
ok(false, "User can activate if Deed is not created!");
return Parse.Promise.error();
}, function(error) {
// Если вернуть удачно завершенный Promise даже из error callback, дальше
// по цепочке будет вызван success callback
// Тот же фокус наоборот сработает для success callback выше
ok(true, "Can't activate if deed is not created yet");
return Parse.Promise.as();
});
}).then(function() {
// Тут интересный момент: и success и error callback возвращают пустой успешно
// завершенный Promise. А это значит мы всегда попадем в Teardown
ok(true, "All ok!");
return Parse.Promise.as();
}, function(error) {
ok(false, error && error.message ? error.message : JSON.stringify(error));
return Parse.Promise.as();
}).then(function() {
// Teardown
return Parse.Object.destroyAll(_.compact([user, newUser, deed]));
}).then(function() {
start();
}, function() {
start();
});
});
Прокомментировал в коде пару трюков.
Я так понимаю что для любой CommonJS совместимой реализации Promises этот код будет выглядеть примерно так же. После того как я освоил эти трюки мне сильно захотелось что-то похожее получить на стороне клиента (IOS). Смотрел в сторону ReactiveCocoa. Но почему-то там все так красиво не выходит.
А еще мне сильно упростило код использование расширения классов. Выглядит у меня это примерно так:
beens.Points = Parse.Object.extend("Points", {}, {
get: function (user, date) {
var query = new Parse.Query("Points");
query.equalTo("user", user);
query.equalTo("date", date);
return query.first().then(function (record) {
if (typeof record === "undefined") {
record = new beens.Points();
record.set("user", user);
record.set("userId", user.id);
record.set("date", date);
}
return Parse.Promise.as(record);
});
}
});
С этой библиотечкой все становится еще красивее: github.com/icangowithout/parse-ph
Для декларации Cloud функций я сделал свою обертку, которая ждет из функции реализующей логику Promise и уже его результат интерпретирует как:
.then(function (result) {
response.success(result);
}, function (error) {
console.log("ERROR: " + JSON.stringify(error));
response.error(error);
});
Прошу прощения за огрызок. Код слегка не универсален и ждет совего рефакторинга.
Как я написал выше, для тестов мне приглянулся JQUnit. Я просто размещаю тесты в каталоге public и открываю как обычные веб страницы. Причем я использую отдельное приложение для тестирования, без или с минимумом ограничений прав (по тесту это видно :)). И планирую использовать еще одно для тестирования с установленными ограничениями. И одно для публикации рабочей версии приложения. Пока даже не могу до конца оценить насколько эта мысль была удачной. Явных проблем еще не выявил.
Поддержание симметричной структуры хранилищ в Parse это боль. Эту мысль я полностью поддерживаю :) Не знаю, что с этим делать. Радует только то, что прогон тестов по хранилищу с отключенными ограничениями генерирует большую часть модели данных :)
Есть еще маленькое ноухау по поводу тестирования. Но пока все на уровне экспериментов. Возможно когда-нибудь напишу статью :)))
PS: Спасибо за статью! Было очень приятно краем глаза взглянуть на устройство чужих проектов использующих этот backend. В сети пока не так уж много интересных решений на этот счет. Слохно найти хороший пример.
Еще один момент, возможно кому-то будет полезен:
В моем случае IDE Brackets + модуль JSHint вычищают значительную часть самых глупых ошибок еще до убликации кода на Parse. А команда:
позволяет сделать процесс публикации незаметным.
Кроме того, для Brackets уже достаточно много интересных модулей, делающих разработку приятнее.
В моем случае IDE Brackets + модуль JSHint вычищают значительную часть самых глупых ошибок еще до убликации кода на Parse. А команда:
parse develop <имя приложения>
позволяет сделать процесс публикации незаметным.
Кроме того, для Brackets уже достаточно много интересных модулей, делающих разработку приятнее.
А async.js удобно использовать, когда тебе надо параллельно несколько функций запустить, например, когда у тебя запуск cloud code не укладывается в timeout.
Да, тут надо всю информацию по разработке на Parse в отдельную статью оформлять. Я еще могу поделиться тем, как писал код для подсчета статистики — там я столкнулся и c timeout и с burst limit. А еще с тем, что на parse не работают setTimeout() и sortBy()
Да, тут надо всю информацию по разработке на Parse в отдельную статью оформлять. Я еще могу поделиться тем, как писал код для подсчета статистики — там я столкнулся и c timeout и с burst limit. А еще с тем, что на parse не работают setTimeout() и sortBy()
Для параллельных процессов в Parse.Promise есть метод when(). Выглядит примерно так:
А по поводу статистики будет очень интересно :) Я строил рейтинги пользователей. И для меня, как человека выросшего на SQL, построить эту часть системы было особенно сложно.
return Parse.Promise.when([userQuery.find(),
commentQuery.find(),
Parse.Cloud.run("someFunction")])
.then(function(users, comments, someResult) {
return Parse.Promise.as("Bingo!");
}, function(userError, commentsError, callError){
return Parse.Promise.error("Error!");
});
А по поводу статистики будет очень интересно :) Я строил рейтинги пользователей. И для меня, как человека выросшего на SQL, построить эту часть системы было особенно сложно.
Ну я предполагал, что можно красивое решение найти, жаль что мы тут поспешили. Спасибо за очень полезную информацию!
А можешь поподробнее описать как ты environment для тестов поднял?
«Я просто размещаю тесты в каталоге public и открываю как обычные веб страницы» то есть папка public лежит в cloud на parse? А как там код можно запускать не через API, а в браузере?
А можешь поподробнее описать как ты environment для тестов поднял?
«Я просто размещаю тесты в каталоге public и открываю как обычные веб страницы» то есть папка public лежит в cloud на parse? А как там код можно запускать не через API, а в браузере?
Да все верно тесты лежат в виде обычных html-страниц в cloud на Parse. Сейчас они запускаются только вручную.
Тесты разбиваются на несколько отдельных страниц, для удобства тестирования отдельных подсистем.
В Parse сейчас заведено 3 приложения. 1-е для публикации, 2-е для разработчиков клиентской части и тестирования со всеми ограничениями, 3-е для backend разработки и тестирования. На текущем этапе эту схему полноценно пока реализовать не удалось. По сути большая часть работ происходит в 3-м приложении в т.ч. разработка клиентской части.
Тестируемые компоненты делятся на 2-х типа:
1. Cloud Code
Для них через JavaScript API создаются тестовые данные и проверяется работоспособность функций и триггеров. В тесте жестко прописаны ключи приложения которое тестируется. Для этих целей у нас заведено отдельное тестовое приложение с ослабленными ограничениями прав доступа.
2. Модули не зависимые от Parse API
Такой код выносится в отдельные Javascript модули и тестируется, как обычная JavaScript библиотека независимо. Модуль дублируется в public для теста и cloud для Cloud кода. В браузере такие модули я подключаю через require.js. В Cloud Code через его родной require().
С такими тестами очень удобно отслеживать результаты запросов. Уточнить какие-то вещи отладной кода теста. Можно выполнять запросы вручную.
CI в проекте я пока не внедрял. JQUnit умеет генерировать отчеты в XML, а сервер непрерывной интеграции может запускать их через безголовый браузер вроде phantome.js. Планирую поднять его позже.
Тесты разбиваются на несколько отдельных страниц, для удобства тестирования отдельных подсистем.
В Parse сейчас заведено 3 приложения. 1-е для публикации, 2-е для разработчиков клиентской части и тестирования со всеми ограничениями, 3-е для backend разработки и тестирования. На текущем этапе эту схему полноценно пока реализовать не удалось. По сути большая часть работ происходит в 3-м приложении в т.ч. разработка клиентской части.
Тестируемые компоненты делятся на 2-х типа:
1. Cloud Code
Для них через JavaScript API создаются тестовые данные и проверяется работоспособность функций и триггеров. В тесте жестко прописаны ключи приложения которое тестируется. Для этих целей у нас заведено отдельное тестовое приложение с ослабленными ограничениями прав доступа.
2. Модули не зависимые от Parse API
Такой код выносится в отдельные Javascript модули и тестируется, как обычная JavaScript библиотека независимо. Модуль дублируется в public для теста и cloud для Cloud кода. В браузере такие модули я подключаю через require.js. В Cloud Code через его родной require().
С такими тестами очень удобно отслеживать результаты запросов. Уточнить какие-то вещи отладной кода теста. Можно выполнять запросы вручную.
CI в проекте я пока не внедрял. JQUnit умеет генерировать отчеты в XML, а сервер непрерывной интеграции может запускать их через безголовый браузер вроде phantome.js. Планирую поднять его позже.
Не могли бы вы объяснить, почему для чата использовался платный сервис PubNub, а не XMPP?
Для использования XMPP нужен сервер. Клиент не хотел сервера ни в каком виде. Даже для шедулинга и запуска некоторых скриптов на Parse мы использовали беслатный сервис iron.io, только потому что клиент настаивал на server less решении.
В следующий раз приглашаю опробовать наш QuickBlox — вот готовый пример iOS кода чата на XMPP, оптимизированный нами и заскейленный амазоном. XMPP сервер автоматически предоставляется с админкой и прочим фаршем. По цене на платных/enterprise пакетах дешевле, чем Parse и Kinvey + есть уникальные фичи, такие как видеозвонки, которых у прочих BaaS просто нет.
Если интересно, напишу статью здесь о том, как легко вставить текстовый чат и видеочат в своё iOS или Android приложение.
Если интересно, напишу статью здесь о том, как легко вставить текстовый чат и видеочат в своё iOS или Android приложение.
Мы рассматривали Ваш сервис в качестве backend для нашего проекта на начальных этапах. И честно говоря выбор был сложный :)
С радостью почитал бы про его преимущества в сравнении с Parse. Интереснее всего узнать про Ваш ответ на Cloud Code, типовое устройство системы безопасности, тестирование и подключение сторонних компонент :)
Хотя и использование XMPP тема тоже весьма актуальная.
С радостью почитал бы про его преимущества в сравнении с Parse. Интереснее всего узнать про Ваш ответ на Cloud Code, типовое устройство системы безопасности, тестирование и подключение сторонних компонент :)
Хотя и использование XMPP тема тоже весьма актуальная.
было бы интересно почитать статью про чат, и думаю не только мне!
По поводу сниппета
__weak typeof(self) weakSelf = self;
рекомендую libextobjc с его @weakify/@strongifyкстати — маленький лайфхак с базой для тестирования в Parse. У нас необходимость в тестовой базе появилась только после выхода в продакшн, так что само приложение под iOS было готово. Я завел новое приложение в parse.com, подставил ключики в iOS приложение, разрешил в настройках парса создание классов на стороне пользователя, потыкался по всему функционалу приложения и вуаля — на парсе пустая база со всеми нужными классами и полями. По крайней мере справедливо для parseSDK.
Да, вроде это тоже работает, но полной уверенности в том что вся модель простроиться нету. Например не совсем понятно как будут создаваться Pointer на объекты. Пару раз мы давали тестерам протестировать работу приложения с не обновленной моделью базы для, специально чтобы проверить как работает автосоздание полей, вроде все ок, но при обновлении продакшен БД на такие эксперементы я бы не пошел.
Sign up to leave a comment.
Социальная сеть без сервера. История разработки iOS-клиента и backend