目录
[📂 前言](#📂 前言)
[🔱 1. 相册同步架构](#🔱 1. 相册同步架构)
[1.1 数据源与编码](#1.1 数据源与编码)
[1.2 通道分工](#1.2 通道分工)
[⚡ 2. 相册同步时序图](#⚡ 2. 相册同步时序图)
[💠 3. 连接方案:Phone GO](#💠 3. 连接方案:Phone GO)
[3.1 Phone GO 方案特点](#3.1 Phone GO 方案特点)
[3.2 入口与触发](#3.2 入口与触发)
[⚛️ 4. 相册同步传输实现](#⚛️ 4. 相册同步传输实现)
[4.1 状态机](#4.1 状态机)
[4.2 会话建立](#4.2 会话建立)
[4.3 文件拉取流程](#4.3 文件拉取流程)
[4.4 关键代码](#4.4 关键代码)
[4.5 .avpk 容器格式解析](#4.5 .avpk 容器格式解析)
[4.6 视频转码 MP4](#4.6 视频转码 MP4)
[4.7 存储路径](#4.7 存储路径)
[4.8 异常处理](#4.8 异常处理)
[📂 小结](#📂 小结)
📂 前言
上一篇《AR 眼镜之-直播-技术方案》聊的是实时音视频推流,核心场景是"眼镜采集---手机中转---云端分发",但眼镜还有一个高频日常场景------相册。
当用户把眼镜戴上头,随手拍张照片、录段视频,但是眼镜屏幕小,甚至没屏幕,怎么查看?怎么导到手机里发朋友圈?总不能每次都掏出 Type-C 线吧,所以相册功能的核心诉求很直白:手机打开 App 相册 → 发现眼镜有新照片/视频 → 拉过来存到 sdcard → 直接在手机上查看。
|------|-----------------------|
| 触发方式 | 手机 App 主动查询,有更新才同步 |
| 传输方式 | 采用 P2P 直连,Socket 文件拉取 |
| 落地方式 | 图片直存,视频转码 MP4 后保存 |
| 编码格式 | H.265 + AAC |
🔱 1. 相册同步架构

1.1 数据源与编码
|----------|--------------|---------------|
| 数据类型 | 编码格式 | 存储形式 |
| 照片 | JPEG | .jpg 独立文件 |
| 视频画面 | H.265 (HEVC) | 封装在 .avpk 容器内 |
| 视频音频 | AAC | 封装在 .avpk 容器内 |
1.2 通道分工
|-----------|------------------------|
| 通道 | 职责 |
| 蓝牙 (BT) | 信令控制、相册查询、WiFi 唤醒、状态通知 |
| Wi-Fi P2P | 文件传输(高带宽) |
⚡ 2. 相册同步时序图

💠 3. 连接方案:Phone GO

3.1 Phone GO 方案特点
手机作为 GO(Group Owner)创建 P2P 网络,眼镜作为 GC 主动加入。
|---------------|-------------------------|
| 维度 | 说明 |
| GO 角色 | 手机 |
| 建网方式 | 手机 createGroup(),等待眼镜连接 |
| discoverPeers | 否 |
| Wi-Fi 参数来源 | 手机生成,蓝牙下发 |
| 建连速度 | 快(无扫描) |
| 稳定性 | 高 |
| 适用场景 | 手机作为数据中心 |
3.2 入口与触发
1)CmdV2GetCameraAlbumReq:手机 App 进入相册页面检查蓝牙状态,已连接则查询相册更新
private fun checkConnectState() {
val isConnect = DeviceManager.getInstance().isConnected()
if (!isConnect) {
state = State.BLUETOOTH_CONNECT
} else {
if (FileTransmissionTask.getInstance().transferring) {
state = State.SYNCING
} else {
DeviceManager.getInstance().sendCmd(CmdV2GetCameraAlbumReq())
}
}
}
2)CmdV2GetCameraAlbumRsp:眼镜回传照片/视频数量,有更新则切到 SYNC 状态并展示同步按钮
if (cmd.bizType == BizType.BIZ_CAMERA_GROUP && cmd.cmdV2Type == MBCmdV2Type.CMD_CAMERA_GET_ALBUM.toInt()) {
val cameraInfo = (cmd as CmdV2GetCameraAlbumRsp).value
val total = if (cameraInfo == null) {
logI(TAG, "cameraInfo == null")
0
} else {
cameraInfo.image + cameraInfo.video
}
logI(TAG, "CmdRspMsg sync total = = $total synchronizedQuantity = ${mViewModel.synchronizedQuantity}")
if (state == State.SYNCING) {
mViewModel.totalSyncQuantity = total + mViewModel.synchronizedQuantity
} else {
mViewModel.totalSyncQuantity = total
mViewModel.synchronizedQuantity = 0
if ((state == State.DEFAULT || state == State.SYNC_SUCCESS) && total != 0) {
state = State.SYNC
} else if (state == State.SYNC) {
syncView?.findViewById<TextView>(R.id.syncQuantity)?.text = this.resources.getString(R.string.gallery_sync_file, mViewModel.totalSyncQuantity.toString())
}
}
updateSyncingUI()
}
3)点击同步 :前置检查通过后,蓝牙 CmdV2WifiONReq 唤醒 WiFi,同时设置并监听 EasyNet 状态
// 前置检查
private fun requirementJudgeChain(): Boolean {
if (FileUtils.isExternalStorageLow()) return false // 存储不足
if (!hasFilePermission()) { requestFilePermission(); return false }
if (!wifiManager.isWifiEnabled) { state = State.WIFI_CONNECT; return false }
return true
}
// 蓝牙 sendWifiOn 唤醒 WiFi
DeviceManager.getInstance().sendCmd(
CmdV2WifiONReq(
BizType.CMD_CONNECT,
cmdType,
null,
wifiType,
support5g,
channel,
ssid,
password
)
)
// 设置并监听 EasyNet 状态
EasyNetManager.getInstance().addEasyNetConnectState(this)
EasyNetManager.getInstance().addEasyNetMessageArrive(this)
EasyNetManager.getInstance().addWifiP2pConnectState(this)
EasyNetManager.getInstance().addMessageOperationComplete(this)
EasyNetManager.getInstance().addEasynetFragmentTracker(this)
EasyNetManager.getInstance().addFilePullProgress(filePullProgress)
DeviceManager.getInstance().addBTCmdReqListener(this)
注: 更多 P2P 细节可参考上一篇《AR 眼镜之-直播-技术方案》第三章节的 Wi-Fi P2P 连接方案讨论部分。
⚛️ 4. 相册同步传输实现
4.1 状态机
|-------------------|-------------|
| 状态 | 说明 |
| DEFAULT | 默认空闲 |
| BLUETOOTH_CONNECT | 蓝牙未连接 |
| WIFI_CONNECT | WiFi 未开启 |
| CONNECTING | 正在建立 P2P |
| CONNECT_FAILED | 建连失败 |
| SYNC | 有新文件,等待用户同步 |
| SYNCING | 正在拉取文件 |
| SYNC_SUCCESS | 同步完成 |
4.2 会话建立
EasyNet 会话激活后,启动同步请求 AlbumSyncRequest 、CmdV2CameraAlbumSyncStatusReq
override fun onSessionActive(session: EasynetSession) {
if (isSyncPicture) {
LiveEventBus.get<String>(GALLERY_CONNECT_GLASS_SUCCESS).post("")
syncFileList.clear()
currentSyncIndex = 0
currentMessage = AlbumSyncRequest(AlbumType(MediaType.ALL))
EasyNetManager.getInstance().sendMessage(currentMessage as AlbumSyncRequest, 5000)
DeviceManager.getInstance().sendCmd(CmdV2CameraAlbumSyncStatusReq(1))
transferring = true
}
}
4.3 文件拉取流程

4.4 关键代码
1)收到文件列表,逐个拉取
override fun onMessageArrived(msg: Message) {
when (msg.header.opcode) {
Opcode.ALBUM_SYNC -> {
val albumList = (msg as Response).body as AlbumList
albumList.all.forEach {
if (!syncFileList.contains(it.get())) {
syncFileList.add(it.get())
}
}
if (syncFileList.isNotEmpty() && currentSyncIndex < syncFileList.size) {
currentMessage = FilePullRequest(FileUuid(syncFileList[currentSyncIndex]))
EasyNetManager.getInstance().sendMessage(currentMessage as FilePullRequest)
currentSyncIndex++
}
}
}
}
2)分片回调,按文件类型处理
override fun onComplete(session: Int) {
fragmentFileOutputStream?.close()
fragmentFile?.let { f ->
when (fragmentFileType) {
FileType.FT_IMAGE -> {
addPictureBytes(f.readBytes(), fragmentTimestampMs)
f.delete()
}
FileType.FT_VIDEO -> {
if (AvpkParser.isAvpkFile(f)) {
muxerAvpkToMp4(f, fragmentTimestampMs) // 新格式
} else {
VideoDataParse.parse(f.readBytes(), fragmentGroup.toString())
muxerMp4(f.lastModified(), fragmentGroup) // 旧格式
}
f.delete()
}
}
}
// ... 更新 UI
syncNext()
}
3)进度回调
private val filePullProgress = object : EasyNetManager.FilePullProgress {
override fun onOperationProgress(progress: Float) {
val p = progress.coerceIn(0f, 100f)
// 变化小于 1% 不上报,减少 UI 刷新
if (p < 100f && lastPostedPullProgress >= 0f && p - lastPostedPullProgress < 1f) return
mainHandler.post { postFilePullProgress(p) }
}
}
4.5 .avpk 容器格式解析

object AvpkParser {
private const val TAG = "AvpkParser"
private const val ISP_AVPK_MAGIC: Int = 0x4b505641 // 'AVPK' little-endian
private const val ISP_AVPK_VERSION_V1: Int = 1
private const val ISP_AVPK_NAME_MAX: Int = 64
private const val FOOTER_HDR_SIZE = 4 + 2 + 2
private const val ENTRY_SIZE = 1 + 3 + 8 + 8 + ISP_AVPK_NAME_MAX
private const val TRAILER_SIZE = 4 + 4
data class UnpackResult(
val h265File: File,
val aacFile: File
)
fun isAvpkFileName(fileName: String): Boolean {
return fileName.endsWith(".avpk", ignoreCase = true)
}
fun isAvpkFile(file: File): Boolean = isAvpkFileName(file.name)
/**
* 将 .avpk 解包到 [outDir],按 entry [name] 写出;用后缀 `.h265` / `.aac` 区分视频轨与音频轨。
*
* @return 成功时返回各轨临时文件;失败返回 null。
*/
fun unpackToDir(avpkFile: File, outDir: File): UnpackResult? {
if (!avpkFile.isFile || !avpkFile.exists()) {
logE(TAG, "unpackToDir: bad file")
return null
}
if (!outDir.exists() && !outDir.mkdirs()) {
logE(TAG, "unpackToDir: mkdirs failed ${outDir.path}")
return null
}
val size = avpkFile.length()
if (size < TRAILER_SIZE.toLong()) {
logE(TAG, "unpackToDir: file too small")
return null
}
RandomAccessFile(avpkFile, "r").use { raf ->
raf.seek(size - TRAILER_SIZE)
val trBuf = ByteArray(TRAILER_SIZE)
raf.readFully(trBuf, 0, TRAILER_SIZE)
val trBb = ByteBuffer.wrap(trBuf).order(ByteOrder.LITTLE_ENDIAN)
val trMagic = trBb.int
val footerLen = trBb.int.toLong() and 0xffffffffL
logI(TAG, "unpackToDir trMagic=$trMagic footerLen=$footerLen")
if (trMagic != ISP_AVPK_MAGIC) {
logE(TAG, "unpackToDir: bad trailer magic")
return null
}
val footerEnd = size - TRAILER_SIZE
if (footerLen > footerEnd || footerLen < FOOTER_HDR_SIZE) {
logE(TAG, "unpackToDir: bad footer_len=$footerLen")
return null
}
val footerStart = footerEnd - footerLen
if (footerStart < 0L) {
return null
}
raf.seek(footerStart)
val fl = footerLen.toInt()
val footerBytes = ByteArray(fl)
raf.readFully(footerBytes, 0, fl)
val fhBb = ByteBuffer.wrap(footerBytes, 0, FOOTER_HDR_SIZE).order(ByteOrder.LITTLE_ENDIAN)
val fhMagic = fhBb.int
val version = fhBb.short.toInt() and 0xffff
val count = fhBb.short.toInt() and 0xffff
logI(TAG, "unpackToDir fhMagic=$fhMagic ver=$version count=$count")
if (fhMagic != ISP_AVPK_MAGIC || version != ISP_AVPK_VERSION_V1 || count <= 0) {
logE(TAG, "unpackToDir: bad footer hdr magic=$fhMagic ver=$version count=$count")
return null
}
val expectedFooter = FOOTER_HDR_SIZE + count * ENTRY_SIZE
if (fl != expectedFooter) {
logE(TAG, "unpackToDir: footer size mismatch exp=$expectedFooter act=$fl")
return null
}
var h265: File? = null
var aac: File? = null
var parseOff = FOOTER_HDR_SIZE
repeat(count) {
if (parseOff + ENTRY_SIZE > footerBytes.size) {
logE(TAG, "unpackToDir: footer entry overflow")
return null
}
// 与磁盘一致:entry 内 offset 起始于 parseOff+4,共 8+8 字节小端
val rawOffSize16 = footerBytes.copyOfRange(parseOff + 4, parseOff + 4 + 16)
logI(
TAG,
"unpackToDir entry $it offset+size raw16(hex)=${hexBytes(rawOffSize16)} " +
"absFileOffset=${footerStart + parseOff + 4}..${footerStart + parseOff + 19} " +
"(LE uint64,uint64)"
)
val entryOff = readUInt64LE(footerBytes, parseOff + 4)
val entrySize = readUInt64LE(footerBytes, parseOff + 12)
val nameBytes = footerBytes.copyOfRange(parseOff + 20, parseOff + 20 + ISP_AVPK_NAME_MAX)
val nameLen = nameBytes.indexOf(0).let { if (it < 0) nameBytes.size else it }
val rawName = String(nameBytes, 0, nameLen, StandardCharsets.UTF_8).trim()
val safeName = basenameSafe(rawName).ifEmpty { "unknown_$it.bin" }
logI(TAG, "unpackToDir entry $it off=$entryOff size=$entrySize name=$rawName safe=$safeName")
parseOff += ENTRY_SIZE
if (entryOff + entrySize > footerStart) {
logE(TAG, "unpackToDir: entry range overflow $entryOff+$entrySize > $footerStart")
return null
}
val outFile = File(outDir, safeName)
when {
safeName.endsWith(".h265", ignoreCase = true) -> {
if (h265 != null) {
logE(TAG, "unpackToDir: multiple .h265")
return null
}
h265 = outFile
}
safeName.endsWith(".aac", ignoreCase = true) -> {
if (aac != null) {
logE(TAG, "unpackToDir: multiple .aac")
return null
}
aac = outFile
}
}
raf.seek(entryOff)
FileOutputStream(outFile).use { out ->
var remain = entrySize
val buf = ByteArray(256 * 1024)
while (remain > 0) {
val n = minOf(buf.size.toLong(), remain).toInt()
val read = raf.read(buf, 0, n)
if (read <= 0) {
logE(TAG, "unpackToDir: read error")
return null
}
out.write(buf, 0, read)
remain -= read.toLong()
}
}
}
if (h265 == null || aac == null) {
logE(TAG, "unpackToDir: missing h265 or aac h265=${h265 != null} aac=${aac != null}")
h265?.delete()
aac?.delete()
return null
}
logI(TAG, "unpackToDir ok h265=${h265?.path} aac=${aac?.path}")
return UnpackResult(h265!!, aac!!)
}
}
private fun basenameSafe(name: String): String {
val n = name.trim()
if (n.isEmpty()) return ""
val s = n.replace('\\', '/')
return s.substringAfterLast('/')
}
private fun hexBytes(data: ByteArray): String {
return data.joinToString(" ") { b -> "%02X".format(b.toInt() and 0xff) }
}
private fun readUInt64LE(buf: ByteArray, index: Int): Long {
var v = 0L
for (i in 0 until 8) {
v = v or ((buf[index + i].toLong() and 0xFFL) shl (8 * i))
}
return v
}
}
4.6 视频转码 MP4
fun h265Aac2mp4(h265Path: String, aacPath: String, mp4Path: String): Int {
val command = "-f hevc -i $h265Path -i $aacPath -c:v copy -c:a copy $mp4Path"
val executeCode = FFmpeg.execute(command)
return executeCode
}
fun h265ToMp4(inputH265: String, outputMp4: String): Int {
val command = String.format("-f hevc -i %s -c:v copy -an %s", inputH265, outputMp4)
val executeCode = FFmpeg.execute(command)
return executeCode
}
完整转码逻辑如下:
private fun muxerAvpkToMp4(avpkFile: File, timeMs: Long) {
val unpacked = AvpkParser.unpackToDir(avpkFile, outDir) ?: return
val mp4File = File(Constant.picturePath, GalleryMediaSaveUtil.generateFileName("mp4"))
val hasValidAudio = unpacked.aacFile.exists() && unpacked.aacFile.length() > 0
val mp4Code = if (hasValidAudio) {
FFmpegManager.getInstance().h265Aac2mp4(
unpacked.h265File.path, unpacked.aacFile.path, mp4File.path
)
} else {
// 音频为空,只封装视频轨
FFmpegManager.getInstance().h265ToMp4(unpacked.h265File.path, mp4File.path)
}
if (mp4Code == 0) {
GalleryMediaSaveUtil.saveExistingVideo(mp4File, timeMs)
LiveEventBus.get<String>(GALLERY_DATA_ADD).post(result.appPath)
}
// 清理临时文件
unpacked.h265File.delete()
unpacked.aacFile.delete()
}
4.7 存储路径
|-------------------|-----------------------|----------------------|
| 常量 | 路径 | 用途 |
| rawPath | .../gallery/raw | 原始文件(.avpk / .h264) |
| tempPath | .../gallery/temp | 转码中间产物(.h265 / .aac) |
| picturePath | .../gallery/picture | 最终 MP4 保存 |
| systemPicturePath | 系统相册目录 | MediaStore 注册 |
4.8 异常处理
|--------|---------------------|
| 异常场景 | 处理方式 |
| 文件拉取失败 | Toast 提示,跳过当前,继续下一个 |
| 列表拉取失败 | 结束同步,通知眼镜 |
| 分片传输异常 | 删除临时文件,跳过当前 |
| 存储空间不足 | 终止同步,提示用户 |
| 蓝牙断开 | 关闭 WiFi,终止同步 |
📂 小结
|------|-------------------------------------|
| 连接方案 | Phone GO(手机做 GO,眼镜做 GC) |
| 传输协议 | TCP Socket(EasyNet 封装) |
| 触发方式 | 手机查询 → 有更新 → 用户点击同步 |
| 拉取方式 | 串行逐文件拉取(非并发) |
| 图片处理 | 直接存入 sdcard |
| 视频处理 | .avpk 解包 → H265+AAC → FFmpeg 封装 MP4 |
| 编码格式 | H.265 + AAC |
| 落地位置 | 系统相册(MediaStore 注册) |
.avpk 轻量容器的思路是让眼镜端只做简单数据拼接,复杂封装交给手机端 FFmpeg,这是 BES 2800 平台资源受限下的合理工程取舍。
另外,由于本人能力有限,如有错误,敬请批评指正,谢谢。