Currency Converter v2 — теперь с кофеином!

Педро Ламас (Pedro Lamas)

Учитывая отзывы пользователей о приложении Currency Converter v2, пришло время его немного усовершенствовать!

Вот лишь некоторые комментарии, полученные нами от пользователей:

  • Конвертация валют выполняется слишком медленно.
  • Чрезмерный трафик данных в приложении/необходимость кэширования валютных курсов.
  • Некоторые валюты не поддерживаются.
  • Неточные результаты/устаревшие данные о валютных курсах.

Эти комментарии означают, что нам нужен более эффективный источник данных и некоторый механизм кэширования…

Включаю кофеварку и… поехали!

Bing — быть или не быть? Вот в чем вопрос…

В первой версии Currency Converter для конвертации валют использовался поисковик Bing. О результатах вы уже прочитали выше.

В текущей версии в качестве источника данных мы выбрали MSN Money, поскольку он содержит более актуальные и точные данные и работает с любыми валютами.

Запустите Internet Explorer 8.0+ и перейдите на страницу https://moneycentral.msn.com/investor/market/exchangerates.aspx. Здесь выводятся актуальные валютные курсы для доллара США.

clip_image002[4]

На этой странице есть вся необходимая информация для перевода любой валюты в доллары США и наоборот. Кроме того, можно конвертировать валюту X в доллары США, а затем в валюту Y.

Так почему бы не получить все эти данные одним запросом, кэшировать их, а затем использовать для конвертации валют офлайн?

Как и прежде, для извлечения необходимых данных со страницы HTML мы воспользуемся регулярными выражениями. Для этого откройте Internet Explorer Developer Tools (нажмите <F12>), выберите «Select element by click» (Выбор элемента по щелчку) (<Ctrl> + <B>) и щелкните «Argentine Peso» (Аргентинский песо). Страница будет выглядеть примерно так:

clip_image004[4]

Используя приведенную выше информацию, мы сможем увидеть шаблон в коде:

HTML

 <tr>
    <td>CURRENCY</td>
    <td style=”text-align:right”><a SOMETHING>VALUE_IN_USD</a></td>
    <td style=”text-align:right”><a SOMETHING>VALUE_PER_USD</a></td>
</tr>

Зная шаблон, мы сможем создать приведенное ниже регулярное выражение:

C#

 private static Regex _resultRegex =
     new Regex("<tr><td>(?<currency>[^<>]+)</td><td style=""text-align:right"">.*?>(?<value>[0-9.,]+)</a></td></tr>");

Применив это регулярное выражение к нужному HTML-коду, мы получим все соответствующие строки, включая наименование валюты и обменный курс для доллара США.

Пора кодировать

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

Как и в предыдущей статье, мы воспользуемся шаблоном MVVM и покажем процесс кодирования с низшего (Model) до самого верхнего (View) уровня шаблона.

Изменения на уровне Model

Для того чтобы воспользоваться полученными и кэшированными валютными курсами, мы должны внести следующие изменения в нашу модель:

  • задать для каждой валюты сохранение ее курса и последнего обновления;
  • пометить одну валюту как базовую (доллар США), присвоив ей обменный курс 1,0 (на случай конвертации долларов в доллары);
  • добавить в службу операцию «Обновить валютные курсы».

А вот и полная модель (изменения выделены желтым):

clip_image006[4]

C#

 using System;
public interface ICurrencyExchangeService
{
    ICurrency[] Currencies { get; }
    ICurrency BaseCurrency { get; }
    void ExchangeCurrency(double amount, ICurrency fromCurrency, ICurrency toCurrency, Action<ICurrencyExchangeResult> callback);
    void UpdateCachedExchangeRates(Action<CachedExchangeRatesUpdateResult> callback, object state);
}
public interface ICurrency
{
    string Name { get; }
    double CachedExchangeRate { get; set; }
    DateTime CachedExchangeRateUpdatedOn { get; set; }
}
public interface ICurrencyExchangeResult
{
    Exception Error { get; }
    string ExchangedCurrency { get; }
    double ExchangedAmount { get; }
}
public interface ICachedExchangeRatesUpdateResult
{
    Exception Error { get; }
    object State { get; }
}

Теперь в ICurrencyExchangeService есть новое свойство BaseCurrency, которому присвоено значение экземпляра валюты «US Dollar», а также метод UpdateCachedExchangeRates для обновления всех валютных курсов.

Для ICurrency добавилось два новых свойства: CachedExchangeRate для хранения обменного курса валюты и CachedExchangeRateUpdatedOn для даты последнего обновления.

Также был добавлен новый интерфейс ICachedExchangeRatesUpdateResult, возвращающий исключение при асинхронном выполнении метода ICurrencyExchangeService.UpdateCachedExchangeRates.

Посмотрим, как реализован интерфейс:

clip_image008[4]

Прежде всего, нужно отметить, что у нас появился абстрактный класс CurrencyBase. Тем самым мы расширяем класс MsnMoneyCurrency, добавляя отдельное свойство Id для хранения числового идентификатора валюты, получаемого с MSN Money.

Затем добавился метод MsnMoneyV2CurrencyExchangeService, который является прямой реализацией ICurrencyExchangeService.

Обратите внимание, что в отличие от метода BingCurrencyExchangeService из предыдущей версии, метод MsnMoneyV2CurrencyExchangeService не расширяет класс CurrencyExchangeServiceBase, а только запрашивает онлайновые данные в методе UpdateCachedExchangeRates и не при каждом вызове метода ExchangeCurrency.

Ниже приведен код для этих классов:

C#

 public class MsnMoneyV2CurrencyExchangeService : ICurrencyExchangeService
{
    private const string MsnMoneyUrl = "<a href='https://moneycentral.msn.com/investor/market/exchangerates.aspx?selRegion=1&selCurrency=1";'>https://moneycentral.msn.com/investor/market/exchangerates.aspx?selRegion=1&selCurrency=1";</a>
    #region Static Globals
    private static Regex _resultRegex = new Regex(@"<tr><td>(?<currency>[^<>]+)</td><td style=""text-align:right"">.*?>(?<value>[0-9.,]+)</a></td></tr>");
    private static ICurrency[] _currencies = new ICurrency[] 
    {
         //The currencies exposed by MSN Money will go here
    };
    #endregion
    #region Properties
    public ICurrency[] Currencies
    {
        get
        {
            return _currencies;
        }
    }
 public ICurrency BaseCurrency
 {
 get;
 protected set;
 }
    #endregion
    public MsnMoneyV2CurrencyExchangeService()
    {
        BaseCurrency = Currencies.First(x => x.Name == "US Dollar");
    }
    public void ExchangeCurrency(double amount, ICurrency fromCurrency, ICurrency toCurrency, bool useCachedExchangeRates, Action<ICurrencyExchangeResult> callback, object state)
    {
        if (useCachedExchangeRates)
        {
            try
            {
                ExchangeCurrency(amount, fromCurrency, toCurrency, callback, state);
                return;
            }
            catch
            {
            }
        }
        UpdateCachedExchangeRates(result =>
        {
            if (result.Error != null)
            {
                callback(new CurrencyExchangeResult(result.Error, state));
                return;
            }
            try
            {
                ExchangeCurrency(amount, fromCurrency, toCurrency, callback, state);
            }
            catch (Exception ex)
            {
                callback(new CurrencyExchangeResult(ex, state));
            }
        }, state);
    }
    private void ExchangeCurrency(double amount, ICurrency fromCurrency, ICurrency toCurrency, Action<ICurrencyExchangeResult> callback, object state)
    {
        var fromExchangeRate = fromCurrency.CachedExchangeRate;
        var toExchangeRate = toCurrency.CachedExchangeRate;
        var timestamp = DateTime.Now;
        if (fromCurrency == BaseCurrency)
            fromExchangeRate = 1.0;
        else
        {
            if (timestamp > fromCurrency.CachedExchangeRateUpdatedOn)
                timestamp = fromCurrency.CachedExchangeRateUpdatedOn;
        }
        if (toCurrency == BaseCurrency)
            toExchangeRate = 1.0;
        else
        {
            if (timestamp > toCurrency.CachedExchangeRateUpdatedOn)
                timestamp = toCurrency.CachedExchangeRateUpdatedOn;
        }
        if (fromExchangeRate > 0 && toExchangeRate > 0)
        {
            var exchangedAmount = amount / fromExchangeRate * toExchangeRate;
            callback(new CurrencyExchangeResult(toCurrency, exchangedAmount, timestamp, state));
        }
        else
            throw new Exception("Conversion not returned!");
    }
    public void UpdateCachedExchangeRates(Action<CachedExchangeRatesUpdateResult> callback, object state)
    {
        var request = HttpWebRequest.Create(MsnMoneyUrl);
        request.BeginGetResponse(ar =>
        {
            try
            {
                var response = (HttpWebResponse)request.EndGetResponse(ar);
                if (response.StatusCode == HttpStatusCode.OK)
                {
                    string responseContent;
                    using (var streamReader = new StreamReader(response.GetResponseStream()))
                    {
                        responseContent = streamReader.ReadToEnd();
                    }
                    foreach (var match in _resultRegex.Matches(responseContent).Cast<Match>())
                    {
                        var currencyName = match.Groups["currency"].Value.Trim();
                        var currency = Currencies.FirstOrDefault(x => string.Compare(x.Name, currencyName, StringComparison.InvariantCultureIgnoreCase) == 0);
                        if (currency != null)
                        {
                            currency.CachedExchangeRate = double.Parse(match.Groups["value"].Value, CultureInfo.InvariantCulture);
                            currency.CachedExchangeRateUpdatedOn = DateTime.Now;
                        }
                    }
                    callback(new CachedExchangeRatesUpdateResult(ar.AsyncState));
                }
                else
                {
                    throw new Exception(string.Format("Http Error: ({0}) {1}",
                        response.StatusCode,
                        response.StatusDescription));
                }
            }
            catch (Exception ex)
            {
                callback(new CachedExchangeRatesUpdateResult(ex, ar.AsyncState));
            }
        }, state);
    }
}

Он работает следующим образом: при вызове метода ExchangeCurrency мы передаем параметр (useCachedExchangeRates), который диктует методу использовать (или не использовать!) кэшированные ранее валютные курсы.

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

С моделью на этом все!

ViewModel

Мы полностью сохранили ViewModel предыдущей версии, но добавили новую функциональность. Ниже приведен код:

C#

 public class MainViewModel : INotifyPropertyChanged
{
    //Full previous code
    #region Properties
    [IgnoreDataMember]
    public ICurrencyExchangeResult Result
    {
        get
        {
            return _result;
        }
        protected set
        {
            if (_result == value)
                return;
            _result = value;
            RaisePropertyChanged("Result");
            RaisePropertyChanged("ExchangedCurrency");
            RaisePropertyChanged("ExchangedAmount");
            RaisePropertyChanged("ExchangedTimeStamp");
        }
    }
    [IgnoreDataMember]
    public string ExchangedTimeStamp
    {
        get
        {
            if (_result == null)
                return string.Empty;
            return string.Format("Data freshness:\n{0} at {1}",
                _result.Timestamp.ToShortDateString(),
                _result.Timestamp.ToShortTimeString());
        }
    }
    [DataMember]
    public CurrencyCachedExchangeRate[] CurrenciesCachedExchangeRates
    {
        get
        {
            return Currencies
                .Select(x => new CurrencyCachedExchangeRate()
                {
                    CurrencyIndex = Array.IndexOf(Currencies, x),
                    CachedExchangeRate = x.CachedExchangeRate,
                    CachedExchangeRateUpdatedOn = x.CachedExchangeRateUpdatedOn
                })
                .ToArray();
        }
        set
        {
            foreach (var currencyData in value)
            {
                if (currencyData.CurrencyIndex >= Currencies.Length)
                    continue;
                var currency = Currencies[currencyData.CurrencyIndex];
                currency.CachedExchangeRate = currencyData.CachedExchangeRate;
                currency.CachedExchangeRateUpdatedOn = currencyData.CachedExchangeRateUpdatedOn;
            }
        }
    }
    #endregion
    //Full previous code
    public void ExchangeCurrency()
    {
        if (Busy)
            return;
        BusyMessage = "Exchanging amount...";
        _currencyExchangeService.ExchangeCurrency(_amount, _fromCurrency, _toCurrency, true, CurrencyExchanged, null);
    }
    public void UpdateCachedExchangeRates()
    {
        if (Busy)
            return;
        BusyMessage = "Updating cached exchange rates...";
        _currencyExchangeService.UpdateCachedExchangeRates(ExchangeRatesUpdated, null);
    }
    private void CurrencyExchanged(ICurrencyExchangeResult result)
    {
        InvokeOnUiThread(() =>
        {
            Result = result;
            BusyMessage = null;
            if (result.Error != null)
            {
                if (System.Diagnostics.Debugger.IsAttached)
                    System.Diagnostics.Debugger.Break();
                else
                    MessageBox.Show("An error has ocorred!", "Error", MessageBoxButton.OK);
            }
        });
    }
    private void ExchangeRatesUpdated(ICachedExchangeRatesUpdateResult result)
    {
        InvokeOnUiThread(() =>
        {
            BusyMessage = null;
            Save();
            if (result.Error != null)
            {
                if (System.Diagnostics.Debugger.IsAttached)
                    System.Diagnostics.Debugger.Break();
                else
                    MessageBox.Show("An error has ocorred!", "Error", MessageBoxButton.OK);
            }
        });
    }
    private void InvokeOnUiThread(Action action)
    {
        var dispatcher = System.Windows.Deployment.Current.Dispatcher;
        if (dispatcher.CheckAccess())
            action();
        else
            dispatcher.BeginInvoke(action);
    }
    #region Auxiliary Classes
    public class CurrencyCachedExchangeRate
    {
        [DataMember]
        public int CurrencyIndex { get; set; }
        [DataMember]
        public double CachedExchangeRate { get; set; }
        [DataMember]
        public DateTime CachedExchangeRateUpdatedOn { get; set; }
    }
    #endregion
}

Прежде всего, вы, наверное, заметили новое свойство «только для чтения» ExchangedTimeStamp, которое передает в интерфейс строку данных с информацией о том, когда были получены используемые данные о валюте. Интерфейс получает уведомление о том, что значение этого свойства изменяется при изменении свойства Result.

Ниже мы видим еще одно новое свойство CurrenciesCachedExchangeRates, в котором хранятся кэшированные валютные курсы. Для того чтобы заставить его работать, у нас есть вспомогательный класс CurrencyCachedExchangeRate, в котором хранится валютный индекс, валютный курс и метка времени обновления.

Благодаря методу UpdateCachedExchangeRates пользователи могут принудительно вручную обновлять кэшированные валютные курсы.

Функции обратного вызова CurrencyExchanged и ExchangeRatesUpdated используют метод InvokeOnUiThread для проверки правильности выполнения своего кода в потоке UI.

View

Мы внесли два простых изменения в MainPage.xaml (наш главный View): была добавлена область экрана, отображающая метку времени для результата конвертации, и пункт меню для полного обновления валютных курсов.

Чтобы внести первое изменение, добавьте простую текстовую область TextArea внизу StackPanel и создайте ее привязку к свойству ExchangedTimeStamp из ViewModel:

XAML

 <StackPanel x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
    <TextBlock Margin="12,0,0,-5" Style="{StaticResource PhoneTextSubtleStyle}">Amount</TextBlock>
    <TextBox InputScope="TelephoneNumber" Text="{Binding Amount, Mode=TwoWay, ValidatesOnExceptions=True, NotifyOnValidationError=True}" />
    <TextBlock Margin="12,10,0,-5" Style="{StaticResource PhoneTextSubtleStyle}">From</TextBlock>
    <toolkit:ListPicker ItemsSource="{Binding Currencies}" SelectedItem="{Binding FromCurrency, Mode=TwoWay}" FullModeHeader="FROM CURRENCY" Style="{StaticResource CurrencyListPicker}" />
    <TextBlock Margin="12,10,0,-5" Style="{StaticResource PhoneTextSubtleStyle}">To</TextBlock>
    <toolkit:ListPicker ItemsSource="{Binding Currencies}" SelectedItem="{Binding ToCurrency, Mode=TwoWay}" FullModeHeader="TO CURRENCY" Style="{StaticResource CurrencyListPicker}" />
    <StackPanel>
        <TextBlock Style="{StaticResource PhoneTextGroupHeaderStyle}" Text="{Binding ExchangedCurrency}"></TextBlock>
        <TextBlock Margin="25, 0, 0, 0" Style="{StaticResource PhoneTextTitle1Style}" Text="{Binding ExchangedAmount}"></TextBlock>
        <TextBlock Style="{StaticResource PhoneTextSubtleStyle}" Text="{Binding ExchangedTimeStamp}" TextWrapping="Wrap" TextAlignment="Right"></TextBlock>
    </StackPanel>
</StackPanel>

Что касается пункта меню для обновления валютных курсов, добавьте новый элемент ApplicationBarMenuItem в коллекцию MenuItems, задайте подходящий текст и добавьте обработчик для события щелчка:

XAML

 <phone:PhoneApplicationPage.ApplicationBar>
    <shell:ApplicationBar IsVisible="True" IsMenuEnabled="True">
        <shell:ApplicationBarIconButton IconUri="/Images/appbar.money.usd.png" Text="Exchange" Click="ExchangeIconButton_Click" />
        <shell:ApplicationBar.MenuItems>
            <shell:ApplicationBarMenuItem Text="update exchange rates" Click="UpdateExchangeRatesMenuItem_Click" />
            <shell:ApplicationBarMenuItem Text="about" Click="AboutMenuItem_Click" />
        </shell:ApplicationBar.MenuItems>
    </shell:ApplicationBar>
</phone:PhoneApplicationPage.ApplicationBar>

Теперь осталось реализовать метод UpdateExchangeRatesMenuItem_. Для этого щелкните обработчик событий в MainPage.xaml.cs:

C#

 private void UpdateExchangeRatesMenuItem_Click(object sender, EventArgs e)
{
    var viewModel = DataContext as MainViewModel;
    if (viewModel == null)
        return;
    Dispatcher.BeginInvoke(() =>
    {
        viewModel.UpdateCachedExchangeRates();
    });
}

Заключение

В результате мы получили приложение, по качеству не уступающее используемому источнику данных. Благодаря новому (и более качественному) источнику данных и нескольким простым изменениям кода, наш Currency Converter стал работать как никогда быстро.

И как раз вовремя — кофе готов!

Об авторе

Педро Ламас (Pedro Lamas) родом из Португалии. Педро имеет статус .NET Senior Developer и работает в компании-партнере Microsoft DevScope, используя все мощные возможности платформы Microsoft .NET для разработчиков.

Педро также работает администратором сети PocketPT.net, крупнейшего сообщества Windows Phone в Португалии, оказывая активную поддержку разработчикам под Windows Phone, и выступает в качестве докладчика на мероприятиях Microsoft в Португалии, посвященных разработке на базе Windows Phone.