SEO 信息SEO 标题动图魔方技术拆解 07ArkTS 实现 GIF LZW 编码与数据子块写入SEO 摘要基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”继续拆解GifEncoderService.ets的底层写入逻辑compressIndices()如何把索引帧压成 GIF LZW 位流BitWriter如何按位打包Clear Code和End Code如何控制字典生命周期以及图像数据为什么必须再切成 255 字节以内的 sub-block。本文给出真实代码、字节级日志和工程截图说明一个本地优先 GIF 工具怎样把压缩结果稳定写进标准文件。关键词GIF LZW, ArkTS, HarmonyOS, GifEncoderService, BitWriter, Clear Code, End Code, sub-block文章封面doc/csdn-series/covers/cover-07-gif-lzw-bytes.jpg投稿方向普通技术拆解 / GIF 编码器项目环境HarmonyOS SDK6.1.0(23)、ArkTS、DevEco Studio、GIFRubiksCube第 06 篇已经把 GIF89a 容器结构讲清楚了但容器只是“能装进去”真正决定图像数据能不能落进去的是 LZW 编码、码宽增长和 sub-block 写法。本文不再重复头部和帧控制块而是直接拆GifEncoderService.ets里最核心的compressIndices()说明同一组索引帧为什么能够被写成可播放的 GIF 数据区。一、真实工程问题背景如果只看表面GIF 导出像是“把一串像素写成文件”。但在“动图魔方”里真正难的是把“索引数组”稳定压进 GIF 的图像数据区且还要满足播放器、查看器和不同平台解析器的共同约束。第 06 篇已经证明了容器结构可以落地第 07 篇要回答的是更底层的问题indices[]怎么变成 GIF 的 LZW 位流。字典什么时候增长什么时候重置。为什么压缩结果还要再按 255 字节切成 sub-block。为什么同样的压缩逻辑放到 UI 线程里就会把体验拖垮。这一步如果写错后果通常不是“画质差一点”而是“文件能生成但打不开”。二、目标与边界本文只聚焦三件事拆清GifEncoderService.ets中compressIndices()的字典逻辑和码流输出。说明BitWriter为什么要按位写而不是按字节写。说明writeImageData()为什么必须把压缩结果切成 GIF 规定的 sub-block。本文不展开的内容也先明确不再重复讲 GIF89a Header、LSD、GCE 和 Trailer这些在第 06 篇已说明。不讨论调色板量化算法细节相关内容会留到第 08 篇。不深入讲视频抽帧或 PixelMap 处理它们属于第 02 篇和导出链路上游。三、输入为什么必须先变成索引帧GifEncoderService接收的不是 RGBA而是已经量化好的索引帧export interface IndexedGifFrame { width: number; height: number; indices: number[]; delayCs: number; } export interface GifEncodeInput { width: number; height: number; palette: number[]; frames: IndexedGifFrame[]; loopCount: number; }这里的判断其实很直接GIF LZW 压缩处理的是索引流不是原始 RGB。上游已经把颜色量化和帧延迟处理完了编码器就只做“写文件”。delayCs直接对应 GIF 的帧延迟单位适合和控制块对齐。loopCount被提升成文件级参数避免循环信息散在页面状态里。也就是说compressIndices()看到的不是“图片”而是“已经准备好落盘的索引序列”。四、BitWriter 为什么必须按位写GIF 的 LZW 码不是整字节对齐的。码宽会随着字典扩容变化实际写出去的是连续 bit 流而不是一串固定宽度的字节。项目里专门放了一个很小的位写器class BitWriter { private data: number[] []; private current: number 0; private bits: number 0; write(value: number, size: number): void { let next value; let remaining size; while (remaining 0) { this.current | (next 1) this.bits; next next 1; this.bits; remaining--; if (this.bits 8) { this.data.push(this.current); this.current 0; this.bits 0; } } } finish(): number[] { if (this.bits 0) { this.data.push(this.current); this.current 0; this.bits 0; } return this.data; } }它的关键点只有两个write()是按 LSB-first 顺序写 bit这和 GIF 的码流规则一致。finish()会把最后没有填满的那一字节刷出去避免尾部 bit 丢失。这个类很小但它是整个编码器能不能“按 GIF 规则说话”的基础。五、LZW 字典是怎么长起来的核心逻辑在compressIndices()private static compressIndices(indices: number[], minCodeSize: number): number[] { const clearCode 1 minCodeSize; const endCode clearCode 1; let codeSize minCodeSize 1; let nextCode endCode 1; const writer new BitWriter(); const table new Mapstring, number(); GifEncoderService.resetTable(table, clearCode); writer.write(clearCode, codeSize); let prefix indices.length 0 ? ${indices[0]} : ; for (let index 1; index indices.length; index) { const value indices[index] 0xFF; const key ${prefix},${value}; if (table.has(key)) { prefix key; } else { writer.write(table.get(prefix) ?? value, codeSize); if (nextCode 4096) { table.set(key, nextCode); nextCode; if (nextCode (1 codeSize) codeSize 12) { codeSize; } } else { writer.write(clearCode, codeSize); GifEncoderService.resetTable(table, clearCode); codeSize minCodeSize 1; nextCode endCode 1; } prefix ${value}; } } if (prefix.length 0) { writer.write(table.get(prefix) ?? 0, codeSize); } writer.write(endCode, codeSize); return writer.finish(); }这里可以拆成 5 个工程判断clearCode和endCode是 GIF LZW 的两个边界码。codeSize初始是minCodeSize 1因为还要容纳控制码。table里存的是“前缀 当前值”组合不是单个像素。nextCode增长到阈值后codeSize会同步增加。字典满到 4096 项后会回到clearCode重新开始一轮。这套逻辑的重点不是“压得多狠”而是“在所有播放器都能接受的前提下把索引流写成合法 GIF”。六、码宽增长为什么不能省LZW 的关键不是字典本身而是码宽会变化。GifEncoderService在字典扩容时做了这一句if (nextCode (1 codeSize) codeSize 12) { codeSize; }这意味着字典项越来越多时单个 code 的表达位数也要跟着变长。如果码宽不变后面的代码就会被截断播放器会直接读歪。GIF LZW 的上限是 12 bit所以codeSize不会无限增长。也就是说codeSize不是一个静态常量而是压缩过程里必须跟着字典动态演进的状态。七、为什么图像数据还要切 sub-block压缩结果出来以后还不能直接当作图像数据写入。GIF 还要求把图像数据分成一块块 sub-blockprivate static writeImageData(out: number[], indices: number[]): void { const compressed GifEncoderService.compressIndices(indices, 8); let offset 0; while (offset compressed.length) { const length Math.min(255, compressed.length - offset); out.push(length); for (let index 0; index length; index) { out.push(compressed[offset index]); } offset length; } out.push(0x00); }这里有三个硬约束每个数据块前都要写长度字节。单块最大 255 字节。整组图像数据结束后必须写0x00结束块。所以compressIndices()负责“压缩”writeImageData()负责“按 GIF 容器规则搬运压缩结果”。这两步不能混成一步。八、字节级证据为了验证这套逻辑我用和项目相同的写入顺序构造了一个最小样例并记录了关键输出sample1: [0,1,0,1,0,1,0,1,0,1,0,1] compressedHex: 00 01 04 10 48 70 a0 c1 80 compressedLength: 9 subBlocks: [9]这组结果说明了几件事压缩结果已经进入位流阶段不再是原始索引数组。输出体积很小所以只需要一个 sub-block。BitWriter能把 LZW 码稳定写成字节序列。我还额外跑了一个更长的索引序列用来确认码流不会卡在短样本上length: 260 bytesLength: 52 hexHead: 00 01 04 10 30 40 20 41 83 05 07 26 3c a8 10 a1 c3 86 10 19 4a 5c 48 f1这个结果至少说明两点压缩逻辑在长序列上会持续输出正常字节。码流前段和后段都不是简单的原样拷贝而是按 LZW 规则打包后的结果。九、工程截图与验收证据9.1 导出结果页说明编码器已经接入真实作品链路这张图说明两件事编码器输出不是测试对象而是已经进入作品链路。导出完成后作品记录和文件写盘是连通的。9.2 编辑页说明导出链路不是孤立实验这张图对应“编辑参数 - 导出 - 作品”的真实路径。GifEncoderService不是单独的协议实验而是整个创作流程的最终落点。9.3 构建记录说明代码处于真实工程环境项目当前构建命令的输出仍然是BUILD SUCCESSFUL Will skip sign hos_hap. No signingConfigs profile is configured in current project.这说明本文讨论的编码器逻辑来自可构建工程不是脱离项目的伪代码。十、工程复盘重新拆过这一层后结论比较明确compressIndices()的核心价值不是把压缩做得极致而是把 GIF LZW 的边界写对。BitWriter这种小工具虽然朴素但它把“码流输出”和“容器结构”彻底分开了。writeImageData()单独负责 sub-block是因为 GIF 的图像数据规则本来就是分层的。对本地优先的 HarmonyOS 工具来说这种写法更稳先保证压缩结果合法再逐步考虑更高级的压缩率优化。十一、验收清单验收项结果说明LZW 码流按位写入通过BitWriter.write()逐 bit 输出Clear Code和End Code存在通过compressIndices()开头和结尾都写入字典扩容会推动码宽增长通过codeSize会随nextCode增长字典满后会重置通过4096 项后重新resetTable()图像数据按 sub-block 切块通过每块最大 255 字节图像数据以0x00结束通过writeImageData()尾部补结束块编码结果已接入真实导出链路通过导出页与作品页截图可见十二、小结第 07 篇真正想讲清楚的是GIF 能不能稳定播放不只取决于“有没有压缩”而取决于你有没有把 LZW、码宽、字典重置和 sub-block 这些细节都写成符合协议的字节流。GifEncoderService.ets当前这套实现不花哨但边界清楚、依赖少、容易维护很适合“动图魔方”这种本地优先工具作为底层基线。十三、下一篇衔接下一篇进入第 08 篇动图魔方技术拆解 08调色板量化怎么把真彩帧压进 256 色。到那一篇我会单独拆PaletteQuantizer.ets说明为什么调色板量化和 GIF 编码不是一回事但它们又必须在同一条导出链路里精确对齐。