Привет!
Недавно Xamarin объявил конкурс на разработку мобильного приложения на функциональном языке программирования F#.
Это было связано с выходом Xamarin 3 с полной поддержкой F#. Я решил отвлечься от повседневных задач и попробовать поучаствовать, тем более что я давно смотрю на F#, но шансов познакомиться с ним подробнее у меня не было. Для участия в соревновании я решил разработать приложение идея которого была предложена кем-то в процессе обсуждения внезапного взлета мобильного приложения Yo. Вот цитата:
Идея для стартапа, рабочее название «ты где?».
Смысл прост, девушка устанавливает приложение, указывает в нем номер своего молодого человека и после этого появляется большая гнопка отправки сообщения «ты где?» #startup #idea
Почему бы и нет?
Примечание
Я писал этот пост параллельно работая над приложением. Поэтому он большой и местами не очень логичный.
Футболочка
Первое что я сделал, это скачал и запустил приложение Xamarin Store чтобы получить футболку с F#. Такая же с C# у меня уже есть
Вернее я попробовал, но сразу же схватил проблему с построением. Оказывается текущая версия 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#
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 строит файлы ресурсов, в частности он генерирует файл 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<String>(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 выполняет функцию + для каждого элемента начиная с первой пары в накопленный итог) -> возвращаем результат вычисления функции.
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="ResourcesResource.designer.fs" />
<Compile Include="PropertiesAssemblyInfo.fs" />
<Compile Include="HelpersHelpers.fs" />
<Compile Include="ServicesUser.fs" />
<Compile Include="ServicesAzureServiceWorker.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)
}
Разберем ключевую последовательность действий функции
book.ToList()
конвертируем в List|> Seq.filter (fun (x: Contact) -> not (Seq.isEmpty x.Phones))
фильтруем все контакты без телефонов|> Seq.map ExtractUserInfo
конвертируем все элементы из класса Contracts в UserInfo, далее у нас коллекция элементов UserInfo|> Seq.sortBy(fun x -> x.DisplayName)
Сортируем|> Seq.toList
конвертируем в List_contacts <-
кладем все в mutable поле_contacts |> Seq.map (fun x -> x.DisplayName.ToUpperInvariant())<-
конвертируем все элементы UserInfo в string, далее у нас коллекция элементов-строк — имена заглавными буквами|> Seq.toArray<-
конвертируем List в Array чтобы его принял ArrayAdapter
Тут есть нелогичность — два раза происходит конвертирование в List. Надо будет исправить.
Azure Mobile Servcies
В качестве бэк-энда традиционно я использую Azure Mobile Services. Пока я не стал заморачиваться с NotificationHub, который призван обеспечить доставку Push уведомлений на все платформы. Описывать подключение Azure я тоже не буду, т.к. у них есть свои подробнейшие мануалы.
В приложении я создаю пару констант, они помечаются тегом <Literal>
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) }
member this.RegisterMe phone name regId = async {
— тут создается функция член определения типа (будет транслировано в статичный публичный метод) с тремя входящими параметрами. Дальнейший код размещается в так называемом «computation expression» или асинхронном workflow. Внутри скобок { } можно использовать специальные конструкции с суффиксом! (читается bang), например do! (do-bang) или let! (let bang)- Я создаю объект записи User. Интересной особенностью является то, что F# сам определит к какому типу относиться данный объект, с помощью набора заданных полей
let usr = { Id = "" PhoneHash = MD5Hash phone Nickname = name RegistrationId = regId }
do! table.InsertAsync usr |> Async.AwaitPlainTask
делаем do-bang, что эквивалентноawait
из C#. Т.е. запускаем асинхронную задачу на выполнение а весь последующий код продолжится выполнятся в continuation после завершения асинхронной задачи.return (usr.Id, usr.PhoneHash, usr.Nickname)
и наконец возвращаем кортеж эквивалентный Tuple<string,string,string> для работы с ним далее.- Конструкция
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#. По крайней мере, пока не увижу явных удобств в этом.
Автор: xakpc