Pull to refresh

Как собрать WhatsApp за сутки. Часть 1

Reading time 12 min
Views 112K


Здравствуйте, дорогие читатели Хабрахабра!

В этой серии статей я расскажу, как быстро и почти безболезненно поднять свой собственный WhatsApp под iOS. Статью делю на две части для вашего удобства:

  1. Создание проекта, простой UI, привязка к сервису мгновенных сообщений
  2. Делаем красивый UI, добавляем видео и аудио звонки, передачу фото и документов

К сожалению, пособие о том, как набрать 400 000 000 пользователей и продать сервис за 19 Инстаграмов, затерялось где-то на книжной полке. Постараюсь его найти, если кому интересно.

Заинтересовавшихся прошу под кат.

Создание проекта


Открываем Xcode и создаем новый проект.



Берем Single View Application за основу.



Вводим все данные для приложения и жмем «Next». Я выбрал наименее претенциозные регалии.



И проект готов.



Но, что это такое? Какая ужасная сортировка файлов по группам! Давайте это поправим.



Так-то лучше! Вы можете использовать свой способ сортировки файлов, но в этом руководстве я буду придерживаться модели выше. Кстати говоря, комбинация клавиш для создания новой группы — это Command + Alt + N.

Простой UI


Тем временем, я позволил себе создать новый класс NKLoginViewController и привязать его к UIViewController объекту в Interface Builder. Этот View Controller будет первым, что увидит пользователь. Это и логично — никакого чата без регистрации!



Продолжая развлекаться, я прикрутил текстовые поля, как Outlet, и Action кнопки «Войти» к нашему NKLoginViewController. Считаю это хорошим тоном, прикручивать IB объекты в .m файлах, дабы они были недоступны извне. Более того, мне нравится, когда код поделен на «Прагмы».



Создаем еще один контроллер (как представление в IB, так и новый класс) — список чатов. Используем стандартный код UITableViewController — нам никакого сверхъестественного функционала тут не нужно, пока что.



Слегка изменим код NKChatListTableViewController.m, чтобы в таблице хоть что-то отображалось:

Жми меня!
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return 20;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
    cell.textLabel.text = @"Vasiliy Pupkin";
    return cell;
}

Теперь подумаем над навигацией. Все приложение у нас будет встроено в один UINavigationController и контроллеры мы будем «пушить» и «попать» в зависимости от ситуации. Встроим же приложение в UINavigationController! Let the magic time begin!



Добавляем названия контроллеров и Segue от Login View Controller до Chat List Table View Controller. Назовем ее «SegueToChatList». Вот так выглядит наше приложение сейчас.



Слегка поработаем над кодом Login View Controller. Дадим пользователю возможность убирать клавиатуру. Для этого мы сделаем контроллер делегатом текстовых полей.



А сам код контроллера поправим следующим образом:

NKLoginViewController.h
#import <UIKit/UIKit.h>

// Добавим нужный протокол к интерфейсу
@interface NKLoginViewController : UIViewController <UITextFieldDelegate>

@end

NKLoginViewController.m
#import "NKLoginViewController.h"

@interface NKLoginViewController ()

@property (weak, nonatomic) IBOutlet UITextField *emailTextField;
@property (weak, nonatomic) IBOutlet UITextField *passwordTextField;

- (IBAction)loginTouched:(UIButton *)sender;

@end

@implementation NKLoginViewController

#pragma mark - UITextFieldDelegate -

// Сделаем так, чтобы по нажатию "Done" клавиатура пряталась
- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
    [textField resignFirstResponder];
    return YES;
}

#pragma mark - Button methods -

// Пока что просто переместимся на следующий экран по нажатию кнопки "Войти".
- (IBAction)loginTouched:(UIButton *)sender
{
    [self performSegueWithIdentifier:@"SegueToChatList" sender:self];
}

На время перенесем все элементы на контроллере логина вверх — это ведь простой UI. О том, как интерактивно перемещать элементы интерфейса вверх при появлении клавиатуры, я расскажу в следующей части.

Наше приложение уже можно потыкать!



Создаем третий — и последний — контроллер. Попадать в него мы будем при помощи нажатия на ячейку предыдущего контроллера. Сам контроллер состоит из UITableView, источником данных которого назначен контроллер, текстового поля и кнопки «Отправить». Полагаю, этот экран интуитивно понятен.



Код NKChatViewController.m ниже:

Жми меня!
#import "NKChatViewController.h"

@interface NKChatViewController ()

@property (weak, nonatomic) IBOutlet UITableView *tableView;
@property (weak, nonatomic) IBOutlet UITextField *messageTextField;

- (IBAction)sendTouched:(UIButton *)sender;

@end

@implementation NKChatViewController

#pragma mark - View Controller life cycle -

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    
    // Показываем клавиатуру, как только попадаем на контроллер
    [_messageTextField becomeFirstResponder];
}

#pragma mark - UITableViewDataSource -

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return 20;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"];
    cell.textLabel.text = @"Вася Пупкен";
    cell.detailTextLabel.text = @"Привет, как дела?";
    return cell;
}

#pragma mark - Button methods -

- (IBAction)sendTouched:(UIButton *)sender
{
    
}

@end

Простенький UI для нашего мессенджера готов. Приступаем к самому интересному — начинке приложения!

Привязка к сервису мгновенных сообщений


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

Все, что вам нужно сделать — это зарегистрировать на c2call.com и купить учетку за $100. К сожалению, в бесплатной версии не работает регистрация через low-language API. Возможно, что-то изменится на момент прочтения вами этой статьи. Однако вместо помесячной оплаты C2Call сняли с меня $100 и, похоже, забыли про меня. Больше денег не списывали. Я не призываю вас ни покупать продукт, ни пытать удачу с месячной подпиской. Полагаю, мне просто повезло.

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



Переносим их в наш проект.



Добавляем следующие фреймворки и библиотеки в проект:

Ужасающий список фреймворков и библиотек
AVFoundation.framework
Accounts.framework
AdSupport.framework
AddressBook.framework
AddressBookUI.framework
AssetsLibrary.framework
AudioToolbox.framework
CFNetwork.framework
CoreAudio.framework
CoreData.framework
CoreFoundation.framework
CoreLocation.framework
CoreMedia.framework
CoreTelephony.framework
CoreText.framework
CoreVideo.framework
MapKit.framework
MediaPlayer.framework
MessageUI.framework
MobileCoreServices.framework
OpenGLES.framework
QuartzCore.framework
QuickLook.framework
Security.framework
StoreKit.framework
SystemConfiguration.framework
iAd.framework
libsqlite3.dylib
libz.dylib

И прописываем следующее в Build Settings:

HEADER_SEARCH_PATHS = /usr/include/libxml2
OTHER_LDFLAGS = -lxml2 -lstdc++
ARCHS = armv7
VALID_ARCHS = armv7

Теперь немного поменяем App Delegate:

NKAppDelegate.h
#import <UIKit/UIKit.h>
#import <SocialCommunication/SocialCommunication.h>

@interface NKAppDelegate : C2CallAppDelegate <UIApplicationDelegate>

@property (strong, nonatomic) UIWindow *window;

@end

NKAppDelegate.m
@implementation NKAppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.affiliateid = @"6B9DF5671444320162B";
    self.secret = @"2fd9cd18aa4d957a4030c0455101646d";
    
    return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

@end

Мы засабклассили элемент от C2Call, да рассказали ему о наших данных. Ваши Affiliate ID и Secret вы можете посмотреть в админке сервиса.

Закончили с настройкой фреймворка, пора его использовать.

Создадим подкласс NSObject под названием NKChat, в котором мы инкапсулируем всю логику чата. Думаю, будет правильным дать вам примерный листинг кода NKChat.m, а после объяснить его.

NKChat.m
#import "NKChat.h"
#import <SocialCommunication/SocialCommunication.h>

@implementation NKChat

#pragma mark - Singleton pattern -

// 1
+ (instancetype)sharedManager
{
    static NKChat *sharedChat = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedChat = [self new];
    });
    return sharedChat;
}

#pragma mark - Accessors -

// 2
- (NSArray *)chatHistory
{
    return [self fetchChatHistory];
}

#pragma mark - General methods -

// 3
- (void)login:(NSString *)email password:(NSString *)password success:(void(^)())successBlock failure:(void(^)())failureBlock
{
    NSDictionary *dictionary = @{@"EMail":email,
                                 @"Password":password};
    
    [[C2CallPhone currentPhone] registerUser:dictionary
                       withCompletionHandler:^(BOOL success, NSString *result) {                           if (success) {
                               [[C2CallPhone currentPhone] startC2CallPhone];
                               successBlock();
                           } else {
                               failureBlock();
                           }
                       }];
}

// 4
- (void)logout
{
    [(C2CallAppDelegate *)[UIApplication sharedApplication].delegate logoutUser];
}

// 5
- (void)sendMessage:(NSString *)message toUser:(NSString *)userId
{
    [[C2CallPhone currentPhone] submitMessage:message toUser:userId];
}

