Разработка приложения на Android с помощью Xamarin и F#

    image

    Привет!

    Недавно Xamarin объявил конкурс на разработку мобильного приложения на функциональном языке программирования F#.
    Это было связано с выходом Xamarin 3 с полной поддержкой F#. Я решил отвлечься от повседневных задач и попробовать поучаствовать, тем более что я давно смотрю на F#, но шансов познакомиться с ним подробнее у меня не было. Для участия в соревновании я решил разработать приложение идея которого была предложена кем-то в процессе обсуждения внезапного взлета мобильного приложения Yo. Вот цитата:
    Идея для стартапа, рабочее название «ты где?».

    Смысл прост, девушка устанавливает приложение, указывает в нем номер своего молодого человека и после этого появляется большая гнопка отправки сообщения «ты где?» #startup #idea

    Почему бы и нет?

    Примечание
    Я писал этот пост параллельно работая над приложением. Поэтому он большой и местами не очень логичный.


    Футболочка


    Первое что я сделал, это скачал и запустил приложение Xamarin Store чтобы получить футболку с F#. Такая же с C# у меня уже есть
    image


    Вернее я попробовал, но сразу же схватил проблему с построением. Оказывается текущая версия Xamarin поддерживает F# версии 3.0, а свободно скачиваемой является только версия F# 3.1.1

    F# 3.0 находится внутри пакета Visual Studio Express 2012 for Web и устанавливается вместе со студией с помощью Microsoft Web Platform Installer. Странный подход.
    Для работы Xamarin и F# достаточно чтобы сборка FSharp.Core версии 4.3.0.0 была в GAC. В любом случае, вот прямая ссылка если кто-нибудь захочет попробовать.

    Начало работы


    Сейчас Xamarin поддерживает F# только внутри Xamarin Studio. Так что пришлось на время забыть о своей любимой VS2013 и поработать в этой, в целом довольно неплохой, среде. Создание нового приложения под Android заняло пару секунд и вот перед нами рабочее Hello-world приложение для Android на F#
    MainActivity.fs
    namespace Xakpc.WhereAreYou.Droid
    
    open System
    
    open Android.App
    open Android.Content
    open Android.OS
    open Android.Runtime
    open Android.Views
    open Android.Widget
    
    [<Activity (Label = "Xakpc.WhereAreYou.Droid", MainLauncher = true)>]
    type WhereAreYouActivity () =
        inherit Activity ()
    
        let mutable count:int = 1
    
        override this.OnCreate (bundle) =
    
            base.OnCreate (bundle)
    
            // Set our view from the "main" layout resource
            this.SetContentView (Resource_Layout.Main)
    
            // Get our button from the layout resource, and attach an event to it
            let button = this.FindViewById<Button>(Resource_Id.myButton)
            button.Click.Add (fun args -> 
                button.Text <- sprintf "%d clicks!" count
                count <- count + 1
            )
    


    Похоже Хабр не умеет раскрашивать F#. Грусть-тоска (зато есть поддержка Vala)

    Сразу в Бой, Попытка номер раз


    Как должно выглядеть приложение мне было очевидно, 3 экрана, 3 пуш уведомления, старый добрый Azure в качестве бэкэнда, вырвиглазные цвета (inspired by Yo)
    Дальше лучший друг разработчика, карандаш и листок бумаги. Нарисовали мокапы и вперед, к коду. Добавляем компоненты из Xamarin Component Store в проект: Azure Mobile Services и Google Play Services (ICS — я не хочу сейчас заморачиваться со старыми версиями Android).
    Собираем и БАМ! — первые грабли.
    Грабельки
    Программирование на Xamarin под Android, по мнению Xlab. Я с ним согласен :)
    image
    При построении проекта Xamarin строит файлы ресурсов, в частности он генерирует файл Resource.Designer.fs содержащий, насколько я понимаю, указатели и/или идентификаторы ресурсов. В частности там есть указатель на идентификатор Id.end который транслируется в следущий код
    // aapt resource value: 0x7f070013
    static member end = 2131165203
    

    а слово end является ключевым для F# и компилятор сообщает об ошибке Недопустимое ключевое слово "end" в определение члена (FS0010). И это та из ошибок которую сам не решишь, управление генерацией этих файлов нам недоступна к сожалению.
    Я сразу же написал на форум Xamarin и в твиттер Miguel de Icaza — и оперативно получил ответ! Разработчики сообщают что в Альфа-версии эта ошибка уже исправлена.
    Переключаю Xamarin Studio на альфа-канал и БАМ! — все равно не работает.
    Оказывается…
    Looks like the Windows Alpha channel is not quite there yet...

    Ну что же, остается только подождать пока оно будет «там», время еще есть. Оставим пока Google Play Services в покое.

    Немного слов о F#

    Начиная проект я ничего не знал о F#, кроме того что это «круто», «современно», и «крайне удобно». Попытка взять его с наскоку в новом проекте с треском провалилась. Почти пятнадцать минут я потратил пытаясь понять почему let values = ["item1"; "item2"; "item3"] нельзя передать в конструктор ArrayAdapter'а listView.Adapter <- new ArrayAdapter(this, Android.Resource.Layout.SimpleListItem1, Android.Resource.Id.Text1, values)
    Решение оказалось, эээ, простым let values = [|"item1"; "item2"; "item3"|]
    - это создает string[], а в первом случае был создан list (IEnumerable как я понимаю)
    Следующие два дня я посвятил всестороннему изучению языка программирования F#. В это мне сильно помог прекрасный интерактивный курс обучения доступный на www.tryfsharp.org/Learn

    Если вы хотите начать изучать F# - вам туда, рекомендую

    Помимо этого мне очень помог цикл статей F# For Fun And Profit


    Сразу в Бой, попытка номер два


    Начинаем реализовывать первый экран - регистрацию.



    Для регистрации я собираю телефон и генерирую hash
    Вот как выглядит функция MD5 для F#
       let MD5Hash (input : string) =
          use md5 = System.Security.Cryptography.MD5.Create()
          input
          |> System.Text.Encoding.ASCII.GetBytes
          |> md5.ComputeHash
          |> Seq.map (fun c -> c.ToString("X2"))
          |> Seq.reduce (+)


    Оператор |> это pipeline оператор, он передает результат выражения дальше.
    Таким образом имеем следующий алгоритм: получаем байты из GetBytes -> вычисляется хеш -> для каждого байта конвертация в HEX формат -> получившийся массив символов склеиваем в строку (метод reduce выполняет функцию + для каждого элемента начиная с первой пары в накопленный итог) -> возвращаем результат вычисления функции.

    Для сравнения, тот же метод на C#
    using System;
     
    public string CreateMD5Hash (string input)
    {
       MD5 md5 = System.Security.Cryptography.MD5.Create();
       byte[] inputBytes = System.Text.Encoding.ASCII.GetBytes (input);
       byte[] hashBytes  = md5.ComputeHash (inputBytes);
     
       StringBuilder sb = new StringBuilder();
       for (int i = 0; i < hashBytes.Length; i++)
       {
           sb.Append (hashBytes[i].ToString ("X2"));
       }
       return sb.ToString();
    }
    



    Одна из проблем над которой я завис на некоторое время: это то что одни модули не видели другие. Например у меня есть модуль для AzureServiceWorker (который в CLR транслируется в статичный класс)

    И сколько я не пытался вызвать его в активити - ничего не получалось. Оказывается для F# важен порядок файлов! И оказывается Xamarin Studio не позволяет его поменять никаким другим образом кроме как в файле проекта.
      <ItemGroup>
        <Compile Include="Resources\Resource.designer.fs" />
        <Compile Include="Properties\AssemblyInfo.fs" />
        <Compile Include="Helpers\Helpers.fs" />
        <Compile Include="Services\User.fs" />
        <Compile Include="Services\AzureServiceWorker.fs" />    	
        <Compile Include="IAmHereActivity.fs" />
        <Compile Include="WhereAreYouActivity.fs" />
        <Compile Include="SignInActivity.fs" />    
      </ItemGroup>
    


    Получение списка контактов

    Первое что необходимо сделать после запуска и регистрации: это получить список контактов. Для этого у Xamarin есть полезный модуль Xamarin.Mobile

    Так же тут возникает вопрос асинхронности. У F# свой подход к асинхронности во многом похожий на TPL, также присутствует совместимость с Task'ами, однако у него есть свои особенности. В частности по умолчанию F# не умеет работать с асинхронными функциями возвращающими просто Task. К счастью, решается эта проблема довольно просто:
    
    module Async =
        open System.Threading
        open System.Threading.Tasks
    
        let inline AwaitPlainTask (task: Task) = 
            // rethrow exception from preceding task if it fauled
            let continuation (t : Task) : unit =
                match t.IsFaulted with
                | true -> raise t.Exception
                | arg -> ()
            task.ContinueWith continuation |> Async.AwaitTask
    

    Ее можно было бы решить еще проще вызвав Async.AwaitIAsyncResult >> Async.Ignore но тогда теряется исключения внутри таски

    А вот как я получаю контакты и делаю над ними операции
    
        let ExtractUserInfo (x : Contact) = 
            let first = x.Phones |> Seq.tryPick(fun x -> if x.Type = PhoneType.Mobile then Some(x) else None)
            match first with
            | Some(first) -> 
                let phone = first.Number |> StripChars [' ';'-';'(';')']             
                UserInfo.CreateUserInfo((MD5Hash phone), phone, x.DisplayName)                                   
            | None ->  UserInfo.CreateUserInfo("no mobile phone", "no mobile phone", "no mobile phone")
    
        // function for async list filling 
        let FillContactsAsync = async {             
            let book = new AddressBook (this)           
            let! result = book.RequestPermission() |> Async.AwaitTask 
            if result then
                _contacts <- book.ToList() 
                    |> Seq.filter (fun (x: Contact) -> not (Seq.isEmpty x.Phones)) 
                    |> Seq.map ExtractUserInfo 
                    |> Seq.sortBy(fun x -> x.DisplayName)
                    |> Seq.toList 
    
                let finalContacts = _contacts |> Seq.map (fun x -> x.DisplayName.ToUpperInvariant()) |> Seq.toArray
                this.ListAdapter <- new ArrayAdapter(this, Resource_Layout.row_contact, finalContacts)                   
            else
                System.Diagnostics.Debug.WriteLine("Permission denied by user or manifest")
                this.ListAdapter <- new ArrayAdapter(this, Resource_Layout.row_contact, Array.empty)                                      
            }  
    

    Разберем ключевую последовательность действий функции
    1. book.ToList() конвертируем в List
    2. |> Seq.filter (fun (x: Contact) -> not (Seq.isEmpty x.Phones)) фильтруем все контакты без телефонов
    3. |> Seq.map ExtractUserInfo конвертируем все элементы из класса Contracts в UserInfo, далее у нас коллекция элементов UserInfo
    4. |> Seq.sortBy(fun x -> x.DisplayName) Сортируем
    5. |> Seq.toList конвертируем в List
    6. _contacts <- кладем все в mutable поле
    7. _contacts |> Seq.map (fun x -> x.DisplayName.ToUpperInvariant())<-конвертируем все элементы UserInfo в string, далее у нас коллекция элементов-строк - имена заглавными буквами
    8. |> Seq.toArray<-конвертируем List в Array чтобы его принял ArrayAdapter

    Тут есть нелогичность - два раза происходит конвертирование в List. Надо будет исправить.



    Azure Mobile Servcies


    В качестве бэк-энда традиционно я использую Azure Mobile Services. Пока я не стал заморачиваться с NotificationHub, который призван обеспечить доставку Push уведомлений на все платформы. Описывать подключение Azure я тоже не буду, т.к. у них есть свои подробнейшие мануалы.

    В приложении я создаю пару констант, они помечаются тегом

    module WruConstants = [<Literal>] let TotallyNotAzureServer = "https://YOUR.azure-mobile.net/"; [<Literal>] let TotallyNotAzureKey = "YOUR"


    Рассмотрим один метод функцию по частям
    
            member this.RegisterMe phone name regId = async {            
                    try
                        let table = this.MobileService.GetTable<User>() 
    
                        let usr = 
                            { Id = ""
                              PhoneHash = MD5Hash phone
                              Nickname = name
                              RegistrationId = regId }                                                            
                        
                        do! table.InsertAsync usr |> Async.AwaitPlainTask 
                       
                        return (usr.Id, usr.PhoneHash, usr.Nickname)
                    with | e -> System.Diagnostics.Trace.WriteLine(e.ToString) 
                                     return (String.Empty,String.Empty,String.Empty) }
    

    1. member this.RegisterMe phone name regId = async { - тут создается функция член определения типа (будет транслировано в статичный публичный метод) с тремя входящими параметрами. Дальнейший код размещается в так называемом "computation expression" или асинхронном workflow. Внутри скобок { } можно использовать специальные конструкции с суффиксом ! (читается bang), например do! (do-bang) или let! (let bang)
    2. Я создаю объект записи User. Интересной особенностью является то, что F# сам определит к какому типу относиться данный объект, с помощью набора заданных полей
          let usr = { Id = ""
                          PhoneHash = MD5Hash phone
                          Nickname = name
                          RegistrationId = regId }   
      
    3. do! table.InsertAsync usr |> Async.AwaitPlainTask делаем do-bang, что эквивалентно await из C#. Т.е. запускаем асинхронную задачу на выполнение а весь последующий код продолжится выполнятся в continuation после завершения асинхронной задачи.
    4. return (usr.Id, usr.PhoneHash, usr.Nickname) и наконец возвращаем кортеж эквивалентный Tuple<string,string,string> для работы с ним далее.
    5. Конструкция try ... with | e -> эквивалентна try..catch из C#




    Интересным является способ доступа к элементам кортежа. В F# есть встроенные функции fst и snd для доступа к первой паре элементов. Но они подходят только для кортежей из 2х элементов. Мне пришлось написать свои функции:
            let id (c,_,_) = c
            let phonehash (_,c,_) = c
            let nickname (_,_,c) = c
    

    их использование очень понятное: id tuple вернет Id и т.п.

    Всего у меня 5 функций Azure, две из них используются для выполнения Push уведомлений. Чтобы их использовать мне пришлось написать Azure Custom Api функцию

    Вот она если кому интересно
    exports.post = function(request, response) {
        // Use "request.service" to access features of your mobile service, e.g.:
        //   var tables = request.service.tables;
        //   var push = request.service.push;
    
        //response.send(statusCodes.OK, { message : 'Hello World!' });
        console.log('Incoming call with requst: ', request.body.RequestId); 
        
        var usersTable = request.service.tables.getTable('User');
        usersTable.where( { id : request.body.TargetId } )
            .read(
                { success: function(results)                 
                    {                    
                        if (results.length > 0)
                        {
                            var user = results[0]
                            console.log('Send to results: ', user.Nickname, user.RegistrationId); 
                            
                            request.service.push.gcm.send(user.RegistrationId, 
                                {
                                    RequesterId: request.body.RequesterId,
                                    RequesterNickname: request.body.RequesterName,
                                    TargetId: user.id,
                                    TargetNickname: user.Nickname                                                                
                                },                                               
                                {
                                    success: function(gcm_response) {
                                        console.log('Push notification sent: ', gcm_response);
                                        response.send(statusCodes.OK, { RequestedNickname : user.Nickname });
                                    }, 
                                    error: function(gcm_error) {
                                        console.log('Error sending push notification: ', gcm_error);
                                        response.send(statusCodes.INTERNAL_SERVER_ERROR, { RequestedNickname : user.Nickname });
                                    }
                                });                                               
                        }
                        else
                        {
                             response.send(statusCodes.NO_CONTENT, { RequestedNickname : "" });
                        }                    
                    },
                  error: function(error) 
                  { 
                      console.log('Error read table: ', error); 
                      response.send(statusCodes.INTERNAL_SERVER_ERROR, { RequestedNickname : "" });
                  }                                                
                });
    };
    


    Ну а выполнение Push операции в мобильном приложении тривиально
    
            // Perform Push operation
            member this.PushAsync targetId myId myNickname = async {
                try
                    let (request : PushRequest) = 
                        {
                            TargetId = targetId
                            RequesterId = myId
                            RequesterName = myNickname
                        }
                    let! result = this.MobileService.InvokeApiAsync<PushRequest, PushResponce>("pushhim", request) |> Async.AwaitTask
                    return result.RequestedNickname
                with | e -> return e.ToString() }
    


    отмечу только что тут нам нужен результат, поэтому мы используем let-bang а не do!

    Пуш нотификации




    Для пушей проекту нужна поддержка Google Play Services. Однако они несовместимы с F# в данный момент. Пришлось полазить по зависимостям и найти ту сборку которая ломала проект. Оказалось что это сборка: Xamarin.Android.Support.v7.AppCompat
    Удаляем ее и все собирается, Google Play Services работают, можно создавать уведомления.

    Вообще процесс получения и обработки push notification достаточно унылая штука. Телефон регистрируется в GCM, получает ID, дальше мы сохраняем этот ID на сервере и по нему отрабатываем Push уведомления (см. серверную функцию pushhim). Простое получение запроса требует от нас создания BroadcastReciever и сервиса и подробно описано на developer.android.com. Переписывать мне это на F# абсолютно не хотелось и тут мне снова помог Xamarin Component Store. Внутри него есть компонент Google Cloud Messaging Client который инкапсулирует в себя большую часть работы с GCM и этим очень удобен. Вот все что нужно сделать для получения ID
    
    //Check to see that GCM is supported and that the manifest has the correct information
            GcmClient.CheckDevice(this)
            GcmClient.CheckManifest(this)
    
            // check google play
            if CheckPlayServices() then
                // Try to get registration id
                let regId = GcmClient.GetRegistrationId this
                if String.IsNullOrEmpty(regId) then
                    // Call to Register the device for Push Notifications
                    GcmClient.Register(this, WruConstants.GcmSender);
    



    Если наберется сотня пользователей воткну сюда карту

    Вот пожалуй и все.
    Заявка на конкурс подана, блог-пост написан
    исходники доступны на битбакете bitbucket.org/xakpc/whereareyou
    само приложение доступно в гуглоплее, могу дать ссылку интересующимся
    Я понимаю что предложенный тут код во многом не функциональный, буду рад за любые предложения по превращению кода в более "функциональную" версию.

    Вердикт


    Да, я написал Android приложение на F#. Это был интересный и увлекательный опыт.
    Нет, я никогда больше не буду писать что-то под Android на F#. По крайней мере, пока не увижу явных удобств в этом.
    • +10
    • 15.2k
    • 8
    Share post

    Comments 8

      0
      Идея не очень удачная, потому что ограничивает аудиторию пользователей девушками. В результате, в Калифорнии за стартап заплатят только 500 млн, а не миллиард.
        0
        Насчёт элементов кортежа — можно к ним получать доступ, передавая кортеж в анонимную функцию, это удобнее:
        ("", 1) |> fun (x,y) => /*do stuff*/
          0
          Первое что я сделал, это скачал и запустил приложение Xamarin Store чтобы получить футболку с F#. Такая же с C# у меня уже есть

          Похоже, что люди с тарифом Starter не смогут получить футболку.

            0
            триалку возьмите
            –2
            Мне пр-е под Андроид напоминает времена Win32 и голого Си, когда для вызова диалога писалось пол-экрана кода. Это ужас и в 21 веке неприемлемо.
              +1
              Это разве сложно?

              SomeDialogFramgent.newInstance(param1, param2, ...).show();

              Или я не понял о чем вы
              0
              Мсье знает толк в извращениях.
                0
                >> Для сравнения, тот же метод на C#
                В F# конкатенация строк, а в C# StringBuilder, поэтому не совсем тот же :)

                Only users with full accounts can post comments. Log in, please.