Codespree by Alexo

Реверс инжиниринг сериализации 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")

Мы смогли создать массив любых значений, которые все еще тайно сохраняют свои статические типы, и мы приводим их из строк, которые содержатся в однородном массиве типов и значений.

Теги: