Реверс инжиниринг сериализации SwitUI NavigationPath
От меня:
Материалу уже пара месяцев, но тема не теряет актуальности.
В iOS 16 был представлен совершенно новый набор инструментов для навигации внутри приложения с прицелом на модель стековой навигации с простым API, основанном на коллекциях. Одним из представленных инструметов является NavigationPath, который представляет собой коллекцию данных со стертым типом (type-erased data) и позволяет осуществлять навигацию в приложении избегая связывания между несвязанных представлений вместе.
Как выяснилось, NavigationPath способен кодировать и декодировать себя в JSON несмотря на стертую информацию о типе. Это очень мощная фича, позволяющая сохранять и восстанавливаться состояние навигации, но как она работает?
NavigationPath codability
NavigationPath - это спискообразный тип с данными, информация о типе которых стерта. В публичном API он имеет несколько простых методов. Вы можете создать инстанс NavigationPath ничего не сообщая о типах данных которые он будет содержать:
var path = NavigationPath()
Вы также можете свободно добавлять любые данные к пути, лишь бы тип этих данных удовлетворял протоколу Hashable
path.append("hello")
path.append(42)
path.append(true)
Вы даже можете добавлять свои собственные типы:
struct User: Hashable {
var id: Int
var name: String
}
path.append(User(id: 42, name: "Blob"))
И хотя NavigationPath предоставляет методы, вроде тех что есть у коллекций, типа append, remove и count, он не позволяет нам итерироваться по добавленным элементам. Это может показаться упущением на данный момент (мы оставлили фидбек), но даже если элементы сделать доступными, мы всё равно получили бы их в виде any Hashable значений. Всё потому что при добавлении они теряют всю информацию о своем типе, кроме той что они являются Hashable значениями.
И несмотря на всё это, NavigationPath сохраняет за собой магическую способность сериализоваться в JSON и обратно оставляя данные и статические типы нетронутыми!
Это становится возможным благодаря доступу к свойству codable, которое возвращает опциональное значение.
path.codable // nil
После добавления User к нашему пути, свойсво codable возвращает nil так как тип User не удовлетворяет протоколу Codable. В логах при этом можно увидеть что-то вроде такого предупреждения:
Cannot create CodableRepresentation of navigation path, because presented value of type "User" is not Codable.
NavigationPath требует, чтобы всё что добавляется к пути удовлетворяло обоим протоколам Hashable и Codable. Давайте сделаем нашу User структуру Codable:
struct User: Codable, Hashable {
...
}
Теперь, свойство codable у NavigationPath возвращает существующее значение именуемое CodableRepresentation:
path.codable // NavigationPath.CodableRepresentation
И теперь, мы можем скормить это поле энкодеру, чтобы получить JSON представление:
try JSONEncoder().encode(path.codable!) // 120 bytes
Давайте попробуем представить эти данные в виде строки:
print(
String(
decoding: try JSONEncoder().encode(path.codable!),
as: UTF8.self
)
)
[
"User",
"{\"id\":42,\"name\":\"Blob\"}",
"Swift.Bool",
"true",
"Swift.Int",
"42",
"Swift.String",
"\"hello\""
]
Каждый фрагмент данных, который мы добавили в путь, был сериализован в плоский массив, содержащий как строковое представление имени типа, так и его строковое представление JSON. Например, наше пользовательское значение было сериализовано в пару элементов массива для имени типа и JSON идентификатора и имени:
"User","{"id":42,"name":"Blob"}"
Интересно, что, хотя NavigationPath не имеет информации о типах элементов, которые он содержит, он все равно может каким-то образом определить, когда элемент соответствует Encodable, и закодировать его.
Еще интереснее, он может сделать обратное!
Он может каким-то образом взять текст и превратить его обратно в честные статически типизированные значения, такие как Strings, Int, Bool и даже структуры, созданные разработчиком. Это кажется волшебством.
Чтобы воочию это увидеть, мы можем взять строку сериализованных в JSON данных и превратить ее обратно в навигационный путь:
let decodedPath = try NavigationPath(
JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: Data(
#"""
[
"User","{\"id\":42,\"name\":\"Blob\"}",
"Swift.Bool","true",
"Swift.Int","123",
"Swift.String","\"Hello\""
]
"""#.utf8
)
)
)
Выглядит как что-то невероятное, но это возможно. Мы можем взять этот свежесозданный NavigationPath, вклеить его в NavigationStack и затем с помощью статически типизированных значений, которые мы передадим в navigationDestination сконструируем представления для каждого из навигационных путей:
List {
...
}
.navigationDestination(for: String.self) { string in
Text("String view: \(string)")
}
.navigationDestination(for: Int.self) { int in
Text("Int view: \(int)")
}
.navigationDestination(for: Bool.self) { bool in
Text("Bool view: \(String(describing: bool))")
}
.navigationDestination(for: User.self) { user in
Text("User view: \(String(describing: user))")
}
Кодирование и декодирование Any
Можно ли самостоятельно воссоздать эту, казалось бы, волшебную функциональность? Можем ли мы действительно взять строчный JSON и превратить его в значения со статическими типами? Собственно, - да, используя немного магии времени выполнения и новые экзистенциальные суперспособности Swift.
Давайте начнем с простой обертки вокруг массива полностью стертых по типу значений Any, а также метода добавления Any в конец массива:
struct NavPath {
var elements: [Any] = []
mutating func append(_ newElement: Any) {
self.elements.append(newElement)
}
}
Что нам потребуется для реализации соответствия Encodable для этого типа, чтобы он кодировался как плоский массив строк, которые чередуются между описанием типа и кодировкой JSON значения:
extension NavPath: Encodable {
func encode(to encoder: Encoder) throws {
// ???
}
}
Мы можем начать с unkeyed контейнера, поскольку хотим кодировать значения в массив:
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
}
Далее, мы можем обойти все элементы пути, но в обратном порядке (по непонятной причине NavigationPath кодирует элементы в обратном порядке):
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
for element in elements.reversed() {
...
}
}
Для каждого элемента массива, нам необходимо кодировать имя типа и само значение в строковое JSON представление.
Мы можем использовать Swift функции начинающиеся с символа подчеркивания, способные превращать тип в строку. И хотя element это полностью стертое Any значение, мы можем получить его runtime тип используя type(of:) функцию, и затем кодировать его строчное имя:
try container.encode(_mangledTypeName(type(of: element)))
Затем мы хотим попробовать закодировать элемент в строку JSON. Сначала нам нужно проверить, является ли элемент энкодируемым для начала, что мы можем легко сделать благодаря новым мощным экзистенциальным функциям типа Swift:
guard let element = element as? any Encodable
else {
throw EncodingError.invalidValue(
element, .init(
codingPath: container.codingPath,
debugDescription: "\(type(of: element)) is not encodable."
)
)
}
Если мы guard пропустит выполнение дальше, мы можем закодировать элемент в строку JSON, а затем закодировать его в наш контейнер:
try container.encode(
String(decoding: JSONEncoder().encode(element), as: UTF8.self)
)
Этой строкой мы завершаем приведение нашего NavPath к поведению оригинального NavigationPath:
var path = NavPath()
path.append("Hello")
path.append(42)
path.append(true)
path.append(User(id: 42, name: "Blob"))
let data = try JSONEncoder().encode(path)
print(String(decoding: data, as: UTF8.self))
[
"11nav_codable4UserV",
"{\"id\":42,\"name\":\"Blob\"}",
"Sb",
"true",
"Si",
"42",
"SS",
"\"Hello\""
]
Мы можем кодировать все значения, даже если мы храним их как полностью стертые типы. Любые значения внутри.
И если мы попробуем добавить что-то, что не может быть закодировано, например, Void:
path.append(())
Мы получим ошибку, которая дает достаточно информации о том, что пошло не так:
invalidValue((), Context(codingPath: [], debugDescription: "() is not encodable.", underlyingError: nil))
Мы на половине пути от нашей цели обратного инжиниринга NavigationPath. Далее, нам нужно привести наш NavPath к соответствию Decodable протоколу:
extension NavPath: Decodable {
init(from decoder: Decoder) throws {
// ???
}
}
И снова воспользуемся unkeyed контейнером, поскольку пытаемся декодировать плоский массив строк:
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.elements = []
}
Давайте обойдем контейнер и будем обрабатывать элементы по очереди:
while !container.isAtEnd {
...
}
Вместо троеточий, мы можем попытаться декодировать строку из контейнера, которая как мы ожидаем, является именем типа для значения, следующего за ней:
let typeName = try container.decode(String.self)
Аналогично функции Swift (начинающейся с символа подчеривания), которая получает строку от типа, есть функция действующая в обратном направлении сопоставляя тип по имени:
let typeName = try container.decode(String.self)
guard let type = _typeByName(typeName)
else {
...
}
Однако нам не нужен просто какой-то там тип. Нам нужен тип соответствующий Decodable, и если это не так, мы выбросим ошибку декодирования. Сделать это нам помогут мощные возможности экзистенциальных типов и кастинг Any.Type возвращенного нам из _typeByName в any Decodable.Type:
let typeName = try container.decode(String.self)
guard let type = _typeByName(typeName) as? any Decodable.Type
else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "\(typeName) is not decodable."
)
}
Если мы пройдем эти проверки, это означает, что тип декодируем, поэтому мы можем получить следующую строку в контейнере, попытаться расшифровать ее, а затем вставить ее в начало массива элементов, так как кодирование исходного массива было в обратном порядке:
let encodedValue = try container.decode(String.self)
let value = try JSONDecoder().decode(type, from: Data(encodedValue.utf8))
self.elements.insert(value, at: 0)
На этом мы завершаем второй этап обратного инжиниринга NavigationPath. Теперь мы можем декодировать JSON данные назад в NavPath:
let decodedPath = JSONDecoder().decode(
NavPath.self,
from: Data(
#"""
[
"11nav_codable4UserV", "{\"id\":42,\"name\":\"Blob\"}",
"Sb", "true",
"Si", "42",
"SS", "\"Hello\""
]
"""#.utf8
)
)
NavPath(elements: [1, "Hello", true, User(id: 42, name: "Blob")])
Давайте проверим что типы в списке элементов кастятся к своим типам без ошибок:
decodedPath.elements[0] as! Int // 1
decodedPath.elements[1] as! String // "Hello"
decodedPath.elements[2] as! Bool // true
decodedPath.elements[3] as! User // User(id: 42, name: "Blob")
Мы смогли создать массив любых значений, которые все еще тайно сохраняют свои статические типы, и мы приводим их из строк, которые содержатся в однородном массиве типов и значений.