[Из песочницы] RxSwift в действии — пишем реактивное приложение
Если верить последним тенденциям, то FRP набирает обороты и не собирается останавливаться. Не так давно я столкнулся с проектом, посвященным FRP — ReactiveX, и его реализацией для Swift — RxSwift. На Хабре уже была небольшая статья, которая будет полезна для начального понимания RxSwift. Я хотел бы развить эту тему, поэтому заинтересовавшимся — добро пожаловать под кат!
Лиха беда начало
И это действительно так. Самое сложное, с чем мне пришлось столкнуться — совершенно другое построение программного кода. С моим опытом императивного программирования было тяжело перестраиваться на новый лад. Но чутьё подсказывало мне, что в этом стоит разобраться; у меня ушло 2 недели паники на то, чтобы вникнуть в суть ReactiveX и я не жалею о потраченном времени. Поэтому сразу хотел бы предупредить — статья требует понимания терминов ReactiveX, таких как Observable, Subscriber и т.д.
Итак, начнем. Будем писать простую читалку своей стены с Facebook. Для этого нам понадобятся RxSwift, ObjectMapper для маппинга данных, Facebook iOS SDK и MBProgressHUD для индикации загрузки. Создаем проект в XCode, подключаем к нему вышеуказанные библиотеки (я использую CocoaPods), настраиваем связку с Facebook по инструкции и переходим к кодингу.
Экран логина
Изобретать велосипед мы не будем, просто разместим по центру экрана уже готовую кнопку от Facebook — FBSDKLoginButton:
let loginButton = FBSDKLoginButton()
loginButton.center = self.view.center
loginButton.readPermissions = ["user_posts"]
loginButton.delegate = self
Не забываем добавить делегата FBSDKLoginButtonDelegate для кнопки логина, а также реализовать методы делегата:
// MARK: Facebook Delegate Methods
func loginButton(loginButton: FBSDKLoginButton!, didCompleteWithResult result: FBSDKLoginManagerLoginResult!, error: NSError!) {
if ((error) != nil) {
// Process error
let alert = UIAlertController(title: "Ошибка", message: error.localizedDescription, preferredStyle: .Alert)
alert.addAction(UIAlertAction(title: "OK", style: .Cancel, handler: nil))
self.presentViewController(alert, animated: true, completion: nil)
}
else if result.isCancelled {
let alert = UIAlertController(title: "Ошибка", message: "Result is cancelled", preferredStyle: .Alert)
alert.addAction(UIAlertAction(title: "OK", style: .Cancel, handler: nil))
self.presentViewController(alert, animated: true, completion: nil)
}
else {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let vc = storyboard.instantiateViewControllerWithIdentifier("navController") as! UINavigationController
self.presentViewController(vc, animated: true, completion: nil)
}
}
func loginButtonDidLogOut(loginButton: FBSDKLoginButton!) {
print("User Logged Out")
}
Тут все просто — если ошибка входа или пользователь нажал кнопку «Отмена» на экране авторизации Facebook, то выводим сообщение об этом в виде alert’а, а если все ок — отправляем его на следующий экран со списком новостей. Функцию логаута я не стал трогать. Как мы видим, пока все достаточно тривиально и ни о какой реактивности речи не идет. Тут тоже достаточно тонкий момент — не совать реактивность во все щели помнить про принцип KISS.
Экран новостей
Напишем функцию получения списка новостей со стены Facebook, возвращаемый тип которой будет Observable:
func getFeeds() -> Observable {
return Observable.create { observer in
let parameters = ["fields": ""]
let friendsRequest = FBSDKGraphRequest.init(graphPath: "me/feed", parameters: parameters, HTTPMethod: "GET")
friendsRequest.startWithCompletionHandler { (connection, result, error) -> Void in
if error != nil {
observer.on(.Error(error!))
} else {
let getFeedsResponse = Mapper().map(result)!
observer.on(.Next(getFeedsResponse))
observer.on(.Completed)
}
}
return AnonymousDisposable {
}
}
}
Что происходит в этом коде? Формируется сетевой запрос FBSDKGraphRequest на получение новостей «me/feed», после чего мы отдаем команду на выполнение запроса и отслеживаем статус в блоке completition; в случае ошибки передаем её в Observable, в случае успеха — передаем в Observable полученные данные.
Примечание: я передаю в FBSDKGraphRequest переменную
let parameters = ["fields": ""]
с пустым набором параметров. Это нужно для того, чтобы Facebook не плакал выводил предупреждения в логах о том, что поле fields в параметрах является обязательным. В принципе, все работает и без этого параметра, но мне так спокойней спится.
Немного отойдем от процесса написания приложения и поговорим о маппинге данных. Я решаю эту задачу с помощью ObjectMapper, он позволяет это делать достаточно быстро и просто:
class GetFeedsResponse: Mappable {
var data = [Feed]()
var paging: Paging!
required init?(_ map: Map){
}
// Mappable
func mapping(map: Map) {
data <- map["data"]
paging <- map["paging"]
}
}
class Feed: Mappable {
var createdTime: String!
var id: String!
var story: String?
var message: String?
required init?(_ map: Map){
}
// Mappable
func mapping(map: Map) {
createdTime <- map["created_time"]
id <- map["id"]
story <- map["story"]
message <- map["message"]
}
}
class Paging: Mappable {
var next: String!
var previous: String!
required init?(_ map: Map){
}
// Mappable
func mapping(map: Map) {
next <- map["next"]
previous <- map["previous"]
}
}
Предлагаю сразу написать сетевой запрос для получения детальной информации о новости:
func getFeedInfo(feedId: String) -> Observable {
return Observable.create { observer in
let parameters = ["fields" : "id,admin_creator,application,call_to_action,caption,created_time,description,feed_targeting,from,icon,is_hidden,is_published,link,message,message_tags,name,object_id,picture,place,privacy,properties,shares,source,status_type,story,story_tags,targeting,to,type,updated_time,with_tags"]
let friendsRequest = FBSDKGraphRequest.init(graphPath: "" + feedId, parameters: parameters, HTTPMethod: "GET")
friendsRequest.startWithCompletionHandler { (connection, result, error) -> Void in
if error != nil {
observer.on(.Error(error!))
} else {
print(result)
let getFeedInfoResponse = Mapper().map(result)!
observer.on(.Next(getFeedInfoResponse))
observer.on(.Completed)
}
}
return AnonymousDisposable {
}
}
}
Как мы видим, я передал в переменной parameters кучу полей — это все поля, которые были в документации. Разбирать их все я не буду, только часть. Вот маппинг данных:
class GetFeedInfoResponse: Mappable {
var createdTime: String!
var from: IdName!
var id: String!
var isHidden: Bool!
var isPublished: Bool!
var message: String?
var name: String?
var statusType: String?
var story: String?
var to = [IdName]()
var type: String!
var updatedTime: String!
required init?(_ map: Map){
}
// Mappable
func mapping(map: Map) {
createdTime <- map["created_time"]
from <- map["from"]
id <- map["from"]
isHidden <- map["is_hidden"]
isPublished <- map["is_published"]
message <- map["message"]
name <- map["name"]
statusType <- map["status_type"]
story <- map["story"]
// It necessary that Facebook API have a bad structure
// buffer%varname% is a temporary variable
var bufferTo = NSDictionary()
bufferTo <- map["to"]
if let bufferData = bufferTo["data"] as? NSArray {
for bufferDataElement in bufferData {
let bufferToElement = Mapper().map(bufferDataElement)
to.append(bufferToElement!)
}
}
type <- map["type"]
updatedTime <- map["updated_time"]
}
}
class IdName: Mappable {
var id: String!
var name: String!
required init?(_ map: Map){
}
// Mappable
func mapping(map: Map) {
id <- map["id"]
name <- map["name"]
}
}
Как видим, без ложки дегтя тут тоже не обошлось. Например, при разборе json-объекта «to», который содержит в себе одно поле «data», которое в свою очередь является json-массивом, мне пришлось изворачиваться следующим образом:
var bufferTo = NSDictionary()
bufferTo <- map["to"]
if let bufferData = bufferTo["data"] as? NSArray {
for bufferDataElement in bufferData {
let bufferToElement = Mapper().map(bufferDataElement)
to.append(bufferToElement!)
}
}
В принципе, я мог создать файл с одним полем «data» и спокойно размаппить объект там, но мне показалась глупой сама идея создавать новый файл ради маппинга одного поля. Если у кого-то из Вас есть более элегантное решение этой задачи — я напьюсь на радостях буду рад узнать о нем.
Вернемся к нашим баранам. С Facebook’ом мы связались, получили список новостей в виде Observable, теперь нам нужно отобразить эти данные. Для этой цели я буду использовать шаблон MVVM. Моя вольная трактовка этого шаблона с учетом использования ReactiveX звучит так: во ViewModel есть Observable, который генерирует события, а View подписывается на эти события и обрабатывает их. То есть, ViewModel не зависит от того, кто на него подписан — если подписчики есть, то Observable генерирует данные, если подписчиков нет, то Observable ничего не генерирует (это утверждение верно для «холодных» Observable, т.к. «горячие» Observable всегда генерируют данные). Напишем ViewModel для экрана новостей:
class FeedsViewModel {
let feedsObservable: Observable<[Feed]>
let clickObservable: Observable
// If some process in progress
let indicator: Observable
init(input: (
UITableView
),
dependency: (
API: APIManager,
wireframe: Wireframe
)
) {
let API = dependency.API
let wireframe = dependency.wireframe
let indicator = ViewIndicator()
self.indicator = indicator.asObservable()
feedsObservable = API.getFeeds()
.trackView(indicator)
.map { getFeedResponse in
return getFeedResponse.data
}
.catchError { error in
return wireframe.promptFor(String(error), cancelAction: "OK", actions: [])
.map { _ in
return error
}
.flatMap { error in
return Observable.error(error)
}
}
.shareReplay(1)
clickObservable = input
.rx_modelSelected(Feed)
.flatMap { feed in
return API.getFeedInfo(feed.id).trackView(indicator)
}
.catchError { error in
return wireframe.promptFor(String(error), cancelAction: "OK", actions: [])
.map { _ in
return error
}
.flatMap { error in
return Observable.error(error)
}
}
// If error when click uitableview - set retry() if you want to click cell again
.retry()
.shareReplay(1)
}
}
Давайте разберем код. В поле input мы передаем таблицу из View, в поле dependency — класс API и Wireframe. В классе есть 3 переменные: feedsObservable возвращает Observable со списком новостей, clickObservable является обработчиком нажатия на ячейку таблицы, а indicator это булева переменная, определяющая нужно ли выводить на экран индикатор загрузки. В коде есть сразу 2 любопытных класса — Wireframe и ViewIndicator, остановимся на них поподробней. Wireframe это не что иное, как «реактивная» реализация alert’ов. Я взял эту реализацию из примеров в репозитории RxSwift. ViewIndicator представляет собой трекинг, а функция trackView из этого класса выполняется до тех пор, пока в цепочке выполняется хотя бы одна последовательность, поэтому trackView удобно использовать для показа индикатора загрузки. Я не буду приводить код в данной статье — вы сможете найти его в репозитории проекта, ссылка находится внизу статьи.
Коснемся логики работы наших Observable. Первый — feedsObservable — получает ответ с Facebook, потом в блоке map из полученных данных извлекаются и возвращаются список новостей, потом стоит обработка ошибок, ну и trackView для отображения загрузки. Второй — clickObservable — отслеживает нажатие на ячейку таблицы, после чего вызывает сетевой запрос на получение детальной информации о новости. Супер, с моделью закончили, переходим непосредственно к View.
Я сразу приведу код View, после чего мы с Вами его разберем:
class FeedsViewController: UIViewController, UITableViewDelegate {
@IBOutlet weak var feedsTableView: UITableView!
var disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
let viewModel = FeedsViewModel(
input:feedsTableView,
dependency: (
API: APIManager.sharedAPI,
wireframe: DefaultWireframe.sharedInstance
)
)
let progress = MBProgressHUD()
progress.mode = MBProgressHUDMode.Indeterminate
progress.labelText = "Загрузка данных..."
progress.dimBackground = true
viewModel.indicator
.bindTo(progress.rx_mbprogresshud_animating)
.addDisposableTo(self.disposeBag)
feedsTableView.rx_setDelegate(self)
viewModel.feedsObservable
.bindTo(feedsTableView.rx_itemsWithCellFactory) { tableView, row, feed in
let cell = tableView.dequeueReusableCellWithIdentifier("feedTableViewCell") as! FeedTableViewCell
cell.feedCreatedTime.text = NSDate().convertFacebookTime(feed.createdTime)
if let story = feed.story {
cell.feedInfo.text = story
} else if let message = feed.message {
cell.feedInfo.text = message
}
cell.layoutMargins = UIEdgeInsetsZero
return cell
}
.addDisposableTo(disposeBag)
viewModel.clickObservable
.subscribeNext { feed in
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let feedInfoViewController = storyboard.instantiateViewControllerWithIdentifier("feedInfoViewController") as! FeedInfoViewController
feedInfoViewController.feedInfo = feed
self.navigationController?.pushViewController(feedInfoViewController, animated: true)
}
.addDisposableTo(disposeBag)
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
// Deselect tableView row after click
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
tableView.deselectRowAtIndexPath(indexPath, animated: true)
}
}
Первым делом, создаем viewModel. Далее нужно создать индикатор загрузки и как-то его связать с ViewIndicator. Для этого нам необходимо написать Extension для MBProgressHUD:
extension MBProgressHUD {
/**
Bindable sink for MBProgressHUD show/hide methods.
*/
public var rx_mbprogresshud_animating: AnyObserver {
return AnyObserver {event in
MainScheduler.ensureExecutingOnScheduler()
switch (event) {
case .Next(let value):
if value {
let loadingNotification = MBProgressHUD.showHUDAddedTo(UIApplication.sharedApplication().keyWindow?.subviews.last, animated: true)
loadingNotification.mode = self.mode
loadingNotification.labelText = self.labelText
loadingNotification.dimBackground = self.dimBackground
} else {
MBProgressHUD.hideHUDForView(UIApplication.sharedApplication().keyWindow?.subviews.last, animated: true)
}
case .Error(let error):
let error = "Binding error to UI: \(error)"
#if DEBUG
rxFatalError(error)
#else
print(error)
#endif
case .Completed:
break
}
}
}
}
Если в MBProgressHUD подается какое-то значение, то отображаем индикатор. Если никаких значений не подается — скрываем его. Теперь нам нужно настроить биндинг между нашим MBProgressHUD и ViewIndicator. Делается это вот так:
viewModel.indicator
.bindTo(progress.rx_mbprogresshud_animating)
.addDisposableTo(self.disposeBag)
После чего настраиваем биндинг между UITableView и получением данных, а также между нажатием на элемент UITableView и переходом на новый экран. Экран детальной информации о посте я также сделал без «реактивности»:
override func viewDidLoad() {
super.viewDidLoad()
var feedDetail = "From: " + feedInfo.from.name
if feedInfo.to.count > 0 {
feedDetail += "\nTo: "
for to in feedInfo.to {
feedDetail += to.name + "\n"
}
}
if let date = feedInfo.createdTime {
feedDetail += "\nDate: " + NSDate().convertFacebookTime(date)
}
if let story = feedInfo.story {
feedDetail += "\nStory: " + story
}
if let message = feedInfo.message {
feedDetail += "\nMessage: " + message
}
if let name = feedInfo.name {
feedDetail += "\nName: " + name
}
if let type = feedInfo.type {
feedDetail += "\nType: " + type
}
if let updatedTime = feedInfo.updatedTime {
feedDetail += "\nupdatedTime: " + NSDate().convertFacebookTime(updatedTime)
}
feedTextView.text = feedDetail
self.navigationController?.navigationBar.tintColor = UIColor.whiteColor()
}
На этом у меня все. Исходный код проекта на Github можно скачать по этой ссылке. Если вам понравилась моя статья, то я лопну от счастья с удовольствием продолжу писать про RxSwift и постараюсь раскрыть его потенциал в более нетривиальных задачах.