// 6
- (NSArray *)fetchChatHistory
{
    // Получаем все Managed Object истории чата
    NSFetchRequest *request = [[SCDataManager instance] fetchRequestForChatHistory:YES];
    NSFetchedResultsController *controller = [[SCDataManager instance] fetchedResultsControllerWithFetchRequest:request sectionNameKeyPath:nil cacheName:nil];
    NSError *error;
    [controller performFetch:&error];
    
    // Собираем результирующий массив
    NSMutableArray *result = [NSMutableArray array];
    for (NSManagedObject *chat in controller.fetchedObjects) {
        // Получаем словарь чата
        NSArray *chatKeys = @[@"contact", @"lastTimestamp", @"missedEvents"];
        NSMutableDictionary *inChat = [[chat dictionaryWithValuesForKeys:chatKeys] mutableCopy];
        
        // Проверяем на дубликаты
        NSMutableDictionary *dublicate = nil;
        for (NSMutableDictionary *dict in result) {
            if ([dict[@"contact"] isEqualToString:inChat[@"contact"]]) {
                dublicate = dict;
                break;
            }
        }
        
        // Получаем все сообщения
        NSMutableArray *messages = (dublicate) ? dublicate[@"messages"] : [NSMutableArray array];
        for (NSManagedObject *chatEvent in [chat valueForKey:@"chatHistory"]) {
            NSArray *chatEventKeys = [[[chatEvent entity] attributesByName] allKeys];
            NSMutableDictionary *inChatEvent = [[chatEvent dictionaryWithValuesForKeys:chatEventKeys] mutableCopy];
            //            NSLog(@"%@",inChatEvent);
            inChatEvent[@"ManagedObject"] = chatEvent;
            [messages addObject:inChatEvent];
        }
        [messages sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"timevalue" ascending:YES]]];
        
        if (dublicate) {
            dublicate[@"messages"] = messages;
            [dublicate[@"ManagedObjects"] addObject:chat];
            dublicate[@"missedEvents"] = @([dublicate[@"missedEvents"] intValue] + [inChat[@"missedEvents"] intValue]);
            if (!dublicate[@"name"])
                dublicate[@"name"] = inChat[@"name"];
        } else {
            inChat[@"messages"] = messages;
            inChat[@"ManagedObjects"] = [NSMutableArray arrayWithObject:chat];
        }
        
        // Добавляем словарь в результат
        if (!dublicate)
            [result addObject:inChat];
    }
    
    // Сортируем результат
    [result sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"lastTimestamp" ascending:NO]]];
    
    // Возвращаем результирующий массив
    return [result copy];
}

@end

Пойдем по-порядку:

  1. Стандартный шаблон — синглтон. Ничего необычного для вас здесь быть не должно. У нас один объект, который отвечает за чат — больше не надо.
  2. Метод-аксессор, который возвращает массив истории чата в нужной нам форме.
  3. Метод для регистрации и логина. Фишка C2Call в том, что, когда вы входите с одними данными в первый раз, вы регистрируетесь. Когда вы входите с теми же данными второй раз, вы просто входите. Этот метод как-раз и недоступен бесплатным подписчикам, к сожалению. Вы можете обойти этот метод добавив нативное окно регистрации от C2Call, дабы сэкономить.
  4. Метод для логаута. Дешево и сердито.
  5. Метод для посылки сообщения — тоже довольно прост.
  6. Ужасный и монструозный метод-скатерть, который возвращает в нужном формате историю чата. Здесь собраны все камни, о которые можно столкнуться, используя C2Call. Во-первых, данные хранятся в Core Data. Во-вторых, имена контактов постоянно разные — то id придет, то имя и фамилия. В-третьих, забудьте пока что про этот метод. Он работает и для этого туториала сойдет :)

Ну, когда все готово для работы, пора использовать магию кода!

Добавьте в NKAppDelegate.m инициализацию NKChat, если еще не сделали этого.

NKAppDelegate.m
#import "NKAppDelegate.h"

@implementation NKAppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.affiliateid = @"6B9DF5671444320162B";
    self.secret = @"2fd9cd18aa4d957a4030c0455101646d";
    [NKChat sharedManager];
    return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

@end

Теперь слегка изменим метод loginTouched у класса NKLoginViewController. Не забудьте сделать импорт NKChat!

Жми меня!
- (IBAction)loginTouched:(UIButton *)sender
{
    sender.enabled = NO;
    [[NKChat sharedManager] login:_emailTextField.text
                         password:_passwordTextField.text
                          success:^{
                              [self performSegueWithIdentifier:@"SegueToChatList" sender:self];
                              sender.enabled = YES;
                          }
                          failure:^{
                              sender.enabled = YES;
                          }];
}

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

В этой части туториала мы будем работать с двумя учетными записями: nikita@borodutch.com и luke@borodutch.com. Мы просто захардкодим возможность отправлять сообщения этим двум контактам, временно.

