What is the best way to save a CGMutablePath as JSON data that can be uploaded to backend?
I'm aware that it could be converted into a Data
object using UIBezierPath
which conforms to NSCoding, then the data could be converted again to a string and saved to backend, but that doesn't seem like a great way. Is there a better way to save this object to backend?
Perhaps you could pull out the huge array of points that make up the path, convert it into a string and save that. Would this be best?
CodePudding user response:
A CGPath
or CGMutablePath
is a very simple data structure. It's an array of path elements. An each path elements is either a move, line, cubicCurve, curve or closeSubpath operation with 0 to 3 points. That's all. There are no additional attributes or variants.
So it's quite straight-forward to translate a path into an array of path element (struct PathElement
) and then encode it as JSON. It results in a JSON that can be easily read with any programming language and works on many graphics systems (incl. iOS/macOS Quartz, Postscript, PDF, Windows GDI ).
The output of the below sample code consists of the printed CGMutablePath
, the generated JSON and the path restored from JSON:
Path 0x10100d960:
moveto (10, 10)
lineto (30, 30)
quadto (100, 100) (200, 200)
curveto (150, 120) (100, 350) (20, 400)
closepath
moveto (200, 200)
lineto (230, 230)
lineto (260, 210)
closepath
[
{
"type" : 0,
"points" : [
[
10,
10
]
]
},
{
"type" : 1,
"points" : [
[
30,
30
]
]
},
{
"type" : 2,
"points" : [
[
100,
100
],
[
200,
200
]
]
},
{
"type" : 3,
"points" : [
[
150,
120
],
[
100,
350
],
[
20,
400
]
]
},
{
"type" : 4
},
{
"type" : 0,
"points" : [
[
200,
200
]
]
},
{
"type" : 1,
"points" : [
[
230,
230
]
]
},
{
"type" : 1,
"points" : [
[
260,
210
]
]
},
{
"type" : 4
}
]
Path 0x10100bd20:
moveto (10, 10)
lineto (30, 30)
quadto (100, 100) (200, 200)
curveto (150, 120) (100, 350) (20, 400)
closepath
moveto (200, 200)
lineto (230, 230)
lineto (260, 210)
closepath
Sample code:
import Foundation
var path = CGMutablePath()
path.move(to: CGPoint(x: 10, y: 10))
path.addLine(to: CGPoint(x: 30, y: 30))
path.addQuadCurve(to: CGPoint(x: 200, y: 200), control: CGPoint(x: 100, y: 100))
path.addCurve(to: CGPoint(x: 20, y: 400), control1: CGPoint(x: 150, y: 120), control2: CGPoint(x: 100, y: 350))
path.closeSubpath()
path.move(to: CGPoint(x: 200, y: 200))
path.addLine(to: CGPoint(x: 230, y: 230))
path.addLine(to: CGPoint(x: 260, y: 210))
path.closeSubpath()
print(path)
let jsonData = encode(path: path)
let jsonString = String(data: jsonData, encoding: .utf8)!
print(jsonString)
print("")
let restoredPath = try! decode(data: jsonData)
print(restoredPath)
func encode(path: CGPath) -> Data {
var elements = [PathElement]()
path.applyWithBlock() { elem in
let elementType = elem.pointee.type
let n = numPoints(forType: elementType)
var points: Array<CGPoint>?
if n > 0 {
points = Array(UnsafeBufferPointer(start: elem.pointee.points, count: n))
}
elements.append(PathElement(type: Int(elementType.rawValue), points: points))
}
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return try encoder.encode(elements)
} catch {
return Data()
}
}
func decode(data: Data) throws -> CGPath {
let decoder = JSONDecoder()
let elements = try decoder.decode([PathElement].self, from: data)
let path = CGMutablePath()
for elem in elements {
switch elem.type {
case 0:
path.move(to: elem.points![0])
case 1:
path.addLine(to: elem.points![0])
case 2:
path.addQuadCurve(to: elem.points![1], control: elem.points![0])
case 3:
path.addCurve(to: elem.points![2], control1: elem.points![0], control2: elem.points![1])
case 4:
path.closeSubpath()
default:
break
}
}
return path
}
func numPoints(forType type: CGPathElementType) -> Int
{
var n = 0
switch type {
case .moveToPoint:
n = 1
case .addLineToPoint:
n = 1
case .addQuadCurveToPoint:
n = 2
case .addCurveToPoint:
n = 3
case .closeSubpath:
n = 0
default:
n = 0
}
return n
}
struct PathElement: Codable {
var type: Int
var points: Array<CGPoint>?
}
CodePudding user response:
"Best" is entirely subjective, and depends entirely on your needs and priorities. Is human-readability important? Is data size important? Compatibility with existing APIs somewhere? Those are questions that you need answers to in order to determine what's right for your use-case.
If human-readability of the data is important, then JSON is a clear winner over NSCoding
. If size is important, then compressed JSON is usually smaller than NSKeyedArchiver
data, but can also be much larger when decompressed.
A small (non-production-ready) test harness for generating some random paths and producing data from them:
import Foundation
import GameplayKit
import UIKit
extension GKRandom {
func nextCGFloat(upperBound: CGFloat) -> CGFloat {
CGFloat(nextUniform()) * upperBound
}
func nextCGPoint(scale: CGFloat = 100) -> CGPoint {
CGPoint(x: nextCGFloat(upperBound: scale),
y: nextCGFloat(upperBound: scale))
}
func nextCGRect(widthScale: CGFloat = 100, heightScale: CGFloat = 100) -> CGRect {
CGRect(origin: nextCGPoint(),
size: CGSize(width: 4 nextCGFloat(upperBound: widthScale - 4),
height: 4 nextCGFloat(upperBound: heightScale - 4)))
}
func nextCGPath() -> CGMutablePath {
let path = CGMutablePath()
path.move(to: nextCGPoint())
let minPathElements = 3
for _ in minPathElements ..< minPathElements nextInt(upperBound: 10) {
switch nextInt(upperBound: 6) {
case 0:
path.addArc(center: nextCGPoint(),
radius: nextCGFloat(upperBound: 20),
startAngle: nextCGFloat(upperBound: CGFloat(2 * Double.pi)),
endAngle: nextCGFloat(upperBound: CGFloat(2 * Double.pi)),
clockwise: nextBool())
case 1:
path.addCurve(to: nextCGPoint(),
control1: nextCGPoint(),
control2: nextCGPoint())
case 2:
path.addEllipse(in: nextCGRect())
case 3:
path.addLine(to: nextCGPoint())
case 4:
path.addQuadCurve(to: nextCGPoint(),
control: nextCGPoint())
case 5:
path.addRect(nextCGRect())
default:
continue
}
}
path.closeSubpath()
return path
}
}
func encodePathWithArchiver(_ path: CGMutablePath) -> Data {
let bezierPath = UIBezierPath(cgPath: path)
return try! NSKeyedArchiver.archivedData(withRootObject: bezierPath, requiringSecureCoding: true)
}
extension CGPathElement: Encodable {
private enum CodingKeys: CodingKey {
case type
case points
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(type.rawValue, forKey: .type)
let pointCount: Int
switch type {
case .moveToPoint, .addLineToPoint, .addQuadCurveToPoint:
pointCount = 2
case .addCurveToPoint:
pointCount = 3
case .closeSubpath: fallthrough
@unknown default:
pointCount = 0
}
try container.encode(Array(UnsafeBufferPointer(start: points, count: pointCount)), forKey: .points)
}
}
func encodePathAsJSON(_ path: CGMutablePath) -> Data {
var elements = [CGPathElement]()
path.applyWithBlock { element in
elements.append(element.pointee)
}
return try! JSONEncoder().encode(elements)
}
let randomSource = GKMersenneTwisterRandomSource(seed: 0 /* some reproducible seed here, or omit for a random run each time */)
let path = randomSource.nextCGPath()
let keyedArchiverData = encodePathWithArchiver(path)
let jsonData = encodePathAsJSON(path)
print(keyedArchiverData.count, "<=>", jsonData.count)
let compressedArchiverData = try! (keyedArchiverData as NSData).compressed(using: .lzma) as Data
let compressedJSONData = try! (jsonData as NSData).compressed(using: .lzma) as Data
print(compressedArchiverData.count, "<=>", compressedJSONData.count)
The above code uses GameplayKit for reproducible randomness using a seed: you can play around with this to see various results. For example, a seed of 0
produces JSON data that is smaller than NSKeyedArchiver
data both when compressed and when uncompressed, but a seed of 14283523348572255252
produces JSON data >2× larger than NSKeyedArchiver
data until compressed.
The takeaway here will largely depend on your specific use-case, and what your priorities for data storage are.
Note: it's easy to look at small numbers here and try to draw conclusions about what's "best", but keep in mind the scale here: unless your paths are many thousands of points long, the effective difference between these methods results in negligible size differences. Whether you put the effort into maintaining an encoding interface for CGPath
references or whether you go with the one-liner conversion to UIBezierPath
may make more of a difference to you than any size savings.