Поскольку я собирал свой предыдущий пост об уведомлениях в игре HTML 5 на Windows 8 , я не мог не подумать, что должен быть лучший способ управлять хранилищем локальной таблицы лидеров. Если вы не знакомы с этим образцом , это простая игра, в которой вы видите, сколько раз вы можете коснуться прыгающего мяча, прежде чем он достигнет границ дисплея десять раз.
Что плохого в использовании ApplicationData для таблицы лидеров?
В этом примере реализована локальная таблица лидеров, которая использует хранилище данных роуминга в хранилище данных приложений Windows 8 , как видно из этого фрагмента кода в методе Scores.setScores :
var roamingSettings = Windows.Storage.ApplicationData.current.roamingSettings; roamingSettings.values["scores"] = JSON.stringify(scoresToSet);
Здесь ScoresToSet представляет собой массив объектов JavaScript, каждый из которых содержит игрока, счет и уровень квалификации, например
{ player:"Jim", score: 32, skill: 1 }
Поскольку хранилище в роуминге синхронизируется с облаком, текущая реализация выводит таблицу лидеров на любые другие устройства, на которых пользователь установил игру, и это может быть, а может и не быть функцией, которую вы хотели бы показать. В конце концов, в игру может быть сложнее играть с различными форм-факторами, касанием мыши и т. Д. Так имеет ли смысл объединять таблицу лидеров в одну на этих разных устройствах? В противном случае было бы тривиально переключиться на локальное хранилище данных приложения, просто ссылаясь на Windows.Storage.ApplicationData.current.localSettings вместо roamingSettings .
Это не та часть, которая беспокоила меня, хотя; это был этот код:
for (var i = scores.length - 1; i >= 0; i--) { if (score.score > scores[i].score) { if (i < scores.length - 1) { scores[i + 1] = scores[i]; scores[i] = score; newScoreRank = i + 1; } if (i === 0) { scores[i] = score; newScoreRank = 1; } } else if (score.score === scores[i].score && i < scores.length - 1) { scores[i + 1] = score; newScoreRank = i + 2; break; } }
Конечно, это всего лишь пример приложения, но посмотрите на объем кода, необходимый для манипулирования таблицей лидеров, и вставьте новый счет в нужном месте, чтобы впоследствии его можно было массово сериализовать в хранилище в роуминге. Кроме того, есть ограниченная возможность показывать другие интересные виды игрового процесса, например историю всех игр, которые Джо завершил, или таблицу лидеров, отфильтрованную по уровню мастерства.
Дело за IndexedDB
Чтобы получить желаемую гибкость, нам нужно хранить больше, чем просто снимок таблицы лидеров, и нам нужны некоторые возможности для выполнения хотя бы элементарных специальных запросов к данным. Это то, для чего SQL Server подойдет! Конечно, в контексте приложений Магазина Windows гораздо меньше вариантов JavaScript, SQL Server и серверов реляционных баз данных не поддерживаются, если только они не размещены в качестве службы.
Хорошая новость заключается в том, что приложения JavaScript встроенные в Windows 8 могут использовать индексированную спецификацию API баз данных в W3C или IndexedDB для краткости. IndexedDB — это нереляционное хранилище объектов JavaScript-объектов, обеспечивающее семантику транзакций с помощью следующих конструкций:
база данных | набор хранилищ объектов ; каждое приложение (или, более конкретно, источник ) может содержать несколько баз данных |
хранилище объектов | коллекция пар ключ-значение в базе данных; аналог таблицы в реляционной базе данных |
ключ | значение типа float , date , string или array, которое однозначно идентифицирует объект с заданным хранилищем объектов, очень похоже на столбцы первичного ключа в таблице реляционной базы данных. Ключ также накладывает порядок сортировки по возрастанию на связанные объекты в хранилище. |
значение | объект JavaScript, связанный с данным ключом. Как и базы данных документов NoSQL, такие как MongoDB и CouchBase , значения могут быть сложными объектами без требований схематизации в хранилище объектов. |
ключевой путь | строка, которая определяет, как извлечь ключ из значения, обычно это конкатенация атрибутов, которые определяют путь через объект к свойству ключа, разделенных точками |
показатель | специализированная конструкция хранилища, которая поддерживает поиск объектов в хранилище по значениям атрибута |
сделка | атомные и долговечные операции с базой данных, в одном из трех режимов : только для чтения , чтения и записи , или versionchange . |
запрос | операция чтения или записи, аналогичная инструкции SQL (хотя IndexedDB не использует SQL). |
ключевой диапазон | один или диапазон значений ключа, для которого можно получить значения из хранилища объектов |
курсор | итератор над результатами запроса , который может быть пройден в одном из четырех режимов или направлений: следующее , nextunique , предыдущих или prevunique . |
Если вы раньше работали с системами управления реляционными базами данных, эти понятия должны быть знакомы, но имейте в виду, что здесь нет реляционной семантики — это означает, что нет соединений! Если вы возились с некоторыми предложениями NoSQL «ключ-значение» , это не будет шоком, и вы будете чувствовать себя как дома.
Кстати, если вы ищете функциональность, похожую на реляционную базу данных, для своего приложения C # или VB Windows Store , взгляните на SQLite (и в частности на отличные сообщения в блоге Тима Хойера ). С SQLite вы по сути встраиваете транзакционный движок SQL прямо в процесс вашего приложения!
Существует также порт SQLite для JavaScript , но я не исследовал его жизнеспособность для приложений Магазина Windows. Наконец, для тех из вас, кто знаком с Web SQL , обратите внимание, что W3C прекратил работу над этой спецификацией.
Реализация локальной таблицы лидеров
Определение схемы
Таблица лидеров в текущем примере HTML 5 содержит объекты с согласованной схемой трех свойств (или атрибутов):
игрок : имя игрока
оценка : оценка игры
skill: уровень навыка связанной игры (от 0 до 2, от Basic до Advanced)
Одна из проблем этой объектной структуры заключается в том, что уникальность не обязательно гарантируется. Я мог забить одинаково в двух играх на одном уровне. В этом случае я был бы вынужден добавить метку времени, которая указывает, когда игра была завершена, и это то, что я, вероятно, хотел бы показать в любом случае в таблице лидеров — чем длиннее кто-то на вершине, тем более убедительным является попытка бить их!
В этом случае временная метка (в формате UTC) будет отлично работать в качестве ключа, поскольку она будет уникальной на этом устройстве. Но вы также можете использовать генератор ключей, который просто создает монотонно возрастающую последовательность значений (до 2 ^ 53) всякий раз, когда нужен ключ.
Теперь давайте вернемся к основным конструкциям IndexedDB и сопоставим с ними несколько конкретных имен, которые будут использоваться в реальном коде:
база данных | Лидеры |
хранилище объектов | игры |
ключ | gamedate |
значение | объект со следующим прототипом {gamedate: <date>, player: <string>; оценка: <номер>, навык: <номер>} |
ключевой путь | gamedate |
показатель | три показателя: игрок, оценка и навык + оценка |
Чтобы обновить таблицу лидеров для использования IndexedDB, нам просто нужно заменить реализацию методов, которые в настоящее время обращаются к хранилищу в роуминге, и этот код локализован для трех методов, расположенных в Scores.js.
- getScores возвращает массив JavaScript из десяти лучших результатов, полученных из перемещаемого хранилища. Этот метод будет изменен, чтобы возвращать оценки из IndexedDB, возможно, с некоторыми критериями фильтра, позволяющими более богатый набор параметров
- setScores записывают снимок таблицы лидеров, сериализованной как JSON, обратно в хранилище в роуминге. Обновленная реализация не нуждается в этом методе.
- newScore добавляет оценку из недавно завершенной игры в соответствующую позицию массива таблицы лидеров JavaScript, а затем записывает весь массив обратно в роуминг-хранилище (через setScores ). В обновленной реализации эта работа сводится к простой операции вставки (или, точнее, запроса на запись ) в IndexedDB.
Создание базы данных
Нам нужно начать с создания базы данных, если она не существует (и это не будет первый раз, когда приложение будет запущено на устройстве с Windows 8). Хорошее место для этого в игровой среде HTML 5 — метод initialize модуля Game (в game.js ):
initialize: function (state) { if (GameManager.gameId === null) { this.stateHelper = state; this.state = state.internal; this.settings = state.external; } var db = window.indexedDB.open("leaderboard", 1); db.onupgradeneeded = function (e) { GameManager.leaderboard = e.target.result; scoresTable = GameManager.leaderboard.createObjectStore( "scores", { keyPath: "gamedate" }); scoresTable.createIndex("playerIdx", "player", { unique: false }); scoresTable.createIndex("scoreIdx", "score", { unique: false }); scoresTable.createIndex("skillIdx", "skill", { unique: false }); txn = e.target.transaction; txn.onerror = function () { console.log("Schema definition failed"); }; txn.oncomplete = function () { console.log("Schema definition worked"); }; }; db.onerror = function () { console.log("Database creation failed"); }; db.onsuccess = function (e) { GameManager.leaderboard = e.target.result; }; },
Строки 1 — 7 | оригинальная реализация инициализации сохраняется |
Линия 9 | откройте существующую базу данных с именем лидеров или создайте ее, если она еще не существует. При создании база данных имеет обозначение версии, которое является пустым значением, поэтому второй параметр здесь (1) интерпретируется как запрос на обновление версии базы данных. Только с помощью запросов на обновление вы можете изменить схему базы данных. |
Линия 10 | onupgradeneeded обратного вызова вызывается всякий раз , когда есть запрос обновления версии базы данных, как это происходит , когда база данных открыта с номером версии , которая пока не существует. |
Линия 11 | объект базы данных сохраняется как новый атрибут одноэлементного экземпляра GameManager . |
Строка 13 | оценки объекта магазин создается с помощью ключевого пути из gamedate , уникального ключа магазина. |
Линии 14 — 16 | создаются три разных индекса, ссылающихся на разные ключевые пути (значения, которые еще не добавлены в хранилище объектов). |
Линии 18 — 20 | обратные вызовы связаны с транзакцией, в которой вносятся изменения схемы. Эта транзакция работает в режиме смены версий , который является единственным контекстом, в котором можно изменить схему базы данных. После внесения изменений вызывается обратный вызов oncomplete ; В этой реализации он просто отправляет сообщение в консоль JavaScript. |
Линия 22 | обратный вызов ошибки будет вызываться , если запрос на открытии базы данных не удается. |
Линия 23-25 | успех обратного вызова вызывается , когда открыт успешно (и версия обновления не требуется). Здесь контекст базы данных для удобства сохраняется как атрибут GameManager . |
Добавление новых результатов в базу данных
На данный момент база данных пуста, и записи добавляются каждый раз, когда игрок завершает игру и регистрируется новый счет. Это происходит в методе newScore модуля Scores ( Scores.js ). Если код извлек последнюю известную таблицу лидеров из хранилища в роуминге и вручную вставил новую оценку (если она появилась в первой десятке), теперь новая запись оценки просто вставляется в базу данных:
newScore: function (score) { var txn = GameManager.leaderboard.transaction(["scores"], "readwrite"); scoreTable = txn.objectStore("scores"); insRequest = scoreTable.add( { player: score.player, score: score.score, skill: score.skill, gamedate: new Date() }); },
Линия 3 | новая транзакция создается с участием оценки магазина объекта и режим READWRITE . |
Строка 5 | дескриптор хранилища объекта store получается. |
Строка 6-12 | новый объект, содержащий информацию о счете только что завершенной игры, добавляется к результатам . Ключом к этому значению является атрибут gamedate ; напомнит , что был ключевым путем , определенный для баллов хранилища объектов , когда схема впервые была создана.
Что, возможно, не очевидно, так это то, что метод add является асинхронным. После успешного завершения обратный вызов по запросу ( insRequest ) сработает. Аналогично, onerror выполнится, если возникла проблема с вставкой новой пары ключ-значение. Для нашего сценария списка лидеров нет конкретных действий, которые необходимо предпринять для успеха; запись была вставлена. Что касается пути ошибки, производственный код, вероятно, будет включать некоторую обработку ошибок в обратном вызове, но, чтобы держать вещи в фокусе, у меня есть эти детали для читателя. |
Получение результатов из базы данных
В исходной реализации поток кода для заполнения таблицы лидеров шел примерно так:
ScoresPage пользовательский интерфейс содержит ListView , который привязан к массиву JavaScript (модели представления) , возвращенного GetItems в scoresPage.js . getItems, в свою очередь, вызывает getScores, чтобы извлечь последний снимок таблицы лидеров из настроек роуминга, по сути захватывая модель и создавая модель представления. Все это происходит синхронно. На приведенной выше схеме цепочка вызовов обозначена сплошными синими стрелками, а возвращаемые данные представлены красными пунктирными стрелками.
Чтобы адаптировать этот рабочий процесс для IndexedDB, концептуально единственное изменение состоит в том, чтобы getScores имел доступ к IndexedDB по сравнению с хранилищем в роуминге. Однако это не так просто: доступ к данным для IndexedDB происходит асинхронно, и существующий код ожидает, что getScores будет блокироваться, пока у него не будут все данные. Таким образом, вместо возврата массива JavaScript в getItems , мы передаем функции обратного вызова вперед. Когда запрос для IndexedDB завершен, его обратный вызов запускается и, в свою очередь, порождает выполнение перенаправленных обратных вызовов, которые устанавливают ViewModel и запускают привязку. Поток теперь больше похож на:
Как и следовало ожидать, в коде есть некоторый волновой эффект, поскольку и getItems, и getScores должны участвовать в цепочке асинхронных вызовов, принимая для выполнения обратные вызовы. getScores (реализация ниже) теперь принимает функцию обратного вызова (переданную ему getItems ), которая вызывается, когда поиск в базе данных завершен. Эта функция обратного вызова (показанная чуть позже) берет данные и создает ViewModel, а когда она завершается, она вызывает другой обратный вызов, который устанавливает привязку ListView .
Код IndexedDB ограничен методом getScores , и в игре есть три основных конструкции:
- сделка , созданная в строке 5 ниже , и с его режимом набора для чтения , так как это только чтение данных. Когда все запросы по сделке будут завершены, то OnComplete обратного вызова пожаров, которые , в свою очередь , заполнит ViewModel через populateViewModel функции пересылаются из GetItems . Не показаны реализации onabort и onerror , которые должны быть включены для обработки ошибок и непредвиденных сбоев.
- запрос (здесь запрос), созданный в строке 10 , и используя один из индексов , определенных на магазинах схеме. Так как этот запрос должен вернуть таблицу лидеров, результаты должны быть отсортированы в порядке убывания. В сценарии SQL вы бы добавили предложение ORDER BY; здесь индекс всегда сортирует объекты в порядке возрастания, поэтому просто обойдите пространство индекса назад — отсюда и аргумент prev для openCursor .
- курсор , который является результатом запроса и пройденным в линиях 14-18. Каждое значение ( «строка») от курсора помещается в массив JavaScript, который будет в конечном счете передается в populateViewModel обратного вызова. Здесь метод continue перемещает курсор в требуемом направлении. Объект запроса используется повторно; следовательно, onsuccess продолжает вызываться до тех пор, пока результаты запроса не будут исчерпаны и курсор не станет неопределенным.
getScores: function (populateViewModel) { var scoresFromDb = []; var txn = GameManager.leaderboard.transaction(["scores"], "readonly"); txn.oncomplete = function () { populateViewModel(scoresFromDb); }; var query = txn.objectStore("scores") .index("scoreIdx") .openCursor(null, "prev"); query.onsuccess = function (e) { var cursor = e.target.result; if (cursor) { scoresFromDb.push(cursor.value); cursor.continue(); } }; },
Для полноты изложения следует пересмотреть getItems. Вместо getScores, возвращающих список оценок, анонимная функция, которая заполняет ViewModel, передается в ( Строки 6 — 24 ), так что, когда оценки извлекаются из базы данных, эта функция может быть вызван, чтобы установить значения в ViewModel.
function getItems(updateBindings) { // TODO: Update desired background styling values of the score table here var colors = ["rgba(209, 211, 212, 1)", "rgba(147, 149, 152, 1)", "rgba(65, 64, 66, 1)"]; var scores = GameManager.scoreHelper.getScores( function (scores) { var items = []; var groupId = 0; // TODO: Update to match your score structure for (var i = 0; i < scores.length; i++) { items.push({ group: pageData.groups[groupId], key: "item" + i, rank: i + 1, player: scores[i].player, score: scores[i].score, skill: GameManager.scoreHelper.getSkillText(scores[i].skill), backgroundColor: colors[i % colors.length], }); } updateBindings(items); }); }
getItems (ниже) аналогичным образом принимает функцию в качестве аргумента, и эта функция обеспечивает доступность недавно заполненного ViewModel ( items ) для кода инициализации страницы, который устанавливает привязку пользовательского интерфейса. В этом же модуле JavaScript есть еще один вызов getItems, который должен быть изменен аналогичным образом, но я опускаю детали, поскольку это имеет отношение к обсуждению IndexedDB.
function ready(element, options) { // Set up ListView WinJS.UI.processAll(element) .done(function () { if (list) { var listView = element.querySelector(".collectionList").winControl; pageData.groups = getGroups(); getItems(function (items) { pageData.items = items; list = new WinJS.Binding.List(pageData.items); groupedItems = list.createGrouped(groupKeySelector, groupDataSelector); listView.forceLayout(); }); } }); }
Получение баллов за один уровень навыка из базы данных
Теперь, когда обновленная таблица лидеров имеет паритет функций с оригинальной реализацией, давайте добавим некоторые новые функциональные возможности. Текущая игра имеет три уровня: базовый, средний и продвинутый, поэтому было бы неплохо иметь возможность отображать списки лидеров для каждого из этих уровней отдельно. Давайте предположим, что есть некоторый элемент пользовательского интерфейса, например трехзначный слайдер, который позволяет пользователю выбрать уровень навыка, отображаемый в таблице лидеров. Значение, полученное из этого элемента пользовательского интерфейса, можно сделать доступным для функции getScores , возможно, в качестве аргумента.
Резолютивная часть getScores воспроизводится ниже; это та же версия, которую мы обсуждали выше, которая возвращает все оценки в порядке убывания.
var query = txn.objectStore("scores") .index("scoreIdx") .openCursor(null, "prev"); query.onsuccess = function (e) { var cursor = e.target.result; if (cursor) { scoresFromDb.push(cursor.value); cursor.continue(); } };
Напомним, что когда база данных создавалась, мы настраивали три разных индекса, в том числе один на уровне квалификации, поэтому одним из подходов было бы использовать этот индекс и ограничить извлеченные элементы — диапазон ключей — выбранным уровнем квалификации. Этот запрос будет выглядеть примерно так, с выделенными изменениями:
var query = txn.objectStore("scores") .index("skillIdx") .openCursor(IDBKeyRange.only(skillLevel), "prev"); query.onsuccess = function (e) { var cursor = e.target.result; if (cursor) { scoresFromDb.push(cursor.value); cursor.continue(); } scoresFromDb.sort(function (a, b) { return b.score - a.score; }); };
Поскольку нам нужны только записи оценки, связанные с выбранным уровнем (доступно здесь как skillLevel ), имеет смысл использовать индекс, определенный для этого атрибута объектов оценки. Кроме того, нам нужен только подмножество диапазона, который охватывает индекс. В данном случае это одно значение, поэтому мы можем использовать единственный диапазон клавиш. Вы также можете указать другие ключевые диапазоны, ограниченные (или нет) либо в верхних, либо в нижних границах диапазона значений, которые охватывает индекс. При возвращении только одного значения ключа режим курсора на самом деле не имеет значения, поэтому необязательный аргумент (со значением prev ) можно удалить из вызова openCursor .
Вы можете быть удивлены, увидев вызов для сортировки массива! Поскольку IndexedDB не поддерживает составные индексы, вы не можете легко сделать то, что вы могли бы сделать в SQL с предложением ORDER BY, указав два столбца. Работая с skillIdx , мы получили преимущество, заключающееся в том , что курсор возвращает только те оценки за требуемый уровень игрового навыка, но их упорядоченный порядок не гарантируется. Таблица лидеров должна отображать их в порядке убывания, поэтому необходим вызов встроенного метода сортировки JavaScript .
Альтернативой было бы остаться с исходным индексом ( scoreIdx ), который сортирует по количеству по убыванию, но добавляет к ScoresFromDb только те значения, которые имеют правильное значение навыка . Простого условия if (if (cursor.value.skill == skillLevel)) перед отправкой значения в ScoresFromDb будет достаточно.
Квоты и другие детали
Надеемся, что приведенный здесь пример сценария предоставил некоторое представление о том, как вы можете использовать IndexedDB в своих собственных приложениях, играх или иным образом. Помните, что это всего лишь один из нескольких вариантов хранения приложений JavaScript Магазина Windows , и у каждого из них есть свои уникальные характеристики и ограничения. Для IndexedDB, в частности, примечание:
- IndexedDB реализуется через базу данных Extensible Storage Engine (ESE), которая использует семантику хранилища ISAM и работает в отдельном процессе, взаимодействующем с вашим приложением через RPC.
- Базы данных, управляемые приложением, могут быть удалены только в том случае, если приложение предоставляет эти функции или если вы удалили приложение с вашего компьютера.
- Каждое приложение Магазина Windows имеет квоту в 250 МБ хранилища IndexedDB, к которому оно может получить доступ; Кроме того, IndexedDB для всех приложений Магазина Windows в сочетании ограничен
- 375 МБ, если жесткий диск устройства меньше 30 ГБ; в противном случае,
- 4% от размера диска или 20 ГБ, в зависимости от того, что меньше
- Также обратите внимание, что нет API, который бы определял, сколько места осталось, поэтому вам нужно защищаться от кода, обрабатывая исключение quotaExceededError .