20261015
读者应具备的知识基础
Kotlin、 Compose、 基础Android开发(谷歌官网课程优先)、网络编程基础、文件解析基础(可参阅《Java编程的逻辑》相关章节)
可以预见的AI能力边界
上节末尾的结语可能触动了"拜AI神教"的信徒,有朋友发消息给我表示我实在太猖狂了,AI分分钟把我秒到渣都不剩!
如果真有这么一天,那么我将毫不意外,十分可惜的是不是今天,甚至不会是今年、明年、后年。
既然如此,我们还是做一点科普吧。
我在之前章节早就已经写过,AI不过是件工具,工具在某些方面超越人类并非稀罕事,这就好比计算器发明之后,人类心算再快也无法受到周围人的景仰了,仅此而已。
现在的怪现象在于,有许多没有理工思维的人,莫名其妙对着AI直接下跪唱征服了,实际上在人类的科技史上,不弱于AI的"奇迹"简直数不胜数,而在此之前他们却对之视而不见。
我坚信科学战胜宗教是必然事件,只要教育平均水平足够高,"拜AI神教"的覆灭也只是时间问题。
同时,我们也十分容易预言AI会在哪里碰壁。
目前所谓AI技术的根本原理,说白了跟机器翻译差不太多,是一种基于统计学的"最优概率输出"。牵扯到概率的话,有个广为流传的说法是:给猴子一部打字机,再给它超长时间比如十亿年,让它随机敲键盘,它也能打出莎士比亚的著作。而牵扯到统计的话,有个极度乐观的说法是:只要给AI足够多的上下文信息,它就可以得出一切问题的答案。
对于第一个说法,那仅仅只是理论上的浪漫,以此套到AI身上认为它总可以力大砖飞也是浪漫。在科学领域有数之不尽这种过于理想的假设了,但在现实中它根本不会发生 。对于第二个说法,同样也是科学史上的老黄历了,也就是所谓的"科学决定论",如果你对此有所了解的话,应当知道这也早就破灭了,既因为某些信息根本无法收集,也因为类似量子物理的存在,指明了我们的现实世界始终存在无法预测之物,总会存在某些事物,人类与AI均不可理解、不可观测、不可触碰。
也就是说,无论如何,AI始终存在犯错的可能性,与人类拉不开本质差别。
当然,AI这件工具仍旧十分强大,在当前时代,就像不再需要心算一样,常规场景下我们或许已经不再需要手写算法。
算法成为逃课项目
之前章节提到过,AI十分擅长写UI,但比起来算法它可能会更为擅长。
算法的发展并不轻松,如果你读过《编程珠玑》,应该记得仅仅一个二分法,人类足足耗费了27年才找到真正完美的写法。
不知道你是否有参加过ACM竞赛?我参加过,也曾在算法八股文里挣扎,成绩不咋地。并非吃不到葡萄就说葡萄酸,我认为算法竞赛与奥数竞赛性质雷同:不过是题海的堆砌罢了。就像奥数冠军往往离真正的数学家相去甚远,算法竞赛冠军也跟真正的软件工程师不是一回事。
只不过,在AI出现之前,在某些场景下我们要把软件性能做得足够高效,就必须苦苦钻研算法工具。
算法的本质,或者说软件优化的本质,就两条路:以空间换时间,或反过来,以时间换空间。
你只需要学习一点经典算法,就不难领会了。我推荐你学一点,因为我见过太多人不懂算法对软件优化完全就抓不到点子上。你也需要懂那么一点皮毛来判定AI的方向是否正确。
撇开设计算法不谈,一般情况下,我们使用算法,不过是使用一些既定套路,适用场景、使用方法,全都非常死板,没有创新之处,因此相当适合AI发挥。
接下来的功能,涉及算法的部分我们就让Gemini来写。
支持网络在线浏览的Pdf解析器
如上一节所述,我们在做Smb在线阅读漫画功能,之前举了Mobi、Zip的例子。
Pdf的情况稍有不同,这个Adobe 1993年发布的文档格式太老了,理念与技术的时代限制令其一出生就存在很大问题,而经过过于漫长的历史,上千页的文档就更像是屎山的具象化了。没有其他大公司强推新格式将其全面替换也实在是很遗憾的事。
Pdf在设计之初没有考虑过网络在线阅读的场景,之后想要补救的时候,引入了"线性化PDF",遗憾的是覆盖范围太小,即使是现在新生成的Pdf,也绝少是线性化的。
而且,现成的开源库都是基于本地阅读的,根本无法适配在线阅读的需求。
所以,我们应当就像处理Mobi、Epub那样,做一个简易的Pdf解析器,反正对于漫画文档来说,只需要处理图片就好。
本文重点不是Pdf解析细节,如有需要推荐参阅书籍《PDF Explained》《Developing with PDF》。
Png图片流解码
PDF漫画储存图片主要采用Jpg,Png这两种格式。对于Jpg,它将是原本的二进制数据,也就是说取得流对象中的ByteArray后就可以直接解析为图片。而对于Png,流对象中的数据有经过压缩,所以第一步需要进行zlib解压,之后,如果Predictor参数大于10,还说明采用了png过滤算法压缩数据,也就需要进行还原,最后,Pdf中存储的为Rgb色彩空间数据,而Android采用Argb,所以还需要一步转换。
让AI来写,这十分简单,注意到这不需要上下文,我们让网页版Gemini来做,模型版本更新且不会消耗Android studio中的用量。只不过它不懂Pdf规范,在经我提示后,得到了可用代码:
kotlin
/**
* 独立的 PDF 图片流解码方法
* @param compressedBytes 压缩的图片字节数据
* @param width 图片宽度 (/Width)
* @param height 图片高度 (/Height)
* @param predictor 预测器类型(无预测传 1,PNG 预测通常传 12 或 15)
* @param colors 颜色通道数(RGB为3,灰度为1)
* @param bitsPerComponent 每分量位数(通常为8)
* @param columns 外部解析出的 /Columns 参数(可空。若有则用之,若无或<=0则自动回退到 width)
*/
private fun decodeImageBytesFlateDecode2(
compressedBytes: ByteArray,
width: Int,
height: Int,
predictor: Int = 1,
bitsPerComponent: Int = 8,
colors: Int = 3,
columns: Int? = null
): ByteArray {
val decompressedBytes = decompressZlib(compressedBytes, 0, compressedBytes.size)
if (predictor >= 10) {
val finalColumns = if (columns != null && columns > 0) columns else width
val rowBytes = (finalColumns * colors * bitsPerComponent + 7) / 8
val bpp = (colors * bitsPerComponent + 7) / 8
if (decompressedBytes.size % (rowBytes + 1) == 0) {
return applyPngPredictor(decompressedBytes, rowBytes, bpp)
}
}
return decompressedBytes
}
private fun decompressZlib(compressedBytes: ByteArray, offset: Int, length: Int): ByteArray {
ByteArrayInputStream(compressedBytes, offset, length).use { bis ->
InflaterInputStream(bis).use { iis ->
ByteArrayOutputStream().use { bos ->
val buffer = ByteArray(1024)
var len: Int
while (iis.read(buffer).also { len = it } != -1) {
bos.write(buffer, 0, len)
}
return bos.toByteArray()
}
}
}
}
private fun applyPngPredictor(rawData: ByteArray, columns: Int, bpp: Int = 1): ByteArray {
val rowLength = columns + 1
if (columns <= 0 || rawData.size % rowLength != 0) {
return rawData
}
val rows = rawData.size / rowLength
val outData = ByteArray(rows * columns)
for (r in 0 until rows) {
val filterType = rawData[r * rowLength].toInt() and 0xFF
val rowStart = r * rowLength + 1
val outRowStart = r * columns
for (c in 0 until columns) {
val rawVal = rawData[rowStart + c].toInt() and 0xFF
val priorRowVal = if (r > 0) outData[(r - 1) * columns + c].toInt() and 0xFF else 0
val leftVal = if (c >= bpp) outData[outRowStart + c - bpp].toInt() and 0xFF else 0
val topLeftVal =
if (r > 0 && c >= bpp) outData[(r - 1) * columns + c - bpp].toInt() and 0xFF else 0
val predicted = when (filterType) {
0 -> rawVal // None
1 -> (rawVal + leftVal) and 0xFF // Sub
2 -> (rawVal + priorRowVal) and 0xFF // Up
3 -> (rawVal + (leftVal + priorRowVal) / 2) and 0xFF // Average
4 -> (rawVal + paethPredictor(
leftVal,
priorRowVal,
topLeftVal
)) and 0xFF // Paeth
else -> rawVal
}
outData[outRowStart + c] = predicted.toByte()
}
}
return outData
}
private fun paethPredictor(a: Int, b: Int, c: Int): Int {
val p = a + b - c
val pa = kotlin.math.abs(p - a)
val pb = kotlin.math.abs(p - b)
val pc = kotlin.math.abs(p - c)
return if (pa <= pb && pa <= pc) a else if (pb <= pc) b else c
}
可以见到,applyPngPredictor属于固定算法应用,十分死板也没什么好说的,让AI代劳实在非常美好,这大概帮我们节省了一下午的生命。:)
以上,就是整个环节里Vibe coding最为顺利的部分了。接下来,无论怎么试,即使我有引入PdfBox库供Gemini参考,它也无法按要求写出解析器来。:( 没办法,只能自己动手了。
设计思维
即便假设AI已经完成超前发展,能够达到顶级人类工程师的水平,有几个地方仍然可能会是绕不开的弱项:
- 技术选项
- 工程架构
- 软件退化
万丈高楼平地起,一个项目适用怎样的技术选型是个难以回答的问题,如果你没有即有项目开发经验的话,基本上不可能第一次就找到正确答案,而不合适的选项产生的影响将会十分深远,甚至最终导致被竞争对手打败。
工程架构,是在打好地基之后构建建筑物的骨架。与之类似,它也不是抽象的堆砌设计模式就能解决的问题,不合理的架构会在某些时候导致某些功能无法实现,如果硬要实现则会引入一次性的胶水代码,这可能会非常危险。
软件不会磨损,只会退化。优质的项目,依靠优秀的人类工程师进行严苛的代码审查来抵御这种退化。AI的高速代码生成特性,注定了无法沿用这一机制,终究只可能是AI写、AI审。Kotlin官方的数据统计报告指出,同样的软件在使用Kotlin重构之后,代码更少,Bug更少,性能更强。我的实际体验认同这个观点,如同前面的章节所述,AI写的代码既缺乏整体性,也很可能在局部上并不最优。累赘、次优、隐患的累积,在经过了海量代码的协同之后,可能会造成难以承受的退化效应。而由于不属于Bug,这种退化的因子,想象不到任何现有技术可以检测。
接下来,以我们现在Pdf解析器的设计为例,讲述一点AI做不好所以需要我们自己动手的部分。
完整的Pdf解析无疑是个浩大的工程,如果你研读过PdfBox或者Pdfium,应该会明白虽然似乎难度不大,但是相当的劳心费力。
好在,我们现在仅仅需要完成一个小小的子集。
Pdf是个文本文件与二进制文件的结合。其中的文本部分采用ASC编码。二进制部分可能有经过(多次)压缩也可能没有。它姑且采用了面向对象的方法,让文档中的内容分对象存储。它的文档结构是一棵树,没有限制所以每一层的节点可以数量任意,也就是说要达到最终的页面叶子节点,并取得正确的顺序,需要采用深度优先搜索(DFS)------如果你听不懂我在说什么,那么你需要如前所述去学习一点基础算法。
PDF中的所有基本素材的寻址方法,也就是前述被抽象为对象的东西,被汇集在一张(或多张)表中。旧版本中是交叉引用表,新版本中是交叉引用流。
交叉引用表样例:
shell
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000120 00000 n
0000000179 00000 n
0000000322 00000 n
trailer
<<
/Size 6
/Root 1 0 R
>>
startxref
408
%%EOF
交叉引用流样例:
css
15 0 obj
<<
/Type /XRef
/Size 6
/W [ 1 4 2 ]
/Index [ 0 6 ]
/Root 1 0 R
/Info 2 0 R
/Length 42
>>
stream
\x00\x00\x00\x00\x00\x00\x00
\x01\x00\x00\x00\x0f\x00\x00
\x01\x00\x00\x00\x4a\x00\x00
\x01\x00\x00\x00\x7c\x00\x00
\x02\x00\x00\x00\x0b\x00\x01
\x02\x00\x00\x00\x0b\x00\x02
endstream
endobj
十分恶心的是,如果一个文档有经过增量更新(Pdf的更新始终的增量更新,显然,全量更新才是更符合现代技术的选择),那么这个文档将有可能具有多段的交叉引用表、交叉引用流,甚至可能同时并存 交叉引用表、交叉引用流,最终我们需要根据他们来构建出可以查找到所有对象的查找表。如前所述,构造方法是采用DFS。
交叉引用表,与交叉引用流中存储的虽然都是对象的寻址方法,但是差别却很大。
对交叉引用表:
erlang
xref
0 6 % 第一个数字代表起始对象编号;第二个数字代表存了几个对象
0000000000 65535 f % 第一个数字为对象起始地址,整个文件中的字节偏移量;第二个数字为代数,说明该对象更新了几次;第三个字母, f表示未使用, n表示使用中
0000000015 00000 n % 第1号对象,起始地址在字节偏移量15的位置,没有经过更新,使用中的对象
............
对交叉引用流:
css
15 0 obj % 对象的寻址方法被储存在经过zlib压缩的流对象中,也就是被下方stream endstream包住的字节。
<<
/Type /XRef
/Size 6
/W [ 1 4 2 ] % 指明寻址方法中,三个字段分别占据的字节宽度。
/Index [ 0 6 ]
/Root 1 0 R
/Info 2 0 R
/Length 42
>>
stream
\x00\x00\x00\x00\x00\x00\x00
\x01\x00\x00\x00\x0f\x00\x00
............
endstream
// 根据 /W [w0 w1 w2] 的定义,解压后的 stream 是一个连续的字节数组。每条记录由 Field1 + Field2 + Field3 组成,Field1(类型字段 $Type$)决定了后续两个字段的含义
// Type (Field 1)含义 Field 2 含义 Field 3 含义
// 0 未使用的空闲对象 (Free Object) 下一个空闲对象的编号 代数 (Generation Number)
// 1 普通未压缩对象 (Uncompressed Object) 对象在文件中的字节偏移量 (Offset) 代数 (Generation Number, 通常为 0)
// 2 对象流中的对象 (Compressed Object) 包含该对象的对象流 (Object Stream)编号 该对象在对象流中的索引下标 (Index)
你可以看到,交叉引用流中的寻址方法,比交叉引用表要复杂不少。简单地,我们可以针对二者设计数据类型XrefItem与CRSItem:
kotlin
/**
* 交叉引用表中的条目。
* 固定为20字节
* 没有管是否有效条目(也就是第18个字节为'n'或'f')。反正总要扫描,减少一重判定,多耗点空间完全值得
* @param objNumber 对象号,新加的使容易处理
* @param offset 偏移量,相对于文件头的字节偏移量
* @param generationNumber 似乎没用?
* */
data class XrefItem(
val objNumber: Int,
val offset: Long,
val generationNumber: Int,
)
enum class CRSItemField1Type(val value: Int) {
FREE(0), UNCOMPRESSED(1), COMPRESSED(2);
companion object {
fun fromValue(v: Int): CRSItemField1Type {
return entries.find { it.value == v }
?: throw IllegalArgumentException("Unknown value: $v")
}
}
}
/**
* Cross-reference Stream item
* */
data class CRSItem(
val objNumber: Int,
val type: CRSItemField1Type,
val field2: Long,
val field3: Int,
) {
fun isUncompressedType() = CRSItemField1Type.UNCOMPRESSED == type
fun isCompressedType() = CRSItemField1Type.COMPRESSED == type
}
水平的分水岭在于接下来处理:
- 平庸的工程师,保持两种Item不做任何处理,if else写到海枯石烂
- 普通的工程师,加一个中间抽象层将Item类型减少为1
- 优秀的工程师,虽然没有看到任何文字提示,但能够识别出Pdf的设计者在堆砌这坨屎山的时候,至少还有保持向前兼容性,因此
XrefItem天然可以转化为CRSItem:
kotlin
data class XrefItem(
val objNumber: Int,
val offset: Long,
val generationNumber: Int,
) {
fun toCRSItem(): CRSItem {
return CRSItem(objNumber, CRSItemField1Type.UNCOMPRESSED, offset, generationNumber)
}
}
这只是一个极端浅显的设计例子,但你应该能明白我在说些什么,以及为什么这种能力AI无法取代。
本节到这里就进入尾声了,感兴趣的读者可以试着去实现一下可以适应网络在线阅读、限定为解析图片的PDF解析器。AI做不到/好,所以我全程手写了,代码量实际上并没有想象中多,基于之前章节提过的接口IRandomAccessFile,在去除上述的代码之后,带注释我总共只写了2000行不到。