Слегка изменим NKChatListTableViewController.m таким образом, чтобы можно было отправлять сообщения только этим двум контактам.

Жми меня!
#import "NKChatListTableViewController.h"

@interface NKChatListTableViewController ()

@end

@implementation NKChatListTableViewController

#pragma mark - UITableViewDataSource -

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return 2;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
    cell.textLabel.text = (indexPath.row) ? @"nikita@borodutch.com" : @"luke@borodutch.com";
    return cell;
}

@end

Результат манипуляций:



Добавим метод передачи информации в следующий контроллер в NKChatListTableViewController.m.

Жми меня!
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(UITableViewCell *)sender
{
    UIViewController *dest = segue.destinationViewController;
    dest.title = sender.textLabel.text;
}

Нам осталось только получать нужную историю чата и отправлять сообщения нужным контактам! Дело в шляпе, сударь.

Как в старые добрые времена, приведу листинг NKChatViewController.m вместе с объяснениями чуть ниже.

Жми меня!
#import "NKChatViewController.h"
#import <SocialCommunication/SocialCommunication.h>
#import "NKChat.h"

@interface NKChatViewController ()

@property (weak, nonatomic) IBOutlet UITableView *tableView;
@property (weak, nonatomic) IBOutlet UITextField *messageTextField;

- (IBAction)sendTouched:(UIButton *)sender;

@end

@implementation NKChatViewController
{
    NSArray *tableData;
}

#pragma mark - View Controller life cycle -

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // 1
    tableData = [self getTableData];
}

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    
    // Показываем клавиатуру, как только попадаем на контроллер
    [_messageTextField becomeFirstResponder];
    
    // 2
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(receivedMessage) name:@"kC2CallPhoneReceivedMessage" object:nil];
}

#pragma mark - UITableViewDataSource -

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    // 3
    return tableData.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    // 4
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"];
    cell.textLabel.text = ([tableData[indexPath.row][@"eventType"] isEqualToString:@"MessageIn"]) ? self.title : @"Я";
    cell.detailTextLabel.text = tableData[indexPath.row][@"text"];
    return cell;
}

#pragma mark - Button methods -

- (IBAction)sendTouched:(UIButton *)sender
{
    // 5
    [[NKChat sharedManager] sendMessage:_messageTextField.text
                                 toUser:@"c45645f71465dcff18e"];
    [self addMessage:_messageTextField.text];
    _messageTextField.text = @"";
}

#pragma mark - General Methods -

- (void)addMessage:(NSString *)message
{
    // 6
    NSMutableArray *mTableData = [tableData mutableCopy];
    [mTableData addObject:@{@"text":message,
                            @"eventType":@"MessageOut"}];
    tableData = mTableData;
    [_tableView reloadData];
}

- (void)receivedMessage
{
    // 7
    tableData = [self getTableData];
    [_tableView reloadData];
}

- (NSArray *)getTableData
{
    // 8
    for (NSDictionary *chat in [NKChat sharedManager].chatHistory)
        if ([chat[@"contact"] isEqualToString:self.title])
            return chat[@"messages"];
    return nil;
}

@end

По-порядку:

  1. Как только контроллер загрузился, мы заполняем его нужными данными
  2. kC2CallPhoneReceivedMessage — это дефиниция названия нотификации о том, что пришло новое сообщение; подписываемся на это событие
  3. Нам нужно столько ячеек, сколько всего сообщений есть в истории этого чата
  4. Каждой ячейке даем нужное имя контакта и сообщение
  5. Отправляем сообщение при помощи метода из NKChat; добавляем сообщение в локальные данные контроллера, потому что сообщению нужно время для того, чтобы оно добавилось в историю C2Call; очищаем поле отправки
  6. Метод добавления сообщения в локальные данные контроллера. Полагаю, интуитивно понятен
  7. При получении сообщения нужно перезагрузить историю в контроллере и заставить таблицу обновить свои данные
  8. Просто проходимся по всей истории и возвращаем историю нужного нам контакта

Вот, что у нас получилось (большая гифка):



Заключение


Огромное спасибо, что дошли до конца первой части этого руководства. В скором времени, как появится пара свободных деньков, напишу вторую часть. Исходный код первой части тут.

Во второй части мы решим несколько щепетильных вопросов по UI, обойдем пару багов C2Call (например, тот, что виден на последней гифке с получением сообщений), добавим функционала приложению и влепим пару-тройку котиков.

Если у вас есть какие-либо вопросы по туториалу, смело задавайте их в комментариях — на все отвечу.

В случае обнаружения вами опечаток или неточностей в статье, прошу обращаться в мой хабрацентр.

До скорой встречи.
Tags:
Hubs:
+114
Comments 55
Comments Comments 55

Articles