Android 顶点上传:Heap 到 Direct 的零拷贝,省的是 JNI 次数 跨一次边界的固定开销远大于它搬过去的那四个字节。前言一屏两三千个要素每个要素四十来个顶点每个顶点六七个属性X 整数、X 小数、Y 整数、Y 小数、颜色、线宽。每写一个属性调一次putFloat一帧就是四五十万次写入。GPU 那头闲着CPU 这头卡在onDrawFrame里出不来——平移时主线程占满帧率就掉在那儿。矛盾不在数据量。四五十万次putFloat搬的字节加起来几兆纯 memcpy 跑完用不了几毫秒时间花在那四五十万次跨语言边界本身——每一次的固定开销都远大于它搬过去的四个字节。JNI 的账按次数记不按字节记。这套引擎给每个MeshData配两块缓冲区顶点先写进堆内ByteBufferJIT 能折成裸数组操作最后一次性put(writeBuf)整块搬到堆外几十万次边界切换压成一次。下面讲清它的代价、边界和gridSize的依据也包括它什么时候反而更慢。一、DirectByteBuffer 不是免费的Java 侧向 GPU 传数据最终必须经过DirectByteBuffer堆外内存因为glBufferData的 native 实现需要一块不受 GC 搬动的稳定内存。于是天真的写法是valbufByteBuffer.allocateDirect(vertexBytes).order(ByteOrder.nativeOrder())for(vinvertices){buf.putFloat(v.x)// 每一次 put 都是一次 JNI 调用buf.putFloat(v.y)buf.putShort(v.decimalX)// ... 更多属性}问题在于DirectByteBuffer.putFloat这类方法在 JDK 里并没有被 JIT 很好地内联很多实现里每一次 put 都要穿越 JNI 边界做一次越界检查 native 内存写入。顶点一多这就是天文数字的边界切换。JNI 边界这四个字可以用一个比方说清楚想象你要把一万个数字抄到一本放在另一个办公室的账本上native 内存。堆内缓冲区HeapByteBuffer就像你手边的草稿纸写一个数字是一笔落下飞快而DirectByteBuffer就像每写一个数字你都得打一次电话、让对面办公室的人帮你记——每一次put都是一次跨办公室电话。抄一万个数字你打了十万次电话光拨号就占满了时间。所以堆内ByteBuffer的put是纯 Java 数组操作JIT 能优化到接近裸数组访问几乎没有边界开销——同样的写入放堆内比放堆外快一个数量级。但 GPU 又只认堆外内存。于是矛盾出现了直接写 Direct 慢写 Heap 快但没法直接上传。1.1 那块堆外内存分配一次就够allocateDirect在 native 侧真分配内存再挂一个Cleaner负责归还Java 堆里只多一个对象头占用全记在 native 账上。归还却滞后Cleaner挂在PhantomReference上要等对象不可达、入队、再轮到下次回收才free。反复分配大块堆外症状是 Java Heap 平稳、Native Heap 台阶上涨涨到某点Failed to allocate a direct buffer。下面MeshData里的directBuf是常驻字段反复重建的只有writeBuf堆外复用堆内一次性。1.2 边界次数是能笔算的一个要素四十顶点、七个属性280 次边界三千个要素 84 万次。同一批数据走一次 bulk按每顶点 20 字节估是 2.4 MB。这是笔算不是实测——差六个数量级噪声盖不住。写法每次跨不跨边界JIT 能否内联固定开销由什么定边界次数量级HeapByteBuffer.putFloat不跨能折成数组写 位运算只剩越界检查0DirectByteBuffer.putFloat跨多数实现不内联封送 栈切换 地址写入顶点属性总数DirectByteBuffer.put(ByteBuffer)跨按块分段不内联只在段首付一次块长1 MB 量级字节数 ÷ 块长二、写入与上传解耦各用各的缓冲区解法是把写和传拆成两个阶段用两块缓冲区写入阶段用 HeapByteBuffer顶点属性逐个写入纯 Java 内存拷贝JIT 友好上传阶段用 DirectByteBuffer一次性put(writeBuf)把整块堆内数据 bulk copy 到堆外。关键在于最后那一次data.put(writeBuf)是单次 bulk 操作无论缓冲区多大都只穿越一次 JNI 边界。这就是零拷贝的精髓——不是真的不拷贝而是把 N 次跨边界的微拷贝合并成 1 次整块拷贝堆内负责快堆外负责交差。顶点数据对象的内部结构大致是这样classMeshData(layout:VertexLayout,vertexCount:Int){privatevalstridelayout.strideprivatevaltotalBytesstride*vertexCount// 堆内缓冲区写入用无 JNI 开销privatevarwriteBuf:ByteBuffer?ByteBuffer.allocate(totalBytes).order(ByteOrder.nativeOrder())// 堆外缓冲区上传用privatevardirectBuf:ByteBufferByteBuffer.allocateDirect(totalBytes).order(ByteOrder.nativeOrder())/** 顶点写入阶段直接写堆内缓冲区 */funputPosition(x:Double,y:Double){valbufwriteBuf?:directBuf buf.putFloat(integerPart(x))buf.putShort(fourDecimal(x))buf.putFloat(integerPart(y))buf.putShort(fourDecimal(y))}/** 上传阶段一次性 bulk copy随后释放堆内缓冲区 */fundata():ByteBuffer{valwbwriteBufif(wb!null){wb.position(0)wb.limit(totalBytes)directBuf.clear()directBuf.put(wb)// 单次 JNI bulk copydirectBuf.position(0)writeBufnull// 堆内缓冲区使命完成及时释放}returndirectBuf}}看到没有putPosition写的是writeBuf堆内而data()才做一次整块搬运并把writeBuf置空回收内存。写入期间零 JNI上传时一次 JNI。2.1 为什么是堆内写、一次 bulk堆内putFloat是拆位模式、按nativeOrder拼字节、写byte[]的四个下标全是整数指令边界检查被提到循环外堆外的每次都要重新取地址、检查越界、封送参数。搬的字节一样付的钱差一个常数倍——乘在 10⁶ 上是秒和毫秒的差别。不用FloatArray直接 bulk是因为布局里混了 ushort坐标是float 整数位 ushort 小数位拆分依据见《Android 地图坐标精度float 不够用拆成整数与小数两层来存》12 字节步长里既有 4 字节也有 2 字节。另外put(ByteBuffer)也不必神化它落到Unsafe.copyMemory按 1 MB 量级的块长分段搬十万顶点和一百万顶点只差几次。量纲变了这才是重点。2.2 两块缓冲区的端序必须一致put(writeBuf)只是纯字节搬运、不换序两块都必须.order(ByteOrder.nativeOrder())一边大端一边小端属性读出来就是NaN、画面全黑而glGetError干净、shader 也编译通过。固定大端在 x86 模拟器上看着正常换真机 ARM 就崩。三、解法与实现3.1 更新与绘制分离地图渲染有一个特点数据更新不频繁但绘制很频繁。平移、缩放时顶点数据没变只是 MVP 矩阵变了根本不需要重新上传顶点。所以渲染管线拆成两个方法classRenderJob{// 每帧调用一次负责数据有变化时更新纹理/顶点funonUpdate(request:RenderRequest){for((renderer,ctx)inrequest.rendererMap){if(rendererisVectorRendererrenderer.isNeedUpdate()){renderer.onUpdate(ctx)// 只在需要时重建顶点并上传}}}// 每帧可能调用多次纯绘制不动顶点数据funonDrawFrame(request:RenderRequest){for((renderer,ctx)inrequest.rendererMap){renderer.draw(ctx)}}}onUpdate里有isNeedUpdate()的判断只有数据脏了才触发顶点重建 上传onDrawFrame则是纯绑定 VBO glDrawElements。平移缩放只走onDrawFrame顶点数据躺在 GPU 里一动不动这才是流畅的来源。用两个场景对比这个分离的价值一目了然场景一平移 / 缩放数据没变只是视角变了 每帧 → onUpdateisNeedUpdate() false直接跳过 → onDrawFrame绑定 VBO 画 顶点数据全程躺在 GPU 里一动不动 → 流畅 场景二数据变化加了图层 / 改了要素 该帧 → onUpdateisNeedUpdate() true → 重建顶点 上传 GPU只有这一次是重的 之后的每一帧 → 又回到纯 onDrawFrame 一句话数据脏了才上传视角变了只重画。地图最频繁的操作恰恰是平移缩放而它们恰好落在只重画、不上传的轻路径上——这就是这套设计能扛住高频交互的原因。“分离还有一层在线程上GL 上下文绑线程glBufferData换线程调用得到GL_INVALID_OPERATION回传只能在 GL 线程写顶点只碰 Java 内存可以放主线程或业务线程GL 线程的占用于是从随顶点数涨变成随字节数涨”。反着来——剖分、转换塞进 GL 线程——帧时间被业务撑满两个线程同写一个MeshDataposition 被各推一截画出来是乱飞的三角形不抛异常也不报 GL 错。写入阶段上传阶段谁来调几何构建 / 业务代码渲染循环在哪个线程任意单一线程必须持有 GL 上下文的线程碰不碰 GL 状态不碰碰glBufferData开销随什么涨顶点属性个数系数极小字节数 一次边界放错线程的症状无纯内存写GL_INVALID_OPERATION整块不上屏3.2 按图元类型分管线一个要素画出来可能同时需要填充的面、描边的线、轮廓线、标注文字。它们的绘制方式GL_TRIANGLESvsGL_LINE_STRIP、顶点布局是否带颜色、着色器都不同不能塞进同一个 VBO。于是按图元类型拆成多条渲染管线每条管线维护自己的渲染器和 VBOclassRendererProxy(disableColor:Boolean,gridSize:Int){privatevalstrokeRendererRenderer(StrokeLayout(disableColor),gridSize)// 描边GL_LINE_STRIPprivatevalfillRendererRenderer(FillLayout(disableColor),gridSize)// 填充GL_TRIANGLESprivatevaloutlineRendererRenderer(OutlineLayout(disableColor),gridSize)// 轮廓GL_LINE_STRIPprivatevaltextRendererTextRenderer(30,gridSize,Typeface.DEFAULT)// 文字privatevalrendererslistOf(fillRenderer,outlineRenderer,strokeRenderer)/** 把一个 Mesh 的各个 MeshData 按布局类型分发到对应管线 */funadd(fid:Long,mesh:Mesh){for(meshDatainmesh.meshDataList){when(meshData.layout.layoutType){LayoutType.Stroke-strokeRenderer.add(fid,meshData)LayoutType.Fill-fillRenderer.add(fid,meshData)LayoutType.Outline-outlineRenderer.add(fid,meshData)}}}funonDrawFrame(ctx:RenderContext){for(rendererinrenderers)renderer.onDrawFrame(ctx)textRenderer.onDrawFrame(ctx)}}一个Mesh对应一个 Feature可能拆成多个MeshData外环轮廓、内环、填充面它们被分发到不同管线。绘制时按先填充、再轮廓、再描边的顺序调用天然形成正确的叠放关系。不能合成一个 VBO是因为绘制模式属于调用级状态混着放也得按段调glDrawElementsdraw call 一次没省还把步长不同一起带进来。顺序写反的症状是线被面盖掉一半、标注被填充挡住像 z-order 出错。整体结构见《Android 地图双渲染一套接口同时驱动 Canvas 与 OpenGL》。3.3 VAO 合并粒度gridSize 的取舍如果每个 Feature 一个 VBO几十万个 VBO 会让glBindVertexArrayglDrawElements的调用次数爆炸。所以要把多个 Feature 合并进同一个 VBO/VAO// gridSize 控制多少个 Feature 合并成一个 VAO// 选 100编辑/增量更新时更快更新的范围小// 选 1000渲染时更快VAO 数量少 10 倍draw 调用少valproxyRendererProxy(disableColorfalse,gridSize100)这是更新性能与渲染性能的权衡gridSize小增量更新时只需重建一个小 VAOgridSize大渲染时 draw call 少、状态切换少。注释里提到禁用逐顶点颜色能省一大块显存账可以自己算颜色属性字节数 × 顶点总数RGBA 用 float 是 16 字节换成 4 字节的打包 ubyte省下的就是这 12 字节的差价。gridSize一个 VAO 装多少要素每帧绑定 draw改一个要素要重传适合的数据很小10 量级10要素数 ÷ 10很小一块在线编辑、单要素频繁改中100 量级100要素数 ÷ 100一个街区大小的块增量加载 偶尔编辑很大1000 量级1000要素数 ÷ 1000一大片基本白传一次成型、只读底图依据不是哪个更快是两个约束同时卡住每帧 draw call 数压在几百量级单块重传字节数压在几十 KB 到几百 KB否则改一个要素要白搬一大片。区间通常落在 100 上下要素小往上调大面要素往下调。gridSize按要素个数切、不按字节数点面混排时块大小能差两个数量级按累计字节封顶更稳见《Android 地图十万级要素空间网格切块与渐进式渲染》。四、这套做法什么时候不成立回头看这套Heap 写 Direct 一次性传的模式并不适用于所有 OpenGL 场景静态场景加载一次、永不更新直接一开始就分配好 Direct 缓冲区写进去一次写入成本可忽略不需要 Heap 中转小数据场景几十个顶点的 JNI 开销可以忽略用 Direct 直接写更省事地图场景的特殊性在于数据量大 频繁增量更新。既要重建顶点写入成本高又要多次上传拷贝成本高Heap/Direct 双缓冲的收益才被放大到不可忽视。区别对待的标准只有一条这个 MeshData 一辈子会被填几次。一次成型的瓦片、剖分好的建筑面走 Direct 直写中间那次 bulk copy 纯亏每帧都在变的动态要素——实时轨迹、拖动中的图元、跟随视角的注记——才值得双缓冲一次会话里要被重填成百上千次。另一个容易忽略的点是内存峰值。writeBuf在data()里 bulk copy 完成后立即置空让 GC 回收堆内缓冲避免同一份数据在堆内和堆外各占一份把内存峰值压到接近单份。这对百万要素的场景是决定性的——否则 double 一份顶点数据内存直接翻倍。如果现在重写我会考虑用FloatBuffer.wrap(floatArray)之类的堆内视图进一步减少装箱或者直接用glBufferData的空指针模式glBufferData(..., null, ...)glMapBufferRange让驱动决定拷贝路径映射之后直接在驱动给的内存上写连directBuf这一跳都省了。但这些都要以目标设备 GPU 特性为前提映射失败、映射后比 memcpy 还慢、驱动偷偷做同步都是真实分支。脱离设备谈优化都是空谈。结论JNI 按次数计费不按字节计费搬 4 字节和搬 4 KB 的固定开销差不多要砍的是调用次数。堆内写、堆外传HeapByteBuffer.putFloat是数组写加位运算JIT 能内联DirectByteBuffer的每次put都是一次边界。put(ByteBuffer)是按 MB 计的几次它落到Unsafe.copyMemory分块搬边界次数与顶点数脱钩量纲从 10⁶ 变 10⁰。堆外复用、堆内一次性allocateDirect的回收绕Cleaner一圈反复分配的症状是 Native Heap 阶梯上涨、GC 不回落。写和传分线程也分时机glBufferData只能在 GL 线程顶点写入放哪都行但一个MeshData只能有一个写者。gridSize按个数切不按字节切卡住 draw call 数与单块重传量取 100 上下要素大小差异大时按字节封顶。putPosition里那句writeBuf ?: directBuf你是留着兜底还是删了置空之后再往里写是不是就悄悄退回了逐点跨边界欢迎在评论区说说你的选择以及为什么。关键词标签#Android#OpenGL#地图引擎#JNI#DirectByteBuffer#VAO#性能优化