company_banner

С любовью к дизайнерам: внедряем веб-формы в мобильное приложение

    При разработке мобильного приложения для проекта, которому приходится работать с большим количеством внешних систем, неизбежно возникают ситуации, в которых приходится проявлять находчивость и смекалку. Особенно часто такие ситуации возникают при попытках реализовать программно полет мысли дизайнера с учетом технических особенностей таких систем. О том, как мы решаем такие задачи при работе над мобильным приложением Денег Mail.Ru, мы расскажем в этой статье.


         
    Итак, у нас имеется внешняя веб страница проекта-партнера, которая содержит веб-форму. Страница отлично работает во встроенном в приложение браузере, но ее внешний вид не совпадает с представлениями о прекрасном нашего отдела дизайна и выглядит внутри неорганично. Дизайнеры рисуют новую красивую форму и дают команду: «Должно выглядеть так!». У всех свои задачи, но наша общая цель – качественное приложение.

    Наша задача ясна. Приступаем к реализации. Внедрить форму в приложение в новом дизайне — ничего сложного. Но как быть с веб-формой?

    Навскидку, можно реализовать программно логику работы страницы с формой. Потом сформировать HTTP-запрос, эмулирующий нажатие кнопки «Отправить», и передать его в UIWebView

    Однако, при всей простоте у такого подхода есть подводные камни. Форма запросто может содержать в себе CSRF-токен (тогда нам придется загружать страницу и парсить токен, чтобы передать его в итоговом запросе), список выбора значений, которые могут часто меняться на стороне сервера (тоже загружать и парсить), да и вообще манипулировать состоянием одного или нескольких скрытых полей формы (привет, JavaScript!) в зависимости от данных, введенных пользователем. Все это достаточно усложняет задачу, не находите?
     
    Есть другой путь! И на сцене под овации зрителей появляется маэстро Костыль. Что мы делаем?

    Все очень просто. Берем скрытый от глаз пользователя UIWebView, загружаем туда нашу веб-страницу и манипулируем с ее объектами DOM при помощи JavaScript.

    Рассмотрим данную технику на простом примере. В качестве подопытного кролика возьмем форму поиска в правом верхнем углу главной страницы Хабра, которая имеет следующее HTML-представление:
     
    <div class="search">
      <form id="search_form" name="search" method="get" action="//habrahabr.ru/search/">
        <input type="submit" value="">
        <input type="text" name="q" x-webkit-speech="" speech="" tabindex="1" autocomplete="off">
      </form>
    </div>


    Форма проста и содержит в себе только одно текстовое поле ввода и кнопку, поэтому является идеальным объектом для эксперимента.
    Первым делом создаем контроллер, который будет управлять веб-формой.

    @interface MRWebViewController () <UIWebViewDelegate>
    @property (nonatomic, weak, readonly) UIWebView *webView;
    @property (nonatomic, strong, readonly) NSURLRequest *request;
    @property (nonatomic, assign) BOOL hasForm;
     
    // ...
     
    @end
     
    @implementation MRWebViewController {
    }
     
    // ...
    - (instancetype)initWithURLString:(NSString *)urlString {
        self = [super init];
        if (self) {
            _request = [NSURLRequest requestWithURL:[NSURL URLWithString:urlString]];
        }
        return self;
    }
     
    - (void)viewDidLoad {
        [super viewDidLoad];
        [self createWebView];
        self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
        self.view.alpha = 0.0;
    }
     
    - (void)createWebView {
        UIWebView *webView = [[UIWebView alloc] initWithFrame:self.view.bounds];
        webView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
        webView.backgroundColor = UIColor.whiteColor;
        webView.scalesPageToFit = YES;
        webView.delegate = self;
        [self.view addSubview:webView];
        _webView = webView;
    }
     
    // ...
     
    - (void)reload {
        self.hasForm = NO;
        self.view.alpha = 0.0;
        [self.webView stopLoading];
        [self.webView loadRequest:self.request];
    }
     
    // ...
     
    @end


    Наш контроллер содержит UIWebView, в который мы будем загружать страницу с формой, и объект NSURLRequest, который мы будем использовать для хранения запроса для загрузки страницы. Указание свойства autoresizingMask для объекта view позволит в дальнейшем без проблем использовать данный контроллер в качестве child view controller, а свойством alpha будем управлять его видимостью.

    Создадим где-то в недрах нашего проекта объект контроллера и загрузим в него страницу с формой.

    static NSString *kMRHabraURLString = @"http://habrahabr.ru";
     
    MRWebViewController *controller = [[MRWebViewController alloc] initWithURLString:kMRHabraURLString];
    
    [controller reload];
    

     
    При этом результат загрузки страницы перехватим соответствующей функцией делегата в нашем контроллере. Манипулировать объектами DOM удобно при помощи jQuery. Поэтому убедимся, что в загруженной странице он точно будет присутствовать.

    - (void)webViewDidFinishLoad:(UIWebView *)webView {
        if (!self.hasForm) {
            NSLog(@"Installing jQuery at %@", webView.request.URL.absoluteString);
            [self.webView stringByEvaluatingJavaScriptFromString:[MRScriptsFactory jqueryScript]];
            self.hasForm = YES;
        } // ...
    }


    Процесс загрузки веб-страницы происходит асинхронно, и хотя страница еще может до конца не загрузиться, ничто не мешает нам в этот момент уже отобразить пользователю нативную форму, реализованную программно. При этом нативная форма берет на себя ответственность за ввод и проверку данных, получаемых от пользователя.

    После того, как пользователь заполнил нативную форму и нажал в ней на кнопку «Искать», наш контроллер получает сообщение searchWithString:.

    - (BOOL)searchWithString:(NSString *)searchString {
        BOOL result = NO;
        if (self.hasForm) {
            // ...
            NSString *actualString = [searchString stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"];
            NSString *script = [NSString stringWithFormat:[MRScriptsFactory fillFormScript], actualString];
            NSString *scriptResult = [self.webView stringByEvaluatingJavaScriptFromString:script];
            __autoreleasing NSError *error = nil;
            id object = [NSJSONSerialization JSONObjectWithData:[scriptResult dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&error];
            result = (!error && [object isKindOfClass:[NSDictionary class]] && [object[@"success"] boolValue]);
            
            // ...
        }
        return result;
    }


    В нашем случае скрипт, получаемый через [MRScriptsFactory fillFormScript], имеет вид:
    
    (function ($, searchString) {
        var components = {
            $text : $("form#search_form input[type='text']"),
            $submit : $("form#search_form input[type='submit']")
        };
        components.$text.val(searchString);
        components.$submit.click();
        return JSON.stringify({
            "success" : true
        });
    })(jQuery, '%@');


    Как видно из исходного кода скрипта, он производит заполнение текстового поля формы строкой поиска и программно эмулирует нажатие на кнопку формы.

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

    - (void)webViewDidFinishLoad:(UIWebView *)webView {
        if (!self.hasForm) {
           // ...
        } else if (self.isScriptExecuting) {
            [UIView animateWithDuration:0.3 animations:^{
                self.view.alpha = 1.0;
            }];
            self.scriptExecuting = NO;
            // ...
        }
    }


    Данный подход успешно применяется нами длительное время и хорошо себя зарекомендовал. Полный исходный код примера располагается здесь

    Если у вас есть вопросы, или вы хотите поделиться своими best practices по работе с формами, предлагаю обсудить это в комментариях.
    Mail.Ru Group 713,82
    Строим Интернет
    Поделиться публикацией
    Комментарии 16
      0
      А как же заявленный JS на странице формы, который якобы может манипулировать со списком значений выпадающих списков (я уж не говорю о валидации, которая может висеть на onSubmit/onChange/onKeyPress)?
        +1
        Так это ничем не противоречит. Мы же в любом случае перед реализацией видим исходный код формы. Что нам мешает выполнять $(«some selector»).trigger(«change»); на полях формы в нашем скрипте?
          0
          И почему же тогда ту же логику не повторить нативно? Да, конечно, она может быть так сложна, что потребуется целый человеко-день на повторение, но если вдруг сменится html-выдача, а вместе с ней и название селектора, что тогда?
            0
            Никто и не говорит, что повторять нативно — это плохо. Просто обычно хочется универсального решения, а использование данной техники оправдано во многом ввиду того, что скрипты для работы с формами являются едиными для работы под разные мобильные платформы (мы используем, например, одинаковые и для iOS и для Android), т.е. разрабатываем и поддерживаем синхронно, что, согласитесь немаловажно.
        +2
        А что если у юзера карта с 3d secure и он должен вводить смс код на странице своего банка?
          +1
          Мы в этом случае после редиректа на страницу банка проявляем UIWebView
          0
          Почему не смогли сделать API?
            +2
            Там, где это возможно, мы так и поступаем. Но еще есть внешние проекты-партнеры, которые по требованиям бизнеса нужно интегрировать быстро и в дизайне приложения.
            –1
            fhcvhh
              0
              Цвет хедеров бы сделать «по мягче», выедает глаза и не позволяет ничего прочитать после него.
                –1
                Небольшой оффтоп:
                но ее внешний вид не совпадает с представлениями о прекрасном нашего отдела дизайна и выглядит внутри неорганично.

                и
                Со счета Билайн

                …вместе как-то не уживаются, или забыли, что такое склонения, и учились в школа, ездили в автобус и ходили на работа? :-). Нужно давать в таких случаях в глаз бренд-менеджеру учебником русского языка.
                  0
                  Я тоже использую такой подход в своих приложениях. Есть интересный момент, сталкивались ли вы когда-нибудь с ограничением stringByEvaluatingJavaScriptFromString: на 10mb используемой памяти и 10sec на исполнение?
                    0
                    Про ограничения знаем, но в своей практике с ними пока не сталкивались.
                      0
                      Вот я тоже, пока получалось так свалить приложение лишь экспериментально.
                    0
                    Если взять пример с карточными платежами, получится, что пользователь вводит платёжные реквизиты не на «родной» странице банка, а в приложении. При таком подходе не возникнет проблем с процессингом, банком-эквайером или МПС? На кого переложат риски за скомпрометированные транзакции в таком случае?
                      0
                      Мы не используем данную технику при заполнении форм на «родных» страницах банков, поэтому с рисками все остается по старому.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое