Поделиться через



ИЮЛЬ 2016

ТОМ 31 НОМЕР 7

SQLite - Работа с локальными базами данных в Xamarin.Forms с применением SQLite

Алессандро Дель Соуле | ИЮЛЬ 2016 | Исходный код можно скачать по ссылке

Продукты и технологии:

SQLite, Xamarin.Forms, C#, Visual Studio 2015

В статье рассматриваются:

  • создание кросс-платформенного приложения Xamarin.Forms в Visual Studio 2015;
  • использование SQLite в качестве локальной базы данных;
  • реализация операция чтения-записи данных;
  • связывание данных с UI.

Довольно часто приложения работают с данными. Это относится не только к настольным и веб-приложениям, но и к мобильным. Во многих случаях мобильные приложения обмениваются данными по сетям и используют преимущества облачных хранилища и сервисов, например всплывающих уведомлений (от серверной части). Однако бывают ситуации, когда мобильным приложениям требуется лишь локальное хранилище данных. Если данные простые, неструктурированные, например пользовательские настройки, то приложения могут хранить информацию в локальных файлах, таких как XML, чисто текстовых или в специфических объектах, предлагаемых различными платформами разработки. В случае сложных, структурированных данных приложениям нужен другой способ хранения информации.

Хорошая новость в том, что вы можете легко включать локальные базы данных в свое мобильное приложение с помощью SQLite (sqlite.org). SQLite — это облегченное, не использующее сервер ядро баз данных с открытым исходным кодом, которое упрощает создание локальных баз данных и выполняет операции над данными. Информация хранится в таблицах, а операции над данными можно выполнять написанием кода на C# и LINQ-запросов. SQLite отлично подходит для кросс-платформенной разработки, поскольку это портируемое ядро баз данных. По сути, оно заранее устанавливается как в iOS, так и в Android и может быть легко развернуто в Windows. По этой причине SQLite также является отличным сопутствующим компонентом при создании кросс-платформенных, ориентированных на данные мобильные приложения с помощью Xamarin.Forms, которой нужна локальная база данных.

Создав проект, вам потребуется управляемый способ доступа к базам данных SQLite.

В этой статье я покажу, как создать мобильное приложение, ориентированное на Android, iOS и Universal Windows Platform (UWP), с использованием Xamarin.Forms и задействовать преимущества SQLite для хранения и получения локальных данных. Я исхожу из того, что вы уже знаете, как создать приложение Xamarin.Forms в Visual Studio 2015, что такое XAML и как отлаживать приложение Xamarin.Forms с помощью различных эмуляторов, включенных в SDK соответствующих платформ. Дополнительную информацию вы можете узнать из статей «Build a Cross-Platform UX with Xamarin.Forms» (msdn.com/magazine/mt595754), «Share UI Code Across Mobile Platforms with Xamarin.Forms» (msdn.com/magazine/dn904669) и «Build a Cross-Platform, Mobile Golf App Using C# and Xamarin» (msdn.com/magazine/dn630648). В последней описывается, как работать с данными поверх платформы Microsoft Azure. Эта статья и примеры кода основаны на Xamarin.Forms 2.0, которую вы получаете при установке Xamarin 4.0.3.

Включение поддержки SQLite в UWP-приложения

Базовое ядро SQLite уже включено в iOS и Android, но не в Windows. Поэтому вам понадобится включать двоичные файлы SQLite в пакет вашего приложения. Вместо добавления таких двоичных файлов вручную к каждому проекту можно использовать преимущества расширения SQLite для Visual Studio 2015, которое предоставляет заранее скомпилированные двоичные файлы ядра базы данных и автоматизирует задачу включения требуемых файлов в новые проекты. Я описываю это до того, как перейду к созданию новых проектов, поскольку данное расширение работает на уровне IDE, а не на уровне проекта, и будет предоставлять заранее скомпилированные двоичные файлы SQLite всякий раз, когда вы будете включать библиотеки SQLite в свои решения. Существует несколько расширений SQLite, каждое из которых ориентировано на конкретную версию Windows, и нужное из них можно скачать через Extensions and Updates в Visual Studio 2015 (рис. 1).

Скачивание расширения SQLite for Universal Windows Platform в Visual Studio 2015
Рис. 1. Скачивание расширения SQLite for Universal Windows Platform в Visual Studio 2015

В данном случае скачайте и установите расширение SQLite for Universal Windows Platform. После этого в UWP-приложение, использующее SQLite, будут вставлены заранее скомпилированные двоичные файлы ядра баз данных. Если потребуется, перезапустите Visual Studio 2015 после установки расширения.

Создание проекта-примера

Первым делом создайте новый проект на основе Xamarin Forms. Шаблон проекта, который вы будете использовать в Visual Studio 2015, называется Blank App (Xamarin.Forms Portable), и он находится в папке Cross-Platform узла Visual C# в диалоге New Project (рис. 2).

Создание нового проекта Xamarin Forms в Visual Studio 2015
Рис. 2. Создание нового проекта Xamarin Forms в Visual Studio 2015

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

После щелчка кнопки OK среда Visual Studio 2015 генерирует новое решение, которое содержит проекты, ориентированные на iOS, Android, UWP, Windows Runtime и Windows Phone, плюс проект Portable Class Library (PCL). В последнем я буду писать большую часть кода, общего для кросс-платформенных проектов.

