Если вы уже разобрались, как программировать на Swift, то, вероятно, вы уже знаете основы языка Swift и умеете писать классы и структуры.
Но Swift — это нечто большее, гораздо больше.
Цель данной статьи — рассказать об очень сильной стороне языка Swift, который уже стал популярен в ряде других языков под названием дженерики .
В типобезопасном языке программирования часто возникает проблема написания кода, который работает только с одним типом, но совершенно корректен для другого типа.
Представьте, например, что функция складывает два целых числа.
Функция, складывающая два числа с плавающей запятой, будет выглядеть очень похоже, но на самом деле она будет выглядеть идентично.
Единственная разница будет заключаться в типе значения переменных.
В строго типизированном языке вам придется определять отдельные функции, такие как addInts, addFloats, addDoubles и т. д., где каждая функция имеет правильный аргумент и типы возвращаемых значений.
Многие языки программирования реализуют решения этой проблемы.
Например, C++ использует шаблоны.
Swift, как Java и C#, использует дженерики — отсюда и тема этого урока! В этой статье о обобщенном программировании на Swift вы погрузитесь в мир существующих обобщений языка программирования, включая те, которые вы уже видели.
Затем создайте программу поиска фотографий Flickr с настраиваемой общей структурой данных для отслеживания критериев поиска пользователя.
Примечание: Эта статья о функциональном программировании на Swift предназначена для тех, кто уже знаком с основами Swift. Если вы новичок в основах языка Swift, мы рекомендуем вам сначала посмотреть некоторые другие наши руководства по языку Swift. Введение в дженерики Возможно, вы этого не знаете, но вы, вероятно, уже видели работу дженериков в Swift. Массивы и словари являются классическими примерами общей безопасности в действии.
Разработчики Objective-C привыкли к массивам и словарям, содержащим объекты разных типов в одной коллекции.
Это обеспечивает большую гибкость, но знаете ли вы, что массив, возвращаемый API, предназначен для хранения? Вы можете убедиться, просмотрев документацию или имена переменных, другую форму документации.
Даже при наличии документации не существует способа (кроме кода без ошибок!) предотвратить сбои сбора данных во время выполнения.
В Swift же есть массивы и словари.
Массив Ints может содержать только целые числа и никогда (например) не может содержать строки.
Это означает, что вы можете зарегистрировать код, написав код, который позволит компилятору выполнять проверку типов за вас.
Например, в Objective-C UIKit метод, обрабатывающий сенсорное управление на основе представления пользователя, выглядит следующим образом:
Известно, что набор в этом методе содержит только экземпляры UITouch, но только потому, что так указано в документации.- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
Но ничто не мешает объектам быть чем-то другим, и вам обычно приходится оценивать касание в наборе как экземпляры UITouch, чтобы эффективно обрабатывать их как объекты UITouch. В настоящее время Swift не имеет набора, определенного в стандартной библиотеке.
Однако, если бы вы использовали массив вместо набора, вы могли бы написать приведенный выше метод следующим образом: func touchesBegan(touches: [UITouch]!, withEvent event: UIEvent!)
Это указывает на то, что массив touches содержит только экземпляры UITouch, и компилятор выдаст ошибку, если код, вызывающий этот метод, попытается передать что-нибудь еще.
Типы не только устанавливаются/определяются компилятором, но вам больше не нужно оценивать элементы экземпляров UITouch! Как правило, дженерики предоставляют типы в качестве параметра класса.
Все массивы действуют одинаково, сохраняя значения в виде списка, но общие массивы параметризуют тип значения.
Вы могли бы найти это полезным, если бы это было так: Алгоритмы, которые вы будете использовать с массивами, не зависят от типа, поэтому все массивы со всеми типами значений могут использовать их совместно.
Теперь, когда у вас есть базовые знания о дженериках и их преимуществах, вы можете легко применить их к конкретному сценарию.
Как работают дженерики Чтобы проверить наличие дженериков, необходимо создать приложение, которое ищет изображения на Flickr. Начните с загрузки стартовый проект для этого урока.
Откройте его и быстро ознакомьтесь с основными классами.
Класс Flickr может обрабатывать API Flickr. Обратите внимание, что ключ для API, который есть в этом классе, предоставляется сразу, но вы можете использовать свой, если хотите расширить приложение.
Вы можете подписаться на один из них Здесь .
Скомпилируйте и запустите приложение, и вы увидите это:
Хотя не совсем так! Не бойтесь, скоро появятся картинки с котами.
Заказанные словари Ваше приложение будет загружать изображения для каждого поиска пользователя, а самый последний поиск будет отображаться в виде списка в верхней части экрана.
Но что, если ваш пользователь будет искать один и тот же элемент дважды? Было бы неплохо, если бы приложение переместило старые результаты в начало нового списка и заменило их новым результатом.
Вы можете использовать массив в качестве структуры данных для возврата результата, но для изучения дженериков вам необходимо создать новую коллекцию: упорядоченный словарь.
Во многих языках программирования и фреймворках (включая Swift) множества и словари не гарантируют какого-либо порядка, в отличие от массивов.
Упорядоченный словарь выглядит как обычный словарь, но содержит ключи в определенном порядке.
Вы будете использовать эту функцию для хранения результатов поиска, что позволит вам быстро находить результаты, а также поддерживать порядок в таблице.
Если вы были невнимательны, вы можете создать структуру пользовательских данных для обработки упорядоченного словаря.
Но вы дальновидны! Вы хотите создать что-то, что можно будет использовать в приложениях долгие годы! Дженерики – идеальный вариант. Первичная структура данных Добавьте новый файл, выбрав File\New\File. а затем iOS\Source\Swift File. Нажмите «Далее» и назовите файл OrderedDictionary. Наконец, нажмите «Создать».
В результате у вас будет пустой Swift-файл, и вам нужно будет добавить следующий код: struct OrderedDictionary {
}
Пока это неудивительно.
Объект станет структурой, поскольку он должен иметь семантику значений.
Примечание : Короче говоря, семантика значений — это причудливый способ сказать «копировать/вставить», а не «публичная ссылка».
Семантика значений имеет множество преимуществ, например, отсутствие необходимости беспокоиться о другом фрагменте кода, который может неожиданно изменить ваши данные.
Чтобы узнать больше, перейдите к Главе 3: Как понять Swift с помощью уроков», Классы и структуры» .
Теперь вам нужно сделать его универсальным, чтобы он мог содержать значения любого типа.
Измените определение структуры на следующее: struct OrderedDictionary<KeyType, ValueType>
?Элементы в угловых скобках являются параметрами общего типа.
KeyType и ValueType сами по себе не являются типами, а становятся параметрами, которые можно использовать вместо типов в определении структуры.
Если вы не понимаете, то скоро все станет ясно.
Самый простой способ реализовать упорядоченный словарь — поддерживать как массивы, так и словари.
В словаре будет храниться преобразование данных, а в массиве — ключи.
Добавьте следующий код в определение структуры: typealias ArrayType = [KeyType]
typealias DictionaryType = [KeyType: ValueType]
var array = ArrayType()
var dictionary = DictionaryType()
Это указывает на два описанных свойства, а также на два псевдонима типа, которые дают новое имя существующему типу.
Здесь вы соответственно присваиваете псевдонимы массивам и типам словарей для резервных массивов и словарей.
Псевдонимы типов — отличный способ взять сложный тип и дать ему гораздо более короткое имя.
Обратите внимание, что вместо типов можно использовать параметры типа KeyType и ValueType из определения структуры.
Массив представляет собой массив KeyTypes. Конечно, такого понятия, как KeyType, не существует; вместо этого Swift рассматривает его как любой пользовательский тип упорядоченного словаря во время создания экземпляра универсального типа.
На этом этапе вы заметите ошибку компилятора: Тип «Keytype» не соответствует протоколу «Hashable».
Это может быть для вас сюрпризом.
Взгляните на реализацию Словарь : struct Dictionary<KeyType: Hashable, ValueType>
Это очень похоже на определения OrderedDictionary, за исключением одного — «:Hashable» после KeyType. Hashable после точки с запятой указывает, что тип, переданный для KeyType, должен соответствовать протоколу Hashable. Это связано с тем, что для реализации словарь должен иметь возможность хешировать ключи.
Такое ограничение параметров универсального типа стало очень распространенным.
Например, вы можете ограничить тип значения, чтобы он соответствовал протоколам Equatable или Printable, в зависимости от того, что вашему приложению нужно делать с этими значениями.
Откройте OrderedDictionary.swift и замените определение структуры следующим: struct OrderedDictionary<KeyType: Hashable, ValueType>
Это показывает, что KeyType для OrderedDictionary должен соответствовать Hashable. Это означает, что независимо от того, какого типа становится KeyType, он все равно будет приемлем в качестве ключа для основного словаря.
Теперь файл скомпилируется без ошибок! Ключи, значения и все такое прочее
Какая польза от словаря, если в него нельзя добавить смысл? Откройте OrderedDictionary.swift и добавьте в определение структуры следующую функцию: // 1
mutating func insert(value: ValueType, forKey key: KeyType, atIndex index: Int) -> ValueType?
{
var adjustedIndex = index
// 2
let existingValue = self.dictionary[key]
if existingValue != nil {
// 3
let existingIndex = find(self.array, key)!
// 4
if existingIndex < index {
adjustedIndex--
}
self.array.removeAtIndex(existingIndex)
}
// 5
self.array.insert(key, atIndex:adjustedIndex)
self.dictionary[key] = value
// 6
return existingValue
}
Все это познакомит вас с новой информацией.
Давайте рассмотрим их шаг за шагом:
- Метод, который помогает вставить новый объект, Insert(_:forKey:atIndex), должен иметь три параметра: значение для определенного ключа и индекс, по которому вставляется пара ключ-значение.
Здесь есть ключевое слово, которое вы, возможно, раньше не видели: мутация.
- Вы передаете ключ индексатору словаря, который возвращает существующее значение, если оно уже существует для этого ключа.
Этот метод вставки имитирует то же поведение, что и updateValue словаря, и поэтому сохраняет существующее значение ключа.
- Если существует существующее значение, то только с помощью метода находится индекс в массиве для этого ключа.
- Если существующий ключ находится перед индексом вставки, вам необходимо настроить индекс вставки, поскольку вам нужно будет удалить существующий ключ.
- При необходимости вам потребуется обновить массивы и словари.
- Наконец, вы возвращаете существующее значение.
Потому что существующего значения быть не может, так как функция возвращает дополнительное значение.
// 1
mutating func removeAtIndex(index: Int) -> (KeyType, ValueType)
{
// 2
precondition(index < self.array.count, "Index out-of-bounds")
// 3
let key = self.array.removeAtIndex(index)
// 4
let value = self.dictionary.removeValueForKey(key)!
// 5
return (key, value)
}
Давайте еще раз посмотрим на код шаг за шагом:
- Это функция, которая изменяет внутреннее состояние структуры, и поэтому вы воспринимаете ее таковой.
Имя RemoveAtIndex соответствует методу в массиве.
При необходимости рекомендуется рассмотреть возможность зеркалирования API в системной библиотеке.
Это помогает разработчикам, использующим ваш API, чувствовать себя свободно на платформе.
- Во-первых, вы можете проверить индекс, чтобы увидеть, находится ли он в массиве.
Попытка удалить элемент за пределы диапазона из базового массива приведет к ошибке времени выполнения, поэтому проверка обнаружит все это немного раньше.
Возможно, вы использовали утверждения в Objective-C с функцией утверждения; Assert также доступен в Swift, но в настоящее время в производственных сборках используется предварительное кодирование, поэтому ваши приложения могут выйти из строя, если предварительные условия не будут выполнены.
- Затем вы получите ключ из массива по заданному индексу, удалив значение из массива.
- Затем вы удалите значение этого ключа из словаря, который также вернет значение, которое присутствовало ранее.
Поскольку словарь может не содержать значения для данного ключа, метод RemoveValueForKey вернет дополнительные данные.
В этом случае вы знаете, что словарь будет содержать значение для данного ключа, поскольку единственный метод, который может добавить в словарь, — это ваш собственный метод Insert(_:_:forKey:atIndex:), который вы написали.
Таким образом, вы можете сразу раскрыть дополнительный материал, зная, что он будет иметь ценность.
- Наконец, вы возвращаете ключ и значение в кортеж.
Это соответствует поведению массива RemoveAtIndex и словаря RemoveValueForKey, которые возвращают существующие значения.
Откройте OrderedDictionary.swift и добавьте следующий код в структуру определения, а под объявлениями массива и переменных укажите словарь: var count: Int {
return self.array.count
}
Это вычисляемое свойство объема упорядоченных словарных данных, обычно необходимых для такой структуры данных.
Количество в массиве всегда будет соответствовать количеству заказанного словаря, таким образом все будет просто Далее вам необходимо получить доступ к элементам словаря.
В Swift вы получаете доступ к словарю, используя синтаксис индекса, например: let dictionary = [1: "one", 2: "two"]
let one = dictionary[1] // Subscript
К этому моменту вы уже знакомы с синтаксисом, но, вероятно, видели его использование только для массивов и словарей.
Как бы вы использовали свои собственные классы и структуры? К счастью, Swift позволяет легко добавлять индексное поведение в пользовательские классы.
Добавьте следующий код в конец определения структуры: // 1
subscript(key: KeyType) -> ValueType? {
// 2(a)
get {
// 3
return self.dictionary[key]
}
// 2(b)
set {
// 4
if let index = find(self.array, key) {
} else {
self.array.append(key)
}
// 5
self.dictionary[key] = newValue
}
Вот что делает этот код:
- Это работает так же, как добавление поведения индекса, но вместо func или var используется ключевое слово index. Параметр, в данном случае ключ, указывает объект, который отображается в квадратных скобках.
- Индексы могут содержать методы доступа, как и вычисляемые свойства.
Обратите внимание, что методы (a) get и (b) set соответственно определяют методы доступа.
- Метод get прост: вам нужно запросить в словаре значение данного ключа.
Индекс словаря уже возвращает дополнительные данные, которые позволяют указать, что для этого ключа не существует значения.
- Метод set более сложен.
Сначала он проверяет, существует ли уже ключ в упорядоченном словаре.
Если его не существует, то необходимо добавить его в массив.
Имеет смысл, чтобы новый ключ находился в конце массива, поэтому вы добавляете значение в массив с помощью добавления.
- Наконец, вы добавляете новое значение в словарь для данного ключа, передавая новое значение через неявно названную переменную newValue.
Вы можете получить значение определенного ключа, но как насчет доступа к нему с помощью индекса, например, с массивом? Учитывая, как это работает с упорядоченным словарем, было бы неплохо также получить доступ к элементу через индекс.
Классы и структуры могут иметь несколько определений индексов для разных типов аргументов.
Добавьте следующую функцию в конец определения структуры: subscript(index: Int) -> (KeyType, ValueType) {
// 1
get {
// 2
precondition(index < self.array.count,
"Index out-of-bounds")
// 3
let key = self.array[index]
// 4
let value = self.dictionary[key]!
// 5
return (key, value)
}
}
Это похоже на нижний индекс, который вы добавили ранее, за исключением того, что тип параметра теперь Int, поскольку именно его вы используете для ссылки на индекс массива.
Однако на этот раз тип результата представляет собой кортеж «ключ-значение», поскольку данный индекс хранится в вашем OrderedDictionary. Как работает этот код:
- Этот индекс имеет только метод получения.
Вы также можете реализовать для него метод установки, сначала проверив индексы, находящиеся в диапазоне размеров упорядоченного словаря.
- Индекс должен находиться внутри массива, определяющего длину упорядоченного словаря.
Используйте предварительное условие, чтобы предупредить программистов, которые пытаются получить доступ за пределы упорядоченного словаря.
- Ключ можно найти, извлекая его из массива.
- Значение можно найти, получив его из словаря для данного ключа.
Опять же обратите внимание на использование расширенного дополнительного материала, ведь как известно, словарь должен содержать значение для любого ключа, находящегося в массиве.
- Наконец, вы возвращаете кортеж, содержащий ключ и значение.
Добавьте набор с последующим завершением, как в предыдущем определении индекса.
На этом этапе вам может быть интересно, что произойдет, если KeyType будет Int. Преимущество дженериков в том, что в качестве ключа можно ввести любой тип хеша, включая Int. В этом случае, как индекс узнает, какой код индекса использовать? Здесь вам нужно будет предоставить компилятору дополнительную информацию о типе, чтобы он знал, что делать.
Обратите внимание, что каждый из индексов имеет свой тип возвращаемого значения.
Поэтому, если вы попытаетесь указать кортеж значений ключа, компилятор будет знать, что ему следует использовать нижний индекс, как в массиве.
Тестирование системы Давайте запустим программу, чтобы вы могли поэкспериментировать с тем, как ее компилировать, какой индексный метод использовать и как вообще работает ваш OrderedDictionary. Создать новый Детская площадка Нажав «Файл\Новый\Файл…», выбрав iOS\Source\Playground и нажав «Далее».
Назовите его ODPlayground и нажмите «Создать».
Скопируйте и вставьте OrderedDictionary.swift в новую игровую площадку.
Вы должны это сделать, потому что, к сожалению, на момент написания этого руководства платформа не может «видеть» код в вашем модуле приложения.
Примечание: Для этого существует обходной путь, отличный от метода копирования/вставки, который реализуется здесь.
Как отмечает Корин Крич, если вы переместили код своего приложения в фреймворк, ваша игровая площадка сможет получить доступ к вашему коду.
Теперь добавьте следующий код на игровую площадку: var dict = OrderedDictionary<Int, String>()
dict.insert("dog", forKey: 1, atIndex: 0)
dict.insert("cat", forKey: 2, atIndex: 1)
println(dict.array.description
+ " : "
+ dict.dictionary.description)
var byIndex: (Int, String) = dict[0]
println(byIndex)
var byKey: String? = dict[2]
println(byKey)
На боковой панели (или через View\Assistant Editor\Show Assistant Editor) вы можете увидеть выходную переменную println():
В этом примере словарь имеет ключ Int, поскольку компилятор будет смотреть на тип переменной и определять, какой индекс использовать.
Поскольку byIndex представляет собой кортеж (Int, String), компилятор знает, что нужно использовать индексную версию стиля массива индексов, соответствующую ожидаемому типу возвращаемого значения.
Попробуйте удалить определение типа данных из одной переменной byIndex или byKey. Вы увидите ошибку компилятора, указывающую, что компилятор не знает, какой индекс использовать.
Зацепка: Для выполнения вывода типа компилятор требует, чтобы тип выражения был однозначным.
Если существует несколько методов с одинаковыми типами аргументов, но разными типами возвращаемых значений, вызывающая функция должна быть конкретной.
Добавление метода в Swift может привести к критическим изменениям в вашей сборке, поэтому будьте осторожны! Поэкспериментируйте с упорядоченным словарем на игровой площадке, чтобы увидеть, как он работает. Попробуйте добавить к нему, удалить себя из него и изменить типы ключей и значений, прежде чем вернуться в приложение.
Теперь вы можете читать и писать в организованном словаре! Это поможет позаботиться о вашей структуре данных.
Теперь вы можете начать работу с приложением! Добавление поиска изображений Пришло время вернуться к приложению.
Откройте MasterViewController.swift и добавьте следующее определение переменной чуть ниже двух @IBOutlets: var searches = OrderedDictionary<String, [Flickr.Photo]>()
Это должен быть упорядоченный словарь, содержащий результаты поиска, полученные пользователем от Flickr. Как видите, он отображает строку, поисковый запрос, массив Flickr.Photo или фотографии, возвращенные из API Flickr. Обратите внимание, что ключ и значение заключаются в угловые скобки, как и в обычном словаре.
В этой реализации они становятся параметрами KeyType и ValueType. Вы можете задаться вопросом, почему в тексте Flickr.Photo стоит точка.
Это связано с тем, что photo — это класс, определенный внутри класса Flickr. Эта иерархия — довольно полезная функция Swift, помогающая содержать пространства имен, сохраняя при этом короткие имена классов.
Внутри класса Flickr вы можете использовать только Photo, принадлежащий классу Photo, поскольку контекст сообщает компилятору, что это такое.
Затем найдите метод источника данных табличного представления под названием tableView(_:numberOfRowsInSection:) и измените его на следующий код: func tableView(tableView: UITableView,
numberOfRowsInSection section: Int) -> Int
{
return self.searches.count
}
Этот метод теперь использует упорядоченный словарь, который определяет, сколько ячеек имеет наша таблица.
Затем найдите метод источника данных tableView (_:cellForRowAtIndexPath:) и измените его на: func tableView(tableView: UITableView,
cellForRowAtIndexPath indexPath: NSIndexPath)
-> UITableViewCell
{
// 1
let cell =
tableView.dequeueReusableCellWithIdentifier("Cell",
forIndexPath: indexPath) as UITableViewCell
// 2
let (term, photos) = self.searches[indexPath.row]
// 3
if let textLabel = cell.textLabel {
textLabel.text = "\(term) (\(photos.count))"
}
return cell
}
Вот что вы делаете в этом методе:
- Во-первых, исключите ячейку из очереди UITableView и направьте ее непосредственно в UITableViewCell, поскольку dequeueReusableCellWithIdentifier по-прежнему возвращается в AnyObject (идентификатор в Objective-C), а не в UITableViewCell. Возможно, в будущем Apple перепишет свой API, чтобы использовать преимущества дженериков!
- Затем вы получите ключ и значение для данной строки, используя индекс индекса, который вы написали.
- Наконец, вы установите текст в метку ячейки и вернете ячейку.
Найдите расширение UISearchBarDelegate и измените один метод следующим образом: func searchBarSearchButtonClicked(searchBar: UISearchBar!) {
// 1
searchBar.resignFirstResponder()
// 2
let searchTerm = searchBar.text
Flickr.search(searchTerm) {
switch ($0) {
case .
Error: // 3 break case .
Results(let results):
// 4
self.searches.insert(results,
forKey: searchTerm,
atIndex: 0)
// 5
self.tableView.reloadData()
}
}
}
Этот метод вызывается, когда пользователь нажимает кнопку «Поиск».
Вот что делается в этом методе:
- Затем прямо сейчас укажите поисковый запрос в виде текста в строке поиска и используйте класс Flickr для поиска этого термина.
Метод поиска Flickr использует как поисковый запрос, так и замыкание для выполнения успешного или неудачного поиска.
Замыкание принимает только один параметр: список ошибок или результатов.
- В случае ошибки вы не увидите никаких признаков об этом.
Но при желании можно сделать так, чтобы предупреждение появлялось, но зачем усложнять.
Код необходимо остановить, чтобы уведомить компилятор Swift о том, что ошибка не причинит никакого вреда.
- Если поиск работает должным образом, он возвращает результаты в виде сопоставленного значения в типе перечисления SearchResults. Вы можете добавить результаты в начало упорядоченного словаря, используя поисковый запрос в качестве ключа.
Если поисковый запрос уже существует в словаре, он переместится в начало списка и обновит его результаты.
- Наконец, вы сможете перезагрузить таблицу экрана, поскольку теперь у вас есть новые данные.
Вы увидите что-то вроде этого:
Теперь повторите один из поисков, который не находится вверху списка.
И вы увидите, что он вернулся в начало списка:
Нажмите на один из поисковых запросов и обратите внимание, что он не показывает ни одной фотографии.
Пришло время это исправить! Дайте мне фотографии!
Откройте MasterViewController.swift и найдите метод подготовитьForSegue. И замените его на: override func prepareForSegue(segue: UIStoryboardSegue,
sender: AnyObject?)
{
if segue.identifier == "showDetail" {
if let indexPath = self.tableView.indexPathForSelectedRow()
{
let (_, photos) = self.searches[indexPath.row]
(segue.destinationViewController
as DetailViewController).
photos = photos
}
}
}
При этом используется тот же метод поиска для упорядоченного словаря, что и при создании ячеек.
Однако он не использует ключ (поиск по ключевым словам), поэтому вы сами указываете, что эта часть кортежа не должна быть связана с локальной переменной.
Скомпилируйте и запустите приложение, выполните поиск и щелкните по нему.
Вы увидите что-то вроде этого:
Кошки! Неужели вам не хочется мурлыкать вместо них от такого удовольствия? Что дальше?
Поздравляем, вы узнали много нового о дженериках! Кроме того, вы узнали о других интересных вещах, таких как индексирование, структуры, предварительная обработка и многое другое.
Если вы хотите узнать больше о дженериках, вам нужно посмотреть полную главу руководства по Swift. Я надеюсь, что вы сможете использовать возможности обобщений в своих будущих приложениях, чтобы избежать дублирования кода и оптимизировать его для повторного использования.
Если у вас есть какие-либо вопросы или комментарии, присоединяйтесь к обсуждению на форуме! Теги: #Swift #iOS #objective-c #разработка iOS #ios8 #разработка iOS #Swift
-
Обзор Wondershare Dvd Creator
19 Oct, 24 -
Как Запустить Service Desk «Из Коробки»?
19 Oct, 24 -
Идеальная Система Комментариев
19 Oct, 24