Pull to refresh

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

Reading time 10 min
Views 16K
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#. По крайней мере, пока не увижу явных удобств в этом.
Tags:
Hubs:
+10
Comments 8
Comments Comments 8

Articles