在实际 iOS 与 Unity 混合工程里,所谓“iOS-Unity 通用绘制工具”,通常并不是一个能自动把世界坐标换算成屏幕坐标的“黑盒”,而是一套从绘制数据协议、Unity 侧指令封装、iOS 原生渲染到回写链路一起协同的方案。之所以需要单独抽一套通用层,是因为大多数项目在 UI 动态画线、涂鸦标记、轨迹预览这些场景里,都会遇到同一个问题:Unity 侧已经采集到了点序列,iOS 原生侧还需要再画一份,两端如果各自写一套坐标换算和样式解析,等到联调时就会出现线条偏移、触摸事件不同步、横竖屏旋转后错位等问题。这篇文章会围绕“动态画线”这个最小场景,介绍一套可落地的通用绘制工具结构。你会看到如何定义两端都认的 JSON 绘制指令,如何在 Unity 侧生成噪声曲线、折线和涂鸦数据,如何在 iOS 侧用 CAShapeLayer 渲染这些指令,并通过桥接方法把原生触摸事件回传给 Unity。工程中不涉及平台破解、抓包或其他灰产场景,只讨论正规游戏、渲染工具和原生辅助功能开发。
1. 先确定主线:这是一套指令化绘制桥接,不是大而全的画板 SDK
1.1 这种工具通常会出现在哪些业务场景里
游戏内玩法或辅助编辑器常见的绘制需求,大致可以分成三类:
- 玩家用触摸笔迹做涂鸦、签名、关卡内标记。
- Unity 把业务生成的路径、连击轨迹、噪声波形显示在场景层或 UI 层。
- iOS 原生层需要在 Unity 渲染结果之上叠加一层批注,例如截图标注、AR 引导线、回放控制工具栏。
第 1 种和第 2 种在纯 Unity 工程中可以用 LineRenderer 或 UGUI 动态网格完成。真正麻烦的是第 3 种。Unity 的渲染结果和 iOS 原生视图属于两个不同的渲染体系:Unity 使用 OpenGL ES 或 Metal 渲染到自己的屏幕区域,iOS 原生视图则基于 UIKit、Core Animation。如果业务要求原生侧动态画线而 Unity 侧又要同步同一份数据,最稳妥的做法不是截屏下发,而是把绘制动作本身抽象成指令,让两端执行同一份指令集。
1.2 通用绘制工具应该只管理绘制,不应该直接管理游戏对象
做这类工具时容易犯的最大错误,是试图让一条线条直接对应一个 GameObject 或一个 UIView,然后用很重的脚本去控制生命周期。真正的通用绘制工具应当更薄:
- 接收一个绘制动作集合。
- 将动作转换成统一数据格式。
- 把数据提交给对应渲染端。
- 渲染端只负责画,不负责业务逻辑。
这里还有一个关键取舍:数据协议才是核心,渲染实现可以替换。Unity 编辑器里可以用 UGUI 顶点临时预览,iOS 原生里可以用 CAShapeLayer 渲染,未来如果切换到 Metal 自绘,也只需要替换最底层的render(payload:)方法,不需要改动上层业务代码。
2. 第一层基础设施:让 iOS 和 Unity 都理解同一份绘制指令
2.1 先用一个可以扩展的 JSON envelope 承载整批操作
绘制数据不能只包含单个线条的点数组。实际场景里还包含画布 ID、画布宽高、坐标空间、绘制颜色、线宽、透明度、是否需要清除画布、是否允许原生接收触摸事件等附加信息。建议先定义一个 envelope 结构用来包住整批操作。
{ "version": 1, "canvasId": "annotation_canvas", "coordinateSpace": "uikit_points", "viewport": { "width": 390, "height": 844, "scale": 3.0 }, "actions": [ { "op": "stroke", "pathId": "noise_001", "style": { "strokeColor": "#2277FF", "strokeWidth": 4, "opacity": 0.9, "lineCap": "round", "lineJoin": "round", "dashPattern": [] }, "points": [ { "x": 20, "y": 500 }, { "x": 60, "y": 470 }, { "x": 100, "y": 420 } ] }, { "op": "clear" } ] }这段 JSON 是两端共同约定的协议。version用来做向后兼容,canvasId用来区分多个画布,coordinateSpace告诉两端坐标原点在哪里,viewport则保留了画布逻辑尺寸,方便 iOS 原生侧在屏幕旋转后做映射。actions数组用于绘画动作,每个动作都是一条独立可执行指令。
2.2 动作类型要按“可回放、可增量追加”设计
绘制工具的动作类型不需要一开始就很多,但每种动作的语义必须明确,不能一个op里既画线又处理手势。建议从最小集开始:
| op 类型 | 用途 | 必需字段 | 说明 |
|---|---|---|---|
stroke | 绘制一条折线或曲线 | pathId,style,points | 一条路径只对应一个 CAShapeLayer |
clear | 清空画布 | 无 | 可以搭配exceptPathIds实现部分清除 |
layerVisible | 隐藏或显示某一批路径 | pathId,visible | 用于标记线的擦除和恢复 |
enableTouch | 开启或关闭原生触摸采集 | enabled | 避免原生层误接管手势 |
touch | iOS 原生把触摸点同步给 Unity | phase,point,pointerId | 这是反向指令 |
在真实工程中,stroke不必高频发送整条路径。更合适的策略是 Unity 先在业务层累积点数据,用手势结束时再把整条路径发给 iOS。如果确实需要实时预览,也可以按帧发送增量点,但增量点仍要归属于同一个pathId。这种设计让回放、回滚、撤销都变得简单,因为每个操作都是可以重放的纯指令。
2.3 坐标系统一是两端联调的第一道门槛
iOS 的 UIKit 坐标以左上角为原点,x 向右增大,y 向下增大。Unity UI 的默认坐标系在某些设置下 y 轴方向并不一致;如果直接使用 Unity 的屏幕像素坐标,很容易出现上下颠倒。在实际项目中,更推荐使用“归一化坐标”或“逻辑点坐标”。
协调方式可以这样定:
- iOS 全部使用 UIKit point,不写 pixel。
- Unity 向 iOS 发送点之前,先把世界坐标或 UI 坐标换算成 iOS 画布坐标。
- 在 iOS 侧渲染时,如果画布尺寸和 JSON 里记录的
viewport不一致,使用比例换算而不是直接 draw at point。
float uiKitX = uiX; float uiKitY = screenHeightPoints - uiY;这段换算说明的是 Unity 坐标转 iOS 坐标时的典型处理思路。如果两端都使用归一化坐标,就是下面这个更稳的公式:
normalizedX = pointX / viewportWidth normalizedY = pointY / viewportHeight收到 JSON 的端再用自己的实际画布宽高乘回去,就能应对不同分辨率。
3. Unity 侧把绘制逻辑收敛成一个命令缓冲器
3.1 C# 侧不要到处直接操作 LineRenderer
如果项目的业务层到处调用LineRenderer.SetPosition,后面想加 iOS 原生画布就会非常痛苦。通用绘制工具的第一步,是在 C# 侧做一个命令缓冲器,业务层只负责向缓冲器提交点数据和样式。
public class DrawingCommandBuffer { private List<DrawingStrokeAction> _strokes = new List<DrawingStrokeAction>(); public void BeginStroke(string pathId) { _strokes.Add(new DrawingStrokeAction { PathId = pathId }); } public void AddPoint(float x, float y) { if (_strokes.Count == 0) { return; } var current = _strokes[_strokes.Count - 1]; current.Points.Add(new DrawingPoint { X = x, Y = y }); } public void SetStrokeStyle(Color32 color, float width, float opacity) { if (_strokes.Count == 0) { return; } var current = _strokes[_strokes.Count - 1]; current.StrokeColor = ColorUtility.ToHtmlStringRGBA(color); current.StrokeWidth = width; current.Opacity = opacity; } public void EndStroke() { // 这里可以做点数压缩、抽稀和校验 } public List<DrawingStrokeAction> Flush() { var result = _strokes; _strokes = new List<DrawingStrokeAction>(); return result; } }DrawingStrokeAction和DrawingPoint都是可序列化的 DTO,不依赖 UnityEngine 的JsonUtility数组序列化限制,实际工程可以结合Newtonsoft.Json、System.Text.Json或 Unity 自带序列化器处理。重点在于,业务层在代码里只看到“开始画笔、加一个点、设置样式、结束画笔”,不需要关心这些点最终是进了 LineRenderer 还是 CAShapeLayer。
3.2 一个可运行的最小用例:用 PerlinNoise 生成动态波形
Mathf.PerlinNoise 经常被用于生成地形高度、云层变化、人物抖动和波形轨迹。在绘制工具里,它适合用来演示“Unity 动态生成路径点,再交给原生渲染”的完整链路。
下面这段代码把噪声值转成一条连续的折线,横坐标从画布左侧均匀推进到右侧。
public string BuildNoiseStrokePayload( float canvasWidth, float canvasHeight, float seed, int sampleCount) { var buffer = new DrawingCommandBuffer(); float startX = 20f; float endX = canvasWidth - 20f; float baseY = canvasHeight * 0.5f; float amplitude = canvasHeight * 0.2f; buffer.BeginStroke("noise_" + seed.ToString("0.000")); buffer.SetStrokeStyle( new Color32(0x22, 0x77, 0xFF, 0xFF), 4f, 0.9f); for (int i = 0; i <= sampleCount; i++) { float t = (float)i / sampleCount; float noiseValue = Mathf.PerlinNoise(seed + t * 6f, 0.5f); float x = Mathf.Lerp(startX, endX, t); float y = baseY + (noiseValue - 0.5f) * 2f * amplitude; buffer.AddPoint(x, y); } buffer.EndStroke(); var payload = DrawingPayloadBuilder.BuildJson( "annotation_canvas", canvasWidth, canvasHeight, buffer.Flush()); return payload; }这段代码的关键点有三个:
sampleCount控制点密度,点太密会浪费原生渲染开销,点太疏则曲线不平滑。入门项目可以先给 200 个点。Mathf.PerlinNoise(seed + t * 6f, 0.5f)的第二个参数写固定值,得到的是一条一维噪声波形;如果第二个参数也随 t 变化,得到的是一块噪声面。BuildJson负责把 DTO 列表转成上一节定义的 JSON envelope,不参与渲染逻辑。
3.3 Unity Editor 内可以先预览,再把指令提交给原生
如果工程暂时只需要在 iOS 原生侧渲染,那么在 Unity Editor 里调试时,需要有一个轻量预览路径。预览最简单的方式是用OnDrawGizmos或 Gizmos.DrawLine,不引入额外 UI 组件。
#if UNITY_EDITOR private List<Vector2> _previewPoints; private void OnDrawGizmosSelected() { if (_previewPoints == null || _previewPoints.Count < 2) { return; } Gizmos.color = Color.blue; for (int i = 0; i < _previewPoints.Count - 1; i++) { Gizmos.DrawLine( new Vector2(_previewPoints[i].x, _previewPoints[i].y), new Vector2(_previewPoints[i + 1].x, _previewPoints[i + 1].y)); } } #endif如果你用的是 UGUI 动态画线,也不建议一开始就直接写自定义网格。先用少量管理类把“路径数据”保存下来,再用LineRenderer或UGUI做编辑器预览,等到数据协议稳定后,再把真正发布到 iOS 的渲染实现切到原生层。编辑器预览和 iOS 原生渲染可以并存:预览用于快速检查,原生渲染用于真机验证。
4. iOS 原生侧:用 CAShapeLayer 渲染指令,用 Auto Layout 管理工具栏
4.1 用 Codable 解析上一节下发的 JSON
iOS 原生侧应当有一个专用绘制视图,不建议直接在主ViewController里写绘制逻辑。绘制视图负责解析 JSON、管理 CAShapeLayer、处理触摸事件,ViewController 只负责布局。
可以先用 Codable 定义结构:
struct DrawingEnvelope: Codable { let version: Int let canvasId: String? let coordinateSpace: String let viewport: DrawingViewport? let actions: [DrawingNativeAction] } struct DrawingViewport: Codable { let width: CGFloat let height: CGFloat let scale: CGFloat? } struct DrawingNativeAction: Codable { let op: String let pathId: String? let style: DrawingStrokeStyle? let points: [DrawingPoint]? let visible: Bool? let enabled: Bool? let phase: String? } struct DrawingStrokeStyle: Codable { let strokeColor: String? let strokeWidth: CGFloat? let opacity: CGFloat? let lineCap: String? let lineJoin: String? let dashPattern: [CGFloat]? } struct DrawingPoint: Codable { let x: CGFloat let y: CGFloat }解析完成后,通过op分发到不同处理函数。核心逻辑是把stroke动作渲染成一个CAShapeLayer。
func render(envelope: DrawingEnvelope) { if let viewport = envelope.viewport { self.viewport = viewport } for action in envelope.actions { switch action.op { case "stroke": renderStroke(action) case "clear": layer.sublayers?.removeAll() case "layerVisible": updateLayerVisibility(action) case "enableTouch": isTouchEnabled = action.enabled ?? false default: break } } }CAShapeLayer直接使用 Core Animation 渲染矢量路径,不涉及额外贴图,对于画线工具来说比频繁提交纹理更高效,也更容易做颜色、圆角、线帽和虚线控制。
4.2 绘制 stroke 时要注意坐标映射和图层命名
CAShapeLayer和UIBezierPath是 iOS 原生绘制线的常用组合。下面给出一个基础实现思路:
private func renderStroke(_ action: DrawingNativeAction) { guard let points = action.points, points.count >= 2 else { return } guard let bezierPath = UIBezierPath() else { return } let firstPoint = mapRenderPoint(points[0]) bezierPath.move(to: firstPoint) for point in points.dropFirst() { let nextPoint = mapRenderPoint(point) bezierPath.addLine(to: nextPoint) } let shape = CAShapeLayer() shape.path = bezierPath.cgPath shape.fillColor = UIColor.clear.cgColor shape.strokeColor = color(from: action.style?.strokeColor).cgColor shape.lineWidth = action.style?.strokeWidth ?? 2 shape.opacity = Float(action.style?.opacity ?? 1) shape.lineCap = .round shape.lineJoin = .round if let dashPattern = action.style?.dashPattern { shape.lineDashPattern = dashPattern.map { NSNumber(value: Double($0)) } } if let pathId = action.pathId { layer.addSublayer(shape) shape.name = pathId } else { layer.addSublayer(shape) } } private func mapRenderPoint(_ point: DrawingPoint) -> CGPoint { let logicalW = viewport?.width ?? bounds.width let logicalH = viewport?.height ?? bounds.height let normalizedX = point.x / logicalW let normalizedY = point.y / logicalH return CGPoint( x: normalizedX * bounds.width, y: normalizedY * bounds.height ) }如果下发时已经约定好 iOS 坐标是“左上角原点”,这里就不需要反转 y。反之,如果 Unity 发送的是从下到上的坐标,就需要在mapRenderPoint里补一句y = bounds.height - y。
注意:不要把
CAShapeLayer的frame当成业务状态去维护。frame只决定 layer 在其父坐标系里的位置,一般绘制坐标都应由path本身携带,图层 frame 保持和画布对齐。
4.3 使用 UIStackView 和 Auto Layout 搭建绘制工具栏
绘制工程里经常需要一组颜色选择器、画笔宽度滑块、撤销按钮。这些控件如果用 Frame 硬编码,横竖屏切换时需要一个一个改坐标。用UIStackView配合 Auto Layout,可以在一开始就避免这种布局问题。
private lazy var toolbarStackView: UIStackView = { let stack = UIStackView() stack.axis = .horizontal stack.alignment = .center stack.distribution = .equalSpacing stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false return stack }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(canvasView) view.addSubview(toolbarStackView) let colorButton = UIButton(type: .system) colorButton.setTitle("颜色", for: .normal) let widthSlider = UISlider() widthSlider.minimumValue = 1 widthSlider.maximumValue = 20 let clearButton = UIButton(type: .system) clearButton.setTitle("清空", for: .normal) toolbarStackView.addArrangedSubview(colorButton) toolbarStackView.addArrangedSubview(widthSlider) toolbarStackView.addArrangedSubview(clearButton) NSLayoutConstraint.activate([ toolbarStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), toolbarStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), toolbarStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), canvasView.leadingAnchor.constraint(equalTo: view.leadingAnchor), canvasView.trailingAnchor.constraint(equalTo: view.trailingAnchor), canvasView.topAnchor.constraint(equalTo: toolbarStackView.bottomAnchor, constant: 8), canvasView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) ]) }这种结构里,canvasView负责整块绘制区域,工具栏stackView悬浮在上方。由于绘制工具往往需要占用比较多屏幕面积,所以要让canvasView具有明确的 leading、trailing、top、bottom 四边约束,不要只给 width 和 height。
5. 端到端链路:从 Unity 主动推指令,到 iOS 原生回传触摸事件
5.1 Unity 调用 iOS 原生方法的桥接层
Unity 工程打 iOS 包后,C# 可以直接调用工程里的 Objective-C/C/C++ 函数。前提是原生侧实现了同名导出函数,并且 C# 使用__Internal标记。
#if UNITY_IOS && !UNITY_EDITOR using System.Runtime.InteropServices; public static class DrawingNativeBridge { [DllImport("__Internal")] private static extern void UnityDrawingBridgeSend(string payload); public static void Send(string payload) { UnityDrawingBridgeSend(payload); } } #endif在 Unity 业务代码里调用时,要区分 Editor 和 iOS 真机环境。Editor 里不能直接调用__Internal函数,否则会因为找不到入口导致崩溃。
public void FlushToNative(string payload) { #if UNITY_IOS && !UNITY_EDITOR DrawingNativeBridge.Send(payload); #else Debug.Log("[Drawing] preview only, payload size=" + payload.Length); #endif }5.2 iOS 原生导出函数和回传 Unity
原生导出的 C 函数接收到字符串后,要切换到主线程再交给绘制视图。不要在回调函数里直接操作 UIKit 对象。
extern "C" { void UnityDrawingBridgeSend(const char *jsonString) { if (jsonString == NULL) return; NSString *json = [NSString stringWithUTF8String:jsonString]; dispatch_async(dispatch_get_main_queue(), ^{ [[NSNotificationCenter defaultCenter] postNotificationName:@"DrawingPayloadDidReceive" object:json]; }); } }iOS 原生侧在touchesBegan、touchesMoved、touchesEnded中采集触摸点后,可以通过UnitySendMessage回传给 Unity 场景里的某个 GameObject。
extern "C" { void UnityDrawingBridgeTouchEvent(const char *eventJson) { UnitySendMessage("DrawingMain", "OnNativeTouchEvent", eventJson); } }C# 侧对应写一个普通方法:
public class DrawingMain : MonoBehaviour { public void OnNativeTouchEvent(string payload) { var touchData = JsonUtility.FromJson<NativeTouchEvent>(payload); // 根据 touchData.phase 处理 down、move、up } }这个回路的价值在于,Unity 只负责下发热点指令,原生侧负责绘图和手势,两端不会互相阻塞。
5.3 真机验证:怎样的输出才算链路打通
搭建完桥接后,不要只看画面是否出现,应该按下面的顺序验证:
- 在 Unity 场景里放一个“生成噪声线”按钮。
- 点击按钮后,Unity 组包并调用
FlushToNative。 - iOS 原生 Xcode 控制台应输出收到 JSON 的日志。
- 绘制视图上出现蓝色噪声线。
- 在 iOS 绘图层用手指滑动,Xcode 控制台应输出 touch 事件。
- Unity Console 中应该能收到
OnNativeTouchEvent回调。
排查链路可以按下表逐步查看:
| 验证点 | 预期现象 | 如果失败优先检查 |
|---|---|---|
| Unity 按钮点击 | 日志出现 payload 长度 | C# 里是否走了UNITY_IOS分支 |
| 原生收到 JSON | Xcode 打印收到字符串 | 导出函数名是否一致,JSON 是否 NULL |
| 视图绘制成功 | 蓝色噪声线出现 | JSON 里coordinateSpace和坐标映射 |
| 触摸采集成功 | iOS 日志输出触摸点 | isTouchEnabled是否为 true |
| Unity 收到回传 | Unity 日志显示 touch phase | UnitySendMessage的对象名是否真实存在 |
6. 这几个坑最容易在 iOS-Unity 绘制链路里出现
6.1 横竖屏旋转后线条位置错乱
很多绘制工具一开始就锁定竖屏,所以横竖屏来回切换时才暴露出坐标系问题。CAShapeLayer的path不会自动跟随 view 的 size 变化重新计算,尤其是你已经保存了绝对坐标,而不是归一化坐标。
排查方法:
- 确认 JSON 下发时是否携带了当时的
viewport。 - 确认
mapRenderPoint是否使用当前 bounds 反算。 - 旋转前后如果画布尺寸发生变化,需要重新触发整批指令的重新渲染。
最稳妥的做法是在 iOS 原生侧保留最后一份DrawingEnvelope,在layoutSubviews或viewDidLayoutSubviews中重新执行一次render(envelope:)。但如果每次都新建 CAShapeLayer,内存会涨得很快,性能不好,所以生产项目偏向于保存 path 列表,在旋转后重建。
6.2 动态画线过于频繁,最终导致卡顿或内存报警
每次touchesMoved都生成新的CAShapeLayer,是画板类项目最常见的错误。一条手指路径可能产生几百个 move 回调,首帧点创建好 layer 后,后续点只需要调用UIBezierPath.addLine并更新同一个 layer 的path。
不要把每个点都提交一个大 JSON。更推荐的做法是:
- 手势 down 时创建一条新 stroke。
- move 时把点追加到内存,并且定时批量渲染。
- up 时才把完整 stroke 发送给 Unity 或保存到回放列表。
- 动态线回放阶段则使用 CADisplayLink,每帧推进有限个点。
性能排查顺序建议:第一看 CAShapeLayer 是否过多,第二看是否频繁调用layer.path =赋值,第三看是否把整条路径 JSON 高频发送到日志或文本控件里。
6.3 iOS 真机上出现 DllNotFoundException: Unable to load DLL 'slua'
如果 Unity 工程集成了 slua 这类 Lua 桥接插件,打包到 iOS 真机后控制台可能会看到类似下面这一条:
DllNotFoundException: Unable to load DLL 'slua'这条报错不能从 C# 调用方单独解决。它通常表示插件没有以 iOS 支持的 native library 形式打进包里,或者插件只导入了 Editor 版本。检查方式如下:
- 查看插件的 iOS 版本目录是否包含
.a、.framework或.bundle文件。 - 在 Unity 插件 Import Settings 中确认 Platform 勾选了 iOS。
- 真机日志无法在 Editor 里复现,所以要先确认你用 Xcode 构建的是真机包,而不是模拟器包。
- 跑一个只调用 slua 的极简用例,排除业务代码干扰。
如果输入数据里还有 Lua 侧动态生成的绘制点,要注意点坐标在进入 C# 后仍然是 Lua 表里的数字,必须先在 C# 侧转换成 DrawingPoint DTO,再进行 JSON 序列化,不能直接把 Lua 表格转成 JSON 后混进 Native 绘制协议。
6.4 渲染 API 或插件不统一导致构建期现象不可复现
Unity 在导出 iOS Xcode 工程时,渲染 API 可以在 Player Settings 里勾选。如果关闭 Auto Graphics API,只留 iOS 支持的 Metal 渲染,很多绘制相关的异常更容易复现。可以进入 Unity 的 Player Settings,在 Other Settings 的 Rendering 部分手动确认:
Graphics APIs: Metal Auto Graphics API: false注意,这并不会直接解决 DllNotFoundException,但统一渲染 API 之后,纹理、截图和动态绘制相关的日志会更稳定,排查问题时也少一个变量。
7. 生产环境下如何继续演进这套通用绘制工具
7.1 性能和内存优化要从“减少指令和图层数量”入手
在开发环境跑通不代表能直接用于生产。上线或对外发布前,至少要做一轮绘制性能检查:
- 用几百条动态线测一测 CAShapeLayer 数量对内存的影响。
- 在性能面板统计 Unity 侧 JSON 序列化耗时。
- 在 iOS Instrument 里观察 Core Animation 的 Commit 数量。
- 如果使用小图标、颜色点等贴图资源,Unity 侧优先考虑 Sprite Atlas 打包合图,减少小图各自提交造成的多次内存拷贝和额外 Draw Call。
下面这张表可以作为性能验收清单:
| 项目 | 开发环境关注点 | 生产环境关注点 |
|---|---|---|
| JSON 格式 | 可读性好,方便打印 | 可压缩,频率低于每帧多次 |
| 指令数量 | 越小越好 | 按帧批量下发,避免每个点一次调用 |
| CAShapeLayer | 一个 stroke 一个 layer | 定期清理无引用图层,或复用 path |
| 点序列 | 完整保留方便调试 | 增加抽稀算法,保留视觉关键点 |
| 图标资源 | 单图测试 | Sprite Atlas 合批,减少单独加载 |
| 横竖屏 | 锁竖屏测试 | 记录最后 envelope,旋转后重放 |
7.2 日志、监控和回滚机制要跟着协议一起扩展
通用绘制工具最大的优势是“指令可回放”,生产环境要充分利用这个特性。用户反馈线条丢失或错位时,你可以让客户端只上传最近一段动作指令,而不是上传整张截图。回放动作指令可以快速判断问题是出在采集端、协议端还是渲染端。
日志记录要覆盖四条链路:
- Unity 初始化绘制指令的耗时。
- Unity 发送 payload 的时间点。
- iOS 收到 payload 和渲染完成的间隔。
- iOS 触摸事件回传给 Unity 是否成功。
如果生产端需要支持撤销、重做,可以给每条动作增加一个本地自增序号或时间戳,而不是用随机字符串作为 pathId。带有序号的指令流,天然支持服务端合并和客户端回放。
7.3 给新项目团队的落地建议
第一次接入这套方案时,不建议直接铺开涂鸦、标注、AR 引导等全部场景。建议按下面这个顺序做增量验证:
- 先实现 Unity 到 iOS 的单向画线,只画一条静态 Bezier 折线。
- 再接入 PerlinNoise 生成动态波形,验证点序列变化。
- 然后接入 iOS 原生触摸回传,验证 Unity 能收到手势事件。
- 最后再把颜色选择器、线宽控制、清空、撤销这些 UI 控件接入 UIStackView 工具栏。
每一步都建立一个可运行版本,再进入下一步。通用绘制工具真正要解决的不是“有没有画出来”,而是当 Unity 侧和 iOS 原生侧都需要理解同一段用户操作时,能不能用同一种指令语言干净地表达出来。只要协议稳定、坐标系统一、图层管理清晰,后续再加曲线插值、橡皮擦、自定义笔刷、多端回放,都是在同一个骨架上扩展而已。