Установка NuGet-пакета SQLite

Создав проект, вам потребуется управляемый способ доступа к базам данных SQLite. Есть много библиотек, позволяющих работать с базами данных SQLite в Microsoft .NET Framework, но вам нужна специальная портируемая библиотека, которая также рассчитана на приложения Xamarin. Она называется SQLite-net и является облегченной библиотекой с открытым исходным кодом для приложений .NET, Mono и Xamarin. Она доступна в виде NuGet-пакета sqlite-net-pcl. Вы можете установить этот NuGet-пакет на уровне решения из консоли NuGet Package Manager, вводом команды install sqlite-net-pcl или через NuGet UI в Visual Studio 2015, которая позволяет щелкнуть правой кнопкой мыши имя решения в Solution Explorer, а затем выбрать Manage NuGet Packages for Solution. На (рис. 3) показано, как найти и установить пакет sqlite-net-pcl через NuGet UI.

Установка нужных NuGet-пакетов
Рис. 3. Установка нужных NuGet-пакетов

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

Специфичный для платформы код: предоставление строки подключения

Как и в случае любой базы данных, ваш код обращается к базе данных SQLite через строку подключения, так что это первый элемент, который вы должны создать. Поскольку база данных SQLite — это файл, размещаемый в локальной папке, конструирование строки подключения требует указать путь к базе данных. Хотя большая часть кода, который вы пишете, является общей для разных платформ, обработка имен с путями в Android, iOS и Windows различается, поэтому формирование строки подключения требует кода, специфичного для платформы. Затем вы вызываете строку подключения через встраивание зависимостей (dependency injection).

В проект типа Portable добавьте новый интерфейс с именем IDatabaseConnection.cs и напишите следующий код:

public interface IDatabaseConnection
{
  SQLite.SQLiteConnection DbConnection();
}

Этот интерфейс предоставляет метод DbConnection, который будет реализован в каждом специфичном для платформы проекте и который будет возвращать должную строку подключения.

Следующий шаг — добавление в каждый специфичный для платформы проект класса, реализующего этот интерфейс и возвращающего корректную строку подключения; при этом я использую пример базы данных, названной мной CustomersDb.db3. (Если вы не знакомы с SQLite, то .db3 — это расширение файла, идентифицирующее базы данных SQLite.) В проект LocalDataAccess.Droid добавьте новый класс DatabaseConnection_Android.cs и напишите код, как на рис. 4.

Рис. 4. Генерация строки подключения в проекте для Android

using SQLite;
using LocalDataAccess.Droid;
using System.IO;
[assembly: Xamarin.Forms.Dependency(typeof(
  DatabaseConnection_Android))]
namespace LocalDataAccess.Droid
{
  public class DatabaseConnection_Android : IDatabaseConnection
  {
    public SQLiteConnection DbConnection()
    {
      var dbName = "CustomersDb.db3";
      var path = Path.Combine(System.Environment.
        GetFolderPath(System.Environment.
        SpecialFolder.Personal), dbName);
      return new SQLiteConnection(path);
    }
  }
}

Атрибут Xamarin.Forms.Dependency указывает, что данный класс реализует необходимый интерфейс. Этот атрибут применяется на уровне пространства имен с помощью ключевого слова assembly. В Android файл базы данных должен храниться в папке Personal, поэтому полное имя базы данных состоит из имени файла (CustomersDb.db3) и пути к папке Personal. Полученное полное имя (с путем) назначается как параметр конструктору класса SQLiteConnection и возвращается вызвавшему коду. В iOS используется тот же API, но база данных SQLite размещается в папке Personal\Library.

Теперь добавьте новый класс с именем DatabaseConnection_iOS.cs в проект для iOS и напишите код, как на рис. 5.

Рис. 5. Генерация строки подключения в проекте для iOS

using LocalDataAccess.iOS;
using SQLite;
using System;
using System.IO;

[assembly: Xamarin.Forms.Dependency(typeof(
  DatabaseConnection_iOS))]
namespace LocalDataAccess.iOS
{
  public class DatabaseConnection_iOS
  {
    public SQLiteConnection DbConnection()
    {
      var dbName = "CustomersDb.db3";
      string personalFolder =
        System.Environment.
        GetFolderPath(Environment.SpecialFolder.Personal);
      string libraryFolder =
        Path.Combine(personalFolder, "..", "Library");
      var path = Path.Combine(libraryFolder, dbName);
      return new SQLiteConnection(path);
    }
  }
}

В Windows 10 база данных SQLite находится в локальной папке приложения. API, используемый для обращения к локальной папке, отличается от такового для остальных платформ, поскольку вы работаете с классами из пространства имен Windows.Storage, а не System.IO. Добавьте новый класс с именем DatabaseConnection_UWP.cs в проект для UWP и напишите код, как на рис. 6.

Рис. 6. Генерация строки подключения в проекте для UWP

using SQLite;
using Xamarin.Forms;
using LocalDataAccess.UWP;
using Windows.Storage;
using System.IO;

[assembly: Dependency(typeof(DatabaseConnection_UWP))]
namespace LocalDataAccess.UWP
{
  public class DatabaseConnection_UWP : IDatabaseConnection
  {
    public SQLiteConnection DbConnection()
    {
      var dbName = "CustomersDb.db3";
      var path = Path.Combine(ApplicationData.
        Current.LocalFolder.Path, dbName);
      return new SQLiteConnection(path);
    }
  }
}

На этот раз путь к локальной папке возвращается в свойстве Windows.Storage.ApplicationData.Current.LocalFolder.Path, и этот путь объединяется с именем базы данных, чтобы получить строку подключения через объект SQLiteConnection. Теперь вы написали специфичный для платформ код, позволяющий генерировать строку подключения, соответствующую платформе, на которой выполняется приложение. С этого момента остальной код будет общим. Следующий шаг — реализация модели данных.

Написание модели данных

Цель приложения — работа с упрощенным списком клиентов (customers), информация о которых хранится в базе данных SQLite, и поддержка операций над данными. Первым делом на этом этапе требуется класс, представляющий клиента, который будет спроецирован на таблицу в базе данных. В проект типа Portable добавьте класс Customer.cs. Этот класс должен реализовать интерфейс INotifyPropertyChanged, уведомляющий вызывающих об изменениях в хранящихся этим классом данных. Он будет использовать специальные атрибуты в пространстве имен SQLite для применения к свойствам правил проверки и их аннотации прочей информацией в стиле, очень близком к аннотациям данных из пространства имен System.ComponentModel.DataAnnotations. На рис. 7 показан пример класса Customer.

Рис. 7. Реализация модели данных

using SQLite;
using System.ComponentModel;

namespace LocalDataAccess
{
  [Table("Customers")
  public class Customer: INotifyPropertyChanged
  {
    private int _id;
    [PrimaryKey, AutoIncrement]
    public int Id
    {
      get
      {
        return _id;
      }

      set
      {
        this._id = value;
        OnPropertyChanged(nameof(Id));
      }
    }

    private string _companyName;
    [NotNull]
    public string CompanyName
    {
      get
      {
        return _companyName;
      }

      set
      {
        this._companyName = value;
        OnPropertyChanged(nameof(CompanyName));
      }
    }

    private string _physicalAddress;
    [MaxLength(50)]
    public string PhysicalAddress
    {
      get
      {
        return _physicalAddress;
      }

      set
      {
        this._physicalAddress=value;
        OnPropertyChanged(nameof(PhysicalAddress));
      }
    }

    private string _country;
    public string Country
    {
      get
      {
        return _country;
      }

      set
      {
        _country = value;
        OnPropertyChanged(nameof(Country));
      }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    private void OnPropertyChanged(string propertyName)
    {
      this.PropertyChanged?.Invoke(this,
        new PropertyChangedEventArgs(propertyName));
    }
  }
}

Главное в этом классе — аннотирование объектов атрибутами SQLite. Атрибут Table позволяет назначить имя таблицы, в данном случае Customers. Это не обязательно, но, если вы не укажете его, SQLite сгенерирует новую таблицу на основе имени класса, т. е. Customer в нашем примере. Поэтому в целях согласованности код генерирует таблицу с именем во множественном числе. Атрибуты PrimaryKey и AutoIncrement, примененные к свойству Id, делают его основным ключом в таблице Customers с автоматическим приращением. Атрибут NotNull, примененный к свойству CompanyName, помечает его как обязательный, а это подразумевает, что проверка хранилища данных будет неудачной, если значение этого свойства будет равно null. Атрибут MaxLength, примененный к свойству PhysicalAddress, задает максимальную длину значения этого свойства. Другой интересный атрибут — Column, который можно применить к имени свойства, чтобы в базе данных было другое название столбца.

Реализация доступа к данным

После написания простой модели данных нужно создать класс, предоставляющий методы для операций над данными. Ради ясности и простоты примера я не стану использовать здесь подход с Model-View-ViewModel (MVVM); не у всех читателей есть опыт работы с этим шаблоном, и в любом случае он больше подходит для крупных проектов. Но вы определенно можете переписать этот пример как угодно.

Давайте начнем с нового класса CustomersDataAccess.cs в проекте типа Portable, который требует следующих директив using:

using SQLite;
using System.Collections.Generic;
using System.Linq;
using Xamarin.Forms;
using System.Collections.ObjectModel;

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

private SQLiteConnection database;
private static object collisionLock = new object();

Конкретнее, блокировки должны иметь следующую форму:

// Блокировки используются,
// чтобы избежать конфликтов в базе данных
lock(collisionLock)
{
  // Здесь вы пишете операции над данными...
}

В Xamarin.Forms для создания UI используется XAML, и вы можете задействовать преимущества связывания с данными для отображения и ввода информации; по этой причине вы должны предоставлять данные так, как с ними работает XAML. Лучший подход — предоставление свойства типа ObservableCollection<Customer>:

public ObservableCollection<Customer> Customers { get; set; }

Тип ObservableCollection имеет встроенную поддержку уведомлений об изменениях, так что это самый подходящий набор для связывания с данными на XAML-платформах.

Теперь нужно реализовать конструктор класса, который также отвечает за вызов специфичной для платформы реализации метода DbConnection, возвращающего правильную строку подключения (рис. 8).

Рис. 8. Реализация конструктора класса

public CustomersDataAccess()
{
  database =
    DependencyService.Get<IDatabaseConnection>().
    DbConnection();
  database.CreateTable<Customer>();

  this.Customers =
    new ObservableCollection<Customer>(
    database.Table<Customer>());

  // Если таблица пустая, инициализируем набор
  if (!database.Table<Customer>().Any())
  {
    AddNewCustomer();
  }
}

Обратите внимание на то, как код вызывает метод DependencyService.Get. Это обобщенный метод, возвращающий специфичную для платформы реализацию предоставленного обобщенного типа, в данном случае IDatabaseConnection. При таком подходе на основе встраивания зависимостей код вызывает специфичную для платформы реализацию метода DbConnection. Тогда исполняющая среда Xamarin узнает, как разрешить вызов метода в зависимости от ОС, в которой работает приложение. Вызов этого метода также приводит к созданию базы данных, если таковая не найдена. Объект SQLiteConnection предоставляет обобщенный метод CreateTable<T>, где обобщенный тип — это класс вашей модели, в данном случае Customer. С помощью этой простой строки кода вы создаете новую таблицу Customers. Если таблица уже имеется, она не будет перезаписываться. Код также инициализирует свойство Customers; он вызывает обобщенный метод Table<T> из SQLiteConnection, где обобщенный тип по-прежнему является классом вашей модели. Table<T> возвращает объект типа TableQuery<T>, который реализует интерфейс IEnumerable<T>, а также может быть запрошен через LINQ. Реально возвращаемый результат состоит из списка объектов <T>; однако связывание объекта TableQuery непосредственно с UI — неподходящий способ представления данных, поэтому на основе возвращенного результата генерируется новый ObservableCollection<Customer> и присваивается свойству Customers. Код также вызывает метод AddNewCustomer, если таблица пуста; определение этого метода выглядит так:

public void AddNewCustomer()
{
  this.Customers.
    Add(new Customer
    {
      CompanyName = "Company name...",
      PhysicalAddress = "Address...",
      Country = "Country..."
    });
}

Этот метод просто добавляет новый Customer в набор Customers со значениями свойств по умолчанию и предотвращает связывание с пустым набором.

Запрос данных

Возможность запроса данных очень важна. В SQLite имеется два основных способа реализации запросов. Первый — использование LINQ применительно к результату вызова метода Table<T>, каковой является объектом типа TableQuery<T> и реализует интерфейс IEnumerable<T>. Второй — вызов метода SQLiteConnection.Query<T>, который принимает аргумент типа strings, представляющего запрос, написанный на SQL. Код на (рис. 9) демонстрирует, как фильтровать список клиентов по стране, используя оба подхода.

Рис. 9. Фильтрация списка клиентов по стране

public IEnumerable<Customer> GetFilteredCustomers(
  string countryName)
{
  lock(collisionLock)
  {
    var query = from cust in database.Table<Customer>()
                where cust.Country == countryName
                select cust;
    return query.AsEnumerable();
  }
}

public IEnumerable<Customer> GetFilteredCustomers()
{
  lock(collisionLock)
  {
    return database.Query<Customer>(
      "SELECT * FROM Item WHERE Country = 'Italy'").
      AsEnumerable();
  }
}

Первая перегрузка GetFilteredCustomers возвращает результат LINQ-запроса, который фильтрует данные по названию страны, передаваемому в качестве аргумента метода. Вторая перегрузка вызывает Query для выполнения SQL-запросов напрямую. Этот метод ожидает, что результатом является обобщенный List, чей обобщенный тип идентичен переданному в Query. Если запрос завершается неудачей, генерируется SQLiteException. Конечно, вы можете получить экземпляр указанного объекта с помощью LINQ или методов расширения, как в следующем коде, который вызывает FirstOrDefault применительно к списку клиентов и получает экземпляр нужного клиента по его идентификатору (id):

public Customer GetCustomer(int id)
{
  lock(collisionLock)
  {
    return database.Table<Customer>().
      FirstOrDefault(customer => customer.Id == id);
  }
}

Выполнение CRUD-операций

Операции (create, read, update and delete, CRUD) также чрезвычайно важны. Чтение данных обычно выполняется с использованием подходов, описанных в предыдущем разделе, так что сейчас я намерен обсудить, как создавать, обновлять и удалять информацию в базе данных SQLite. Объект SQLiteConnection предоставляет методы Insert, InsertAll, Update и UpdateAll для вставки или обновления объектов в базе данных. InsertAll и UpdateAll выполняют операцию вставки или обновления в наборе, который реализует IEnumerable<T>, передаваемый в качестве аргумента. Операция вставки или обновления выполняется в пакетном режиме, и оба метода также позволяют выполнять операция в виде транзакции. Учтите: если InsertAll требует, чтобы в наборе базы данных не было никаких элементов, то для UpdateAll нужно, чтобы все элементы в наборе уже существовали в базе данных. В моем случае имеется ObservableCollection<Customer>, который может содержать как объекты, извлекаемые из базы данных, так и новые объекты, добавляемые через UI и еще не сохраненные, или отложенные изменения для существующих объектов. По этой причине использование InsertAll и UpdateAll не рекомендуется. Хороший подход — просто проверять, есть ли Id у экземпляра класса Customer. Если есть, экземпляр уже существует в базе данных, поэтому его нужно лишь обновить. Если же Id равен нулю, экземпляра нет в базе данных, а значит, его нужно сохранить. Код на (рис. 10) демонстрирует, как вставить или обновить один экземпляр объекта Customer с учетом приведенных соображений.

Рис. 10. Вставка или обновление одного экземпляра объекта Customer в зависимости от наличия у него идентификатора класса Customer

public int SaveCustomer(Customer customerInstance)
{
  lock(collisionLock)
  {
    if (customerInstance.Id != 0)
    {
      database.Update(customerInstance);
      return customerInstance.Id;
    }
    else
    {
      database.Insert(customerInstance);
      return customerInstance.Id;
    }
  }
}

Код на (рис. 11) иллюстрирует, как вставить или обновить все экземпляры Customer.

Рис. 11. Вставка или обновление всех экземпляров Customer

public void SaveAllCustomers()
{
  lock(collisionLock)
  {
    foreach (var customerInstance in this.Customers)
    {
      if (customerInstance.Id != 0)
      {
        database.Update(customerInstance);
      }
      else
      {
        database.Insert(customerInstance);
      }
    }
  }
}

Методы Insert и Update возвращают целочисленное значение, представляющее количество добавленных или обновленных строк. Insert также предлагает перегрузку, принимающую строку с дополнительными SQL-выражениями, которые вы, возможно, захотите выполнить над вставленными строками. Кроме того, стоит упомянуть, что Insert автоматически обновляет по ссылке свойство в вашем бизнес-объекте, назначенное на роль основного ключа, в данном случае Customer.Id. Помимо этого, класс SQLiteConnection предоставляет методы Delete<T> и DeleteAll<T>, которые безвозвратно удаляют один или все объекты из таблицы. Операция удаления необратима, поэтому выполняйте ее только в том случае, если вы знаете, что делаете. В следующем коде реализован метод DeleteCustomer, удаляющий указанный экземпляр Customer как из набора Customers в памяти, так и из базы данных:

public int DeleteCustomer(Customer customerInstance)
{
  var id = customerInstance.Id;
  if (id != 0)
  {
    lock(collisionLock)
    {
      database.Delete<Customer>(id);
    }
  }
  this.Customers.Remove(customerInstance);
  return id;
}

Если у заданного Customer есть id, значит, он имеется в базе данных и поэтому удаляется из нее, а также из набора Customers. Delete<T> возвращает целочисленное значение, представляющее количество удаленных строк. Вы также можете безвозвратно удалить все объекты из таблицы. Вы, конечно, можете вызвать DeleteAll<T>, где обобщенный тип — это ваш бизнес-объект, такой как Customer, но вместо этого я хочу показать альтернативный подход, чтобы вы получили представление о других членах. Класс SQLiteConnection предоставляет метод DropTable<T>, который безвозвратно уничтожает таблицу в базе данных. Например, вы могли бы реализовать удаление таблицы так:

public void DeleteAllCustomers()
{
  lock(collisionLock)
  {
    database.DropTable<Customer>();
    database.CreateTable<Customer>();
  }
  this.Customers = null;
  this.Customers = new ObservableCollection<Customer>
    (database.Table<Customer>());

Этот код удаляет таблицу Customers, затем создает ее и, наконец, очищает и создает заново набор Customers. На (рис. 12) показан полный листинг для класса CustomersDataAccess.cs.

Рис. 12. Класс CustomersDataAccess.cs

using SQLite;
using System.Collections.Generic;
using System.Linq;
using Xamarin.Forms;
using System.Collections.ObjectModel;

namespace LocalDataAccess
{
  public class CustomersDataAccess
  {

    private SQLiteConnection database;
    private static object collisionLock = new object();
    public ObservableCollection<Customer>
      Customers { get; set; }

    public CustomersDataAccess()
    {
      database =
        DependencyService.Get<IDatabaseConnection>().
        DbConnection();
      database.CreateTable<Customer>();

      this.Customers =
        new ObservableCollection<Customer>(
        database.Table<Customer>());
        
      // Если таблица пуста, инициализируем набор
      if (!database.Table<Customer>().Any())
      {
        AddNewCustomer();
      }
    }

    public void AddNewCustomer()
    {
      this.Customers.Add(new Customer
      {
        CompanyName = "Company name...",
        PhysicalAddress = "Address...",
        Country = "Country..."
      });
    }

    // Используем LINQ для запроса и фильтрации данных
    public IEnumerable<Customer> GetFilteredCustomers(
      string countryName)
    {
      // Используем блокировки,
      // чтобы избежать конфликтов в базе данных
      lock(collisionLock)
      {
        var query = from cust in database.Table<Customer>()
                    where cust.Country == countryName
                    select cust;
        return query.AsEnumerable();
      }
    }

    // Используем SQLзапросы к данным
    public IEnumerable<Customer> GetFilteredCustomers()
    {
      lock(collisionLock)
      {
        return database.
          Query<Customer>
          ("SELECT * FROM Item WHERE Country = 'Italy'").
          AsEnumerable();
      }
    }

    public Customer GetCustomer(int id)
    {
      lock(collisionLock)
      {
        return database.Table<Customer>().
          FirstOrDefault(customer => customer.Id == id);
      }
    }

    public int SaveCustomer(Customer customerInstance)
    {
      lock(collisionLock)
      {
        if (customerInstance.Id != 0)
        {
          database.Update(customerInstance);
          return customerInstance.Id;
        }
        else
        {
          database.Insert(customerInstance);
          return customerInstance.Id;
        }
      }
    }

    public void SaveAllCustomers()
    {
      lock(collisionLock)
      {
        foreach (var customerInstance in this.Customers)
        {
          if (customerInstance.Id != 0)
          {
            database.Update(customerInstance);
          }
          else
          {
            database.Insert(customerInstance);
          }
        }
      }
    }

    public int DeleteCustomer(Customer customerInstance)
    {
    var id = customerInstance.Id;
      if (id != 0)
      {
        lock(collisionLock)
        {
          database.Delete<Customer>(id);
        }
      }
      this.Customers.Remove(customerInstance);
      return id;
    }

    public void DeleteAllCustomers()
    {
      lock(collisionLock)
      {
        database.DropTable<Customer>();
        database.CreateTable<Customer>();
      }
      this.Customers = null;
      this.Customers = new ObservableCollection<Customer>
        (database.Table<Customer>());
    }
  }
}

Простой UI со связыванием с данными

Теперь, когда у вас есть модель и уровень доступа к данным, нужно создать UI для представления и редактирования данных. Как вы знаете, Xamarin.Forms позволяет писать UI либо на C#, либо на XAML, но последний обеспечивает большее отделение UI от процедурного кода и дает вам непосредственное восприятие иерархической организации UI, поэтому для своей статьи я выберу именно его. Стоит отметить, что Xamarin.Forms 2.0 также позволяет включить оптимизацию XAML (XamlC) для большей производительности и проверки ошибок на этапе компиляции. Подробнее о XamlC см. на bit.ly/24BSUC8.

Теперь давайте напишем простую страницу, которая показывает список данных с некоторыми кнопками. В Xamarin.Forms страницы являются общими элементами, поэтому они добавляются в проект Portable. Для этого в Solution Explorer щелкните правой кнопкой мыши проект Portable и выберите Add | New Item. В диалоге Add New Item найдите узел CrossPlatform и укажите шаблон Forms Xaml Page (рис. 13). Назовите новую страницу CustomersPage.cs и щелкните Add.

Добавление новой страницы на основе XAML
Рис. 13. Добавление новой страницы на основе XAML

Чтобы отображать список клиентов, UI будет состоять из элемента управления ListView, связанного с набором Customers, который предоставляется классом CustomersDataAccess. DataTemplate элемента состоит из четырех элементов управления Entry, каждый из которых связан со свойством в классе модели Customer. Если у вас есть опыт работы на других XAML-платформах вроде Windows Presentation Foundation (WPF) и UWP, вы можете считать Entry эквивалентом TextBox. Элементы управления Entry группируются в панели StackLayout, включенной в контейнер ViewCell. Для тех, кто перешел с WPF и UWP, StackLayout — это Xamarin-эквивалент контейнера StackPanel. ViewCell позволяет создать пользовательские ячейки в элементах управления наподобие ListView. Вы заметите, что я использую Entry со свойством IsEnabled, равным False для свойства Customer.Id, вместо Label, который по своей природе является элементом управления только для чтения. Как вы, возможно, помните, при вызове метода SQLiteConnection.Insert свойство, назначенное основным ключом в вашей модели, обновляется, поэтому UI должен уметь автоматически отражать это изменение. К сожалению, Label не обновляется новым значением, тогда как Entry обновляется, и это является причиной, по которой используется Entry, но задается только для чтения.

Для тех, кто перешел с WPF и UWP, StackLayout — это Xamarin-эквивалент контейнера StackPanel.

Вторая часть UI состоит из кнопок ToolbarItem, которые обеспечивают взаимодействие пользователя с приложением через удобную панель меню, доступную на всех платформах. Ради простоты эти кнопки будут реализованы в дополнительной области панели меню, которая не требует создания значков, специфичных для каждой платформы. Полный код для этого UI приведен на (рис. 14).

Рис. 14. Пользовательский интерфейс CustomersPage

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
  xmlns:x="https://schemas.microsoft.com/winfx/2009/xaml"
  x:Class="LocalDataAccess.CustomersPage">

  <ListView x:Name="CustomersView"
            ItemsSource="{Binding Path=Customers}"
            ListView.RowHeight="150">
    <ListView.ItemTemplate>
      <DataTemplate>
        <ViewCell>
          <StackLayout Orientation="Vertical">
            <Entry Text="{Binding Id}" IsEnabled="False"/>
            <Entry Text="{Binding CompanyName}" />
            <Entry Text="{Binding PhysicalAddress}"/>
            <Entry Text="{Binding Country}"/>
          </StackLayout>
        </ViewCell>
      </DataTemplate>
    </ListView.ItemTemplate>
  </ListView>

<ContentPage.ToolbarItems>
  <ToolbarItem Name="Add" Activated="OnAddClick"
               Priority="0" Order="Secondary" />
  <ToolbarItem Name="Remove" Activated="OnRemoveClick"
               Priority="1" Order="Secondary" />
  <ToolbarItem Name="Remove all" Activated="OnRemoveAllClick"
               Priority="2" Order="Secondary" />
  <ToolbarItem Name="Save" Activated="OnSaveClick"
               Priority="3" Order="Secondary" />
</ContentPage.ToolbarItems>
</ContentPage>

Заметьте, что каждый ToolbarItem имеет свойство Order, установленное как Secondary; если вы хотите сделать их доступными в основной области панели инструментов и предоставить специфичные для платформы значки, измените значение этого свойства на Primary. Кроме того, свойство Priority позволяет указывать порядок, в котором ToolbarItem появляется на панели инструментов, а Activated можно сравнить с событием щелчка, и оно требует обработчика событий.

Следующий шаг — написание кода на C#, который создает экземпляр класса CustomersDataAccess, связывает объекты через механизм привязки к данным и выполняет операции над данными. На (рис. 15) показан отделенный код на C# для страницы (более подробную информацию см. в комментариях).

Рис. 15. Отделенный код для CustomersPage

using System;
using System.Linq;
using Xamarin.Forms;

namespace LocalDataAccess
{
  public partial class CustomersPage : ContentPage
  {
    private CustomersDataAccess dataAccess;
    public CustomersPage()
    {
      InitializeComponent();

      // Экземпляр CustomersDataAccessClass, используемый
      // для связывания с данными и доступа к данным
      this.dataAccess = new CustomersDataAccess();
    }

    // Событие, генерируемое при выводе страницы
    protected override void OnAppearing()
    {
      base.OnAppearing();

      // Экземпляр CustomersDataAccess является
      // источником привязки к данным
      this.BindingContext = this.dataAccess;
    }

    // Сохраняем любые отложенные изменения
    private void OnSaveClick(object sender, EventArgs e)
    {
      this.dataAccess.SaveAllCustomers();
    }

    // Добавляем нового клиента в набор Customers
    private void OnAddClick(object sender, EventArgs e)
    {
      this.dataAccess.AddNewCustomer();
    }

    // Удаляем текущего клиента. Если он есть в базе данных,
    // то будет удален и оттуда.
    private void OnRemoveClick(object sender, EventArgs e)
    {
      var currentCustomer =
        this.CustomersView.SelectedItem as Customer;
      if (currentCustomer!=null)
      {
        this.dataAccess.DeleteCustomer(currentCustomer);
      }
    }

    // Удаляем всех клиентов. Используйте объект DisplayAlert,
    // чтобы запросить подтверждение у пользователя.
    private async void OnRemoveAllClick(
      object sender, EventArgs e)
    {
      if (this.dataAccess.Customers.Any())
      {
        var result =
          await DisplayAlert("Confirmation",
          "Are you sure? This cannot be undone",
          "OK", "Cancel");

        if (result == true)
        {
          this.dataAccess.DeleteAllCustomers();
          this.BindingContext = this.dataAccess;
        }
      }
    }
  }
}

Свойство BindingContext является эквивалентом DataContext в WPF и UWP и представляет источник данных для текущей страницы.

Тестирование приложения в эмуляторах

Теперь нужно протестировать приложение. В файле App.cs смените стартовую страницу. Когда вы создаете проект Xamarin.Forms, Visual Studio 2015 генерирует страницу, написанную на процедурном коде, и присваивает ее объекту MainPage. Это присваивание осуществляется в конструкторе класса App, поэтому откройте App.cs и замените конструктор App на следующее:

public App()
{
  // Корневая страница вашего приложения
  MainPage = new NavigationPage(new CustomersPage());
}

Возможно, вы удивлены, что экземпляр CustomersPage не присваивается MainPage напрямую, а инкапсулируется как параметр для экземпляра класса NavigationPage. Дело в том, что использование NavigationPage — единственный способ показать панель меню в Android, но это никак не влиет на поведение UI. В зависимости от платформы, на которой вы хотите протестировать приложение, выберите соответствующий стартовый проект и подходящий эмулятор из стандартной панели инструментов в Visual Studio, а затем нажмите F5. На (рис. 16) показано приложение, выполняемое в Android и Windows 10 Mobile.

Приложение-пример, выполняемое на разных платформах
Рис. 16. Приложение-пример, выполняемое на разных платформах

Заметьте, что элементы панель инструментов корректно показываются на панели меню и что вы можете одинаково работать с данными на разных устройствах и в разных ОС.

Написание специфичного для платформ кода с помощью проектов Shared

Создание общего кода — ключевая концепция в Xamarin.Forms. По сути, весь код, не использующий специфичные для платформы API, можно написать один раз, а затем задействовать его в проектах для iOS, Android и Windows. Когда вы создаете проект Xamarin.Forms, Visual Studio 2015 предлагает шаблоны проектов Blank App (Xamarin.Forms Portable) и Blank App (Xamarin.Forms Shared) на основе PCL и общих (shared) проектов соответственно. Вообще-то, в Visual Studio код можно сделать общим либо с помощью PCL, которые генерируют повторно используемые DLL-библиотеки, ориентированные на несколько платформ, но не позволяющие писать специфичный для конкретной платформы код, либо с помощью общих проектов, не генерирующих сборку, и поэтому их область ограничена решением, к которому они относятся. Проекты Shared дают возможность писать специфичный для платформ код.

С помощью SQLite приложения Xamarin.Forms могут легко управлять локальными базами данных, применяя портируемое, не использующее сервер ядро баз данных с открытым исходным кодом, которое поддерживает C# и LINQ-запросы.

В Xamarin.Forms, когда Visual Studio 2015 генерирует новое решение, добавляется или проект PCL, или общий проект — в зависимости от выбранного шаблона. В любом случае выбранный шаблон является тем местом, куда вы помещаете весь общий код. Но в портируемом проекте вы кодируете интерфейсы, у которых будут специфичные для платформы реализации в проектах iOS, Android и Windows, чьи члены потом будут вызываться через механизм встраивания зависимостей. Как раз это вы и видели в этой статье.

В случае проектов Shared можно использовать условные директивы препроцессора (#if, #else, #endif) и переменные окружения, которые позволяют легко понять, на какой платформе выполняется приложение, так что вы можете писать специфичный для платформы код непосредственно в общем проекте. В приложении-примере, описываемом в этой статье, строка подключения формируется с помощью специфичных для платформ API. Если бы вы использовали общий проект, то могли бы писать код, приведенный на (рис. 17), непосредственно в общем проекте. Помните, что проект Shared не поддерживает NuGet-пакеты, поэтому вы должны включить файл SQLite.cs (доступен на GitHub по ссылке bit.ly/1QU8uiR).

Рис. 17. Создание строки подключения в общем проекте с помощью условных директив препроцессора

private string databasePath {
  get {
    var dbName = "CustomersDb.db3";
    #if __IOS__
    string folder = Environment.GetFolderPath
      (Environment.SpecialFolder.Personal);
    folder = Path.Combine (folder, "..", "Library");
    var databasePath = Path.Combine(folder, dbName);
    #else
    #if __ANDROID__
    string folder = Environment.GetFolderPath
      (Environment.SpecialFolder.Personal);
    var databasePath = Path.Combine(folder, dbName);
    #else  // WinPhone
    var databasePath =
      Path.Combine(Windows.Storage.ApplicationData.Current.
      LocalFolder.Path, dbName);
    #endif
    #endif

    return databasePath;
  }
}

Как видите, можно использовать директивы #if и #else, чтобы определить, на какой платформе работает приложение. Каждая платформа представлена переменными окружения __IOS__, __ANDROID__ или __WINPHONE__, где __WINPHONE__ предназначена для Windows 8.x, Windows Phone 8.x и UWP. Выбор между портируемыми библиотеками и общими проектами зависит исключительно от ваших потребностей. Портируемые библиотеки являются повторно используемыми и требуют очень четкого разделения между общим и специфичным для платформ кодом. Общие проекты позволяют писать специфичный для платформ код вместе с общим, но не генерируют повторно используемые библиотеки и труднее в сопровождении, если ваша кодовая база сильно увеличится.

Дальнейшие совершенствования

Приложение-пример, описанное в этой статье, определенно можно улучшить во многих отношениях. Например, вы могли бы реализовать шаблон MVVM и предоставлять команды для UI вместо обработки событий щелчка, а также подумать о перемещении элементов панели инструментов в основную область меню, подготовив специфичные для платформ значки. В отношении данных вам может понадобиться работа с внешними ключами и взаимосвязями. Поскольку обработка всего этого с помощью библиотеки SQLite отнюдь не проста, вероятно, стоит воспользоваться библиотекой SQLite-Net Extensions (bit.ly/24yhhnP), проектом с открытым исходным кодом, который упрощает C#-код, нужный для работы со взаимосвязями и для более сложных сценариев. Это лишь небольшой список возможных усовершенствований, которые вы могли бы попробовать реализовать для более глубокого изучения предмета.

Заключение

Во многих ситуациях мобильным приложениям необходимо локальное хранилище данных. С помощью SQLite приложения Xamarin.Forms могут легко управлять локальными базами данных, применяя портируемое, не использующее сервер ядро баз данных с открытым исходным кодом, которое поддерживает C# и LINQ-запросы. SQLite предлагает интуитивно понятные объекты для работы с объектами таблиц и баз данных, значительно облегчая реализацию доступа к локальным данным на любой платформе. Более подробную информацию и описание дополнительных сценариев см. в документации SQLite (sqlite.org/docs.html).


Алессандро Дель Соуле (Alessandro Del Sole) — был Microsoft MVP с 2008 года. Награждался званием MVP of the Year пять раз. Автор многих печатных и электронных книг, обучающих видеороликов и статей по .NET-разработке в Visual Studio. Является экспертом и разработчиком решений в Brain-Sys (brain-sys.it), основное внимание уделяя .NET-разработке, обучению и консалтингу. Следите за его заметками в Twitter @progalex.

Выражаю благодарность за рецензирование статьи экспертам Кевину Эшли (Kevin Ashley) и Саре Сильва (Sara Silva).