【代码札记】从零开始的安卓应用开发 - P4
哈哈，没想到吧，竟然还有续集！其实我也没想到。本文介绍我如何为我的安卓APP追加扫码功能。背景本来上一篇就完结了，APP也写完了，目前也没有需要追加的功能了。但是在实际使用中发现，使用Google的条码扫描器，经常会出现Google Play Service自废武功的情况。为了解决这个情况我真是新买了一台三星S23+，但情况还是没有改善。这部分的故事是这样的。我原本使用的是红米K40 Pro，使用了MIUI EU版本的系统，这个是一群票友基于中国版MIUI固件，缝合了谷歌全家桶，并删减了一些臃肿的小米框架得到的第三方ROM。在此基础上我还进行了Root，并安装了一些Magisk模块，例如存储空间隔离等。总之是一个非常客制化的一个环境。在进行长期测试时我发现Google的条码扫描服务经常在一段时间之后变得不可用。具体表现就是抛出com.google.mlkit.common.MlKitException: Failed to scan code这样的异常。经过一番搜索，我发现清空Google Play Service的数据能够缓解这个问题——因为扫描服务是按需下载的，清空数据之后要重新下载扫描服务，当然就可以恢复正常。但是过一段时间（一天）又会抛出异常，还得再清空，就很烦。于是我开始搜索详细原因。在Google官方的一个Issue tracker上我看到有一个人说关于Google Play Service版本的问题，我查了一下，我这个好像不是最新版，于是就想办法升级。但是升级过程中我发现Play商店总是失败——并不是那么明显的失败，而是点了升级按钮之后，短暂读条，然后立刻又变回去了。我尝试过单独下载最新版的Google Play Service的APK，手动安装，但是系统提示我签名不一样。我寻思，这不对啊，都是谷歌的APK，怎么会签名不对呢？于是我又回到Google Play商店开始点升级按钮。按照以往的经验，有时候Play商店会犯傻，因为在国内需要使用代理访问，所以Play商店会误判无网络，从而导致安装失败。于是我开始不停的点升级按钮，然后到了某一刻，开始读条之后没有退出，我以为是Play商店想明白了。过了一段时间（指看了一会儿YouTube），我也没有检查商店页面有没有升级好（默认情况下在Play商店是搜不到Google Play Service的），就直接回到我的APP调用了扫码服务。结果这一下手机就卡死了，因为我用的是全面屏手势导航，所以界面也不响应我的手势。我想，那重启一下应该就没问题了吧。结果这一个电源键按下去，它就再也不启动了。具体表现是就是开始会过logo动画，过完动画之后就卡死在这里了，不进入桌面。但是指纹传感器会有震动反馈。然而屏幕不响应触控操作，重启之后需要键盘才能解锁。理论上我可以尝试插入一个usb键盘，但我觉得希望不大。所以就直接刷机了。四清了四五次，这手机才重新见到桌面。关于这个过程，我猜测有如下几种可能：票友缝合的谷歌框架有问题，因为刷机之后我查询Google Play Service的安装时间，它反馈是2009年，相比之下，三星系统的安装时间显示的是2023年；与此同时，我从网络上下载的APK提示签名不符，这其中肯定是有修改系统在Root之后影响了Play商店的更新，导致更新时把系统搞坏了我安装的Magisk模块干扰了Play商店的更新，但具体是什么模块不清楚总之我决定不再使用票友制作的ROM了。一方面是小米的系统太臃肿了，基于中国版ROM，但又缺乏中国版ROM的特性（例如公交卡、钱包等），有Google框架，但没有认证级别（原版MIUI据说有，但是很明显，经过EU修改之后就没了）。总之就是不如原生的体验。所以左思右想，换了三星S23+，关于这个设备，在我使用一段时间之后将会写文章和大家分享感受。API设计总之，既然没有办法绕开这个Bug，就只能自己实现扫码功能了。在开始之前，我们先看看谷歌这个库的API设计：val scanner = GmsBarcodeScanning.getClient( this, GmsBarcodeScannerOptions.Builder() .setBarcodeFormats( Barcode.FORMAT_DATA_MATRIX, Barcode.FORMAT_CODE_128 ).build() ) scanner.startScan() .addOnSuccessListener { barcode -> // use the barcode } .addOnCanceledListener { showToast("Scan canceled") } .addOnFailureListener { e -> showToast("Scan failed: ${e.message}") }首先需要创建一个Client，这个Client在创建的时机上并没有要求，你可以随用随创建，也可以在一开始创建，创建完保存在ViewModel里供以后调用。创建完Client之后就可以发起扫码请求了。发起请求后需要添加三个回调。成功的回调将会在扫码成功后调用，参数是扫出来的二维码。取消则是用户通过返回按钮退出了扫码页面，而失败则很明显，是捕获异常用的。很明显，Google应该是用了什么高深的生命周期之类的东西。对于一般的拉起Activity并获取返回数据的话，需要使用registerForActivityResult并配合ActivityResultContracts.StartActivityForResult()来使用。同时安卓对于注册回调的时机也有要求，并不能做到像Google那样随用随创建。所以没办法只简单封装registerForActivityResult来进行调用。UI总之，在想明白API怎么设计之前，不如先从简单一些的UI下手。扫码界面的UI非常简单：最底层是相机预览上层是两个按钮，一个控制闪光灯，一个控制缩放而谷歌官方推荐使用CameraX这个库配合ML Kit使用，可以避免许多样板代码。CameraX主要提供两种控制方式，一种是CameraController，一种是CameraProvider。前者旨在使用少量代码就能提供完整的相机功能；而后者旨在提供较多的控制选项。很明显，前者更适合扫码——你总不会在预期让用户在扫码的时候手动调整对焦和曝光吧？CameraController提供了一些自动控制的功能，例如在Preview上点击以进行对焦，还有双指缩放来控制相机的缩放等。配合PreviewView和MlKitAnalyzer，我们可以完成预览和扫码这个需求。UI部分还是和前几篇一样使用Compose。最底层使用了AndroidView来提供不兼容Compose的PreviewView的对象，随后使用Column和Row在屏幕下方绘制按钮，用来控制闪关灯和缩放。在创建UI之前，我们需要先处理好相机的部分： // setup camera controller val cameraController = LifecycleCameraController(this) cameraController.bindToLifecycle(this) cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA cameraController.isTapToFocusEnabled = true cameraController.isPinchToZoomEnabled = true // setup camera preview val previewView = PreviewView(this) previewView.controller = cameraController previewView.scaleType = PreviewView.ScaleType.FILL_CENTER这里我们需要在Compose外面创建好View对象，因为这些对象并不需要每次都重新创建。首先是CameraController，这里允许点击对焦和双指缩放，并要求使用后置摄像头进行扫码。然后创建一个PreviewView来展示相机的预览。这里的缩放默认为FILL_CENTER，也就是让相机画面填满屏幕并居中。这样一来相机画面的周围部分就会被裁剪，如果希望显示完整，那么可以之后通过按钮切换。这里我们刚刚设置好了预览的部分，接下来说说图像分析，也就是扫码的部分： // setup the scanner cameraController.setImageAnalysisAnalyzer( ContextCompat.getMainExecutor(this), MlKitAnalyzer( listOf(barcodeScanner), COORDINATE_SYSTEM_VIEW_REFERENCED, ContextCompat.getMainExecutor(this) ) { result -> val centerX = previewView.width / 2 val centerY = previewView.height / 2 // select the center one val list = result.getValue(barcodeScanner)?.sortedBy { val dx = centerX - (it.boundingBox?.centerX() ?: 0) val dy = centerY - (it.boundingBox?.centerY() ?: 0) dx * dx + dy * dy } ?: emptyList() val barcode = list.firstNotNullOfOrNull { it.rawValue } if (barcode != null) { setResult(RESULT_OK, Intent().apply { putExtra("barcode", barcode) }) finish() } } )这里使用了MlKitAnalyzer来提供CameraX需要的图像分析器对象。如果你看ML Kit条码扫描部分的文档，你会发现Google并没有告诉你这种使用方法，而是手动通过传递参数来实现的。而MlKitAnalyzer可以省略这部分，自动从CameraX获取需要的信息，并且它能保证你获取到的数据和Preview的坐标是相对应的。与Google的条码扫描器不同，ML Kit会返回一组条码，但我的应用只需要一个条码。因此我这里采用的策略是选择与屏幕中心距离最近的一个。由于条码的坐标和Preview的坐标是相对应的，因此中心坐标就可以通过previewView的宽和高计算得出，进而实现选择。如果最终选择出来的条码不为空，那么获取它的字符串值传递给调用者就好了。解决了页面逻辑，下面简单说说渲染： AndroidView( factory = { previewView }, modifier = Modifier.fillMaxSize(), ) var torchStatus by remember { mutableStateOf(false) } var fillStatus by remember { mutableStateOf(true) } Column( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize() ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth() ) { if (torchStatus) { IconButton(onClick = { cameraController.enableTorch(false) torchStatus = false }) { Icon( imageVector = Icons.Default.FlashOn, contentDescription = "turn off torch" ) } } else { IconButton(onClick = { cameraController.enableTorch(true) torchStatus = true }) { Icon( imageVector = Icons.Default.FlashOff, contentDescription = "turn on torch" ) } } if (fillStatus) { IconButton(onClick = { previewView.scaleType = PreviewView.ScaleType.FIT_CENTER fillStatus = false }) { Icon( imageVector = Icons.Default.ZoomInMap, contentDescription = "switch to fill" ) } } else { IconButton(onClick = { previewView.scaleType = PreviewView.ScaleType.FILL_CENTER fillStatus = true }) { Icon( imageVector = Icons.Default.ZoomOutMap, contentDescription = "switch to fit" ) } } } }这里关于预览就不多说了，主要说说按钮。按钮部分，每个按钮都有两种状态。对于闪光灯来说，图标总是表示闪光灯当前的状态，并在点击后切换到相反状态。有一些APP则是表示闪光灯在按下之后的状态，并在点击后切换到这个状态。我觉得这个就看个人吧，我更喜欢展示当前状态一些。另一个是控制预览缩放。默认情况下是填充屏幕，而此时的图标是缩小，点击后就会变成适应屏幕，也就是通过缩放将画面呈现在屏幕中；而在适应屏幕时，图标变成放大，表示通过缩放让预览充满屏幕。调用方式因为我在安卓方面的经验不够丰富，了解的也不多。作为个人项目来说，我的主要目标是能用。所以我设计了一种比较简单的交互方式。因为要处理权限问题，所以我之前引入了一个VazanActivity，通过子类调用super.onCreate来帮助子类请求权限。因此对于registerForActivityResult的调用也可以放在这里。但问题在于，这个调用注册的回调是固定的，没办法在运行时修改。不过这也好办，搞一个队列就是了： private val scannerCallbackQueue = ConcurrentLinkedQueue<Pair<(String) -> Unit, () -> Unit>>() private val scannerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { val (onSuccess, onFailed) = scannerCallbackQueue.poll() ?: error("Missing callback") val barcode = it.data?.getStringExtra("barcode") if (it.resultCode == RESULT_OK && barcode != null) onSuccess(barcode) else onFailed() }这里我采用了简化的设计，回调一共就两种，一种是成功，一种是取消或者失败，总之前者表示有结果，后者表示没有结果。调用时我们先把回调放入队列，然后拉起Activity进行扫码。扫码的Activity退出之后就会执行到我们注册的回调。如果返回值为成功，并且有条码结果，则调用成功的回调；如果没有，则调用失败的回调。 protected fun scanBarcode(onSuccess: (String) -> Unit, onFailed: () -> Unit) = synchronized(scannerCallbackQueue) { scannerCallbackQueue.offer(onSuccess to onFailed) scannerLauncher.launch(intent(ScannerActivity::class)) }这里我对扫码操作的调用加了锁。严格来说不加锁也没问题，无论扫码的Activity怎么乱序启动，最终处理回调的时候保证先进先出就可以了。如果更严谨一点的话，可以使用Object的await和notifyAll来确保队列中等待处理的回调至多只有一个。很遗憾，我没有那么严谨，我觉得加个锁就差不多了。至于迁移，也很好办。由于不需要创建扫码Client的，因此可以直接删掉这部分代码，对于扫码的调用，可以直接换成scanBarcode。在回调方面，成功的回调几乎不变，而失败的回调作为取消处理，不考虑异常捕捉的回调。至此，我觉得这一系列应该真的完结了吧。-全文完-
【代码札记】从零开始的安卓应用开发 - P3
本文介绍APP的UI部分。前面铺垫了那么多文章，终于到UI部分了。Material 3在开始之前，我想先说说看Google的这个设计语言。因为我也不是专业搞前端的，所以在这方面可能说的有些错误，一切以实际为准。简单地说Material 3提供了一系列风格统一的UI组件，并针对颜色、字体、形状等要素提供了较为一致的指导。然而在使用Android studio创建APP时，默认引用的是Material 2，如果想使用Material 3的话，好像还需要折腾一下依赖这些东西。关于Material 3的优点，我认为最大的就是他们的色彩系统。官方有一个调色器，可以根据你上传的壁纸自动生成一系列看起来很融洽的色彩，比我手动挑的好看多了。除了这一点之外，在安卓13上还有系统级别的动态配色：也就是说你的APP可以根据用户设定的壁纸，自动采用同样颜色的配色，使得整个系统看起来更加统一（我想特立独行的流氓APP，哦不是，我是说国产APP，他们应该不会采纳这样的方法）。我这里就使用了官方的调色器生成了一套主题，但看起来好像并没有使用动态主题，所以还需要手动改一下：@Composable fun VazanTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val useDynamicColors = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val colors = when { useDynamicColors && darkTheme -> dynamicDarkColorScheme(LocalContext.current) useDynamicColors && !darkTheme -> dynamicLightColorScheme(LocalContext.current) darkTheme -> DarkColors else -> LightColors } MaterialTheme( colorScheme = colors, shapes = Shapes, typography = Typography, content = content ) }由于动态配色是安卓13才有的，所以需要判断一下。这里我们的逻辑是：如果有动态配色则优先使用动态配色没有则使用App自己的配色至于字体、形状这些，我并没有什么自定义的，就自带的挺好。权限然后再说说看权限。有些权限不需要向用户申请，比如说联网权限。但是有些就需要申请，而用户有可能会拒绝。虽然谷歌建议APP应该允许用户拒绝授予权限，并在这种情况下尽力提供服务，但是对于这个APP来说，像是蓝牙、相机这种对于功能来说很必须的权限，拒绝了就没法使用了。例如蓝牙权限，既然要通过蓝牙调用打印机打印标签，你不给我蓝牙权限，我怎么和打印机通讯呢？基于这种观察，我设计了一种以Activity为单位的权限申请机制。总地来说权限在进入需要权限的Activity之后才申请，并且申请失败直接退出这个Activity。具体做法就是继承ComponentActivity，在里面实现申请权限的逻辑，然后再由其他Activity继承这个抽象的Activity：@AndroidEntryPoint abstract class VazanActivity : ComponentActivity() { protected abstract val permissionExplanation: Map<String, String> private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> permissions.forEach { (permission, isGranted) -> if (!isGranted) { AlertDialog.Builder(this) .setTitle("Failed to grant permission") .setMessage( "Permission: $permission is not granted.\n" + "This permission is required for ${permissionExplanation[permission]}." ) .setCancelable(false) .setNeutralButton("Fine") { dialog: DialogInterface, _: Int -> dialog.dismiss() finish() } .create() .show() } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ensurePermissions(permissionExplanation.keys.toList()) } private fun ensurePermissions(permissions: List<String>) { val array = permissions .filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } .onEach { require(permissionExplanation.containsKey(it)) { "Unexplainable permission $it" } } .toTypedArray() if (array.isNotEmpty()) requestPermissionLauncher.launch(array) } }permissionExplanation是一个Map，它的键是安卓权限，它的值是一个描述权限用途的字符串。当用户没有授予必要的权限时，向用户说明申请的权限用作何种用途，最后调用finish()退出当前Activity。UI组件原本想说说看各个UI页面的设计，但是后来想了想，这部分大多是布局代码，也没什么好说的。但在构造这部分组件的时候确实做了一些自定义的工作。这里主要针对其中使用到的不常见操作和自定义的部分做出说明。主界面主界面作为功能选单，用户进入到APP之后可以直接选取需要的功能并跳转到对应的页面。但是直接丢一个列表又好像不好看，幸好无意中发现了这个叫做LazyVerticalGrid的组件。这个组件与LazyColumn差不多，能够按需渲染一个列表，并提供水平或竖直滚动的功能。而这个Grid，一看就知道是网格了。这个东西用起来有点像是手机的桌面：可以想象每一个Grid就是一个图标。这些图标从左上角开始按行排列，如果一行排满了，就自动换到下一行。只需要给出每一个图标需要的最小宽度就可以动态地根据屏幕宽度自动排列。对于罗列功能的菜单来说，这个组件再合适不过了。但是这个组件并不能随便用：LazyVerticalGrid只提供了这样一种布局。至于里面每一个元素长什么样子，有什么功能，还得我们自己来写。而且这些元素在外观上具有一定的相似度，但功能又各不相同。所以最好的办法就是把里面这个元素抽象成一个Composable函数。这里我用了Card来充当按钮，而Card内则有一个图标和一行文字，用来描述相应的功能：@Composable fun GridMenuItem( icon: ImageVector, text: String, onClick: Context.() -> Unit, ) { val context = LocalContext.current Box( modifier = Modifier.padding(10.dp).aspectRatio(1f), contentAlignment = Alignment.Center ) { Card( onClick = { onClick(context) }, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer ), modifier = Modifier.fillMaxSize(), ) { Spacer(modifier = Modifier.weight(0.07f)) Box( modifier = Modifier.fillMaxWidth().weight(0.5f), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = text, modifier = Modifier.size(150.dp) ) } Box( modifier = Modifier.fillMaxWidth().weight(0.3f), contentAlignment = Alignment.Center, ) { Text(text = text, style = MaterialTheme.typography.titleLarge) } Spacer(modifier = Modifier.weight(0.05f)) } } }这里我用了比较多的Box，有点类似于HTML里面的div，我不知道我这样用的对不对，但反正是能用。我也不是专业写前端的，如果用错了，那也就错了。只要不是错的特别离谱，我也不打算改。为了方便使用，我们当然不想手动渲染每一个Item，最好是能够给一个列表，然后他就自动去调用这些Composable了。为了实现这个目的，得先实现一个描述菜单的结构：data class MenuItem( val icon: ImageVector, val action: String, val callback: Context.() -> Unit )对于一个菜单按钮来说，无外乎需要提供三个元素：图标、文字和执行的操作。这里执行的操作给了一个Context，一开始是打算为了方便调用startActivity这些方法，但后来发现这个菜单只在Activity里面渲染，而Activity本身就是一个Context了，好像没必要再这样了。不过写都写了，就这样吧。接下来封装一下那个Grid：@Composable fun GridMenu( menuItems: List<MenuItem>, modifier: Modifier = Modifier, style: GridCells = GridCells.Adaptive(180.dp), ) { LazyVerticalGrid( columns = style, modifier = modifier ) { items(menuItems) { item -> GridMenuItem(icon = item.icon, text = item.action, onClick = item.callback) } } }这样一来，每次我们需要菜单的之后就直接给一个List，然后调用这个GridMenu就好了，效果如下：至于实际上主菜单都要有哪些功能，我想了想，就五个：同步：将Memento的数据同步到App的数据库中打印：打印标签快速扫描（Quick Scan）：选定目标位置，将扫描的箱子或物品移动到该位置备份：将App数据库导出或导入设置：通过图形界面调整APP设置设置设置页面是继主菜单之后最为重要的一个页面。但是我比较懒，不想设计太复杂的组件，所以就采用了列表的方式，每一行一个配置项，标题就是对应的数据库键，值就是内容。一开始的设计是所有内容都设计成文字，但后来发现像是Memento库的ID，这种东西最好是向API获取数据，然后让用户输入，不然id看起来就是一个随机字符串，用户很大概率会输入错误，更何况正常的图形界面下用户不可能看到库的ID。尽管配置项之间存在差异，但大体思路没有变化：点击一行，弹出修改窗口，应用修改。所以基于这种观察，设计了ConfigItem：@Composable private fun ConfigItem( key: String, valueProvider: (String) -> String, dialogContent: @Composable (MutableState<Boolean>) -> Unit, ) { val showAlertDialog = rememberSaveable { mutableStateOf(false) } Column( modifier = Modifier .fillMaxWidth() .padding(5.dp) .clickable { showAlertDialog.value = true } ) { Text( key, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(5.dp) ) Text( valueProvider(key), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(10.dp) ) if (showAlertDialog.value) { dialogContent(showAlertDialog) } } }这就是两行文字，在被点击时显示弹窗。弹窗的部分使用了AlertDialog组件，对于API Key这种需要用户输入的，就采用TextField，如果是需要选择的，就用ExposedDropdownMenuBox：ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = !expanded } ) { TextField( value = selected?.let{itemToString(it)} ?: "", onValueChange = {}, readOnly = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier.menuAnchor() ) ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { items().forEach { DropdownMenuItem(text = { Text(text = itemToString(it)) }, onClick = { expanded = false selected = it }) } } }有些设置的下拉菜单通常具有重复选项。例如我们在选择Memento库中的字段时，一个库要选三个字段。但字段不太可能经常变，如果每次选都去API拉去数据，一方面UI会显得很慢，另一方面API会达到rate limit。所以在ViewModel中做一个简单的Cache： private val fieldsMap = ConcurrentHashMap<SettingsKey, SnapshotStateList<LibraryField>>() fun getLibraryFields(settingsKey: SettingsKey): SnapshotStateList<LibraryField> { val list = fieldsMap.getOrPut(settingsKey) { mutableStateListOf() } // a simple cache, so we don't call api multiple times to select field if (list.isNotEmpty()) return list viewModelScope.launch { try { val libId = configRepo.getConfigByKey(settingsKey)?.value ?: "" mementoRepository.getLibraryFields(libId).let { list.clear() list.addAll(it) } } catch (e: IOException) { Log.e(tag, "Error when fetching library fields", e) showToast("Failed to list library fields: ${e.message}") return@launch } catch (e: HttpException) { Log.e(tag, "Error when fetching library fields", e) showToast("Failed to list library fields: ${e.message}") return@launch } } return list }当我们以某个配置的值去获取库的字段时，将结果放到fieldsMap里。这里没有使用库的ID，因为获取库Id需要执行数据库操作，我们这里需要立刻给UI返回一个列表，没办法等数据库操作。这也就导致了如果用户更换了库的ID，我们可能会用旧的结果返回给新的请求。因此我们需要在更新库ID的时候删除对应的缓存。同步有了配置API和参数的能力之后，我们就可以开始从Memento那边同步数据了。 同步方面没什么好说的，大体步骤如下：从Memento API拉取数据，插入或更新数据库，此时status为IN_USE，version为同步开始时的时间戳删除所有status为IN_USE，删除所有version不为最新的数据在UI方面也没有太多的设计，不过因为我没有用后台服务，所以不建议用户切换页面，同时为了避免耗时过长自动息屏，在进入同步的Activity时自动开启屏幕常亮：@AndroidEntryPoint class SyncActivity : VazanActivity() { // ...... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ...... setContent { // keep screen on DisposableEffect(key1 = Unit) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) onDispose { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } VazanTheme { // ...... } } } }打印数据同步完成之后就可以考虑打印标签的部分了。打印部分分为两个页面：生成标签和打印。生成标签有三种方式：随机生成：保证生成的标签在数据库中没有出现过扫描：扫描正在使用或已经打印过的标签，一般用于重复打印手动输入：我想不到什么需要手动输入的场景，但是有总比没有好打印界面主要是和标签编码相关的，确定好类型和标签之后将标签传给打印界面。打印界面将生成打印标签的预览，以及选择打印机、纸张、重复次数等，并实际调用蓝牙联络打印机。在打印页面返回成功后还会更新数据库，将刚刚打印的标签插入数据库，这时status为PRINTED，version为0。备份前面对着数据库一顿操作，如果数据丢了就糟糕了。好在我们的数据库比较简单，这里我使用了bencode来存储数据。关于Bencode的编码格式，可以详见之前的文章。备份文件整体是一个map，键是数据库表名，值是一个列表，列表内每一个map都对应一个记录。相对来说还是比较简单的，但是考虑到数据量可能很大，因此在Map和列表处理方面采用了流式处理： fun import(reader: Reader): Unit = runBlocking { // the import file should only contains 1 map // the map is <String, List<Map<String, *>>> // <Type, content of objects> val decoder = BencodeDecoder(reader) decoder.startMap() // not the end of the map while (decoder.nextType() != BEntryType.EntityEnd) { val type = decoder.readString() require(decoder.hasNext()) { "Invalid map: only has key, no value" } when (type) { "configs" -> { decoder.startList() while (decoder.nextType() != BEntryType.EntityEnd) { val map = Config.fromMap(readMap(decoder)) map?.let { configDao.insertOrUpdateConfig(it) } } decoder.endEntity() } "labels" -> { decoder.startList() while (decoder.nextType() != BEntryType.EntityEnd) { val map = Label.fromMap(readMap(decoder)) map?.let { labelDao.insertOrUpdateLabel(it) } } decoder.endEntity() } else -> error("Unknown type: $type") } } // end of the map decoder.endEntity() }快速扫描快速扫描部分针对目的地需要分别设计。如果目的地是一个位置，那么需要从Memento API拉去目前已知的位置，通过下拉列表让用户选择一个；如果目的地是一个容器，那么需要让用户扫描容器的条码。对于实际扫描移动的部分，逻辑上是没差的：扫描、发出请求将位置或容器设置为目的地，然后清空另一个字段。关于选择目的地，我直接套用了主界面的菜单格式。如果选择位置的话，则直接通过下拉列表选择，如果选择箱子的话则调用GMS的扫码服务进行扫码。GMS的扫码服务是通过调用Google Play服务完成的，因此APP不需要请求相机权限即可完成扫码。就目前的体验来说，它的扫码速度是最快的。但是我在测试过程中发现Google Play服务经常会自废武功。一开始扫码还能用，不知道后台什么时候一个自动更新，这个扫码就不能用了，这个时候就得手动清空Google Play服务的数据（不会登出Google账号），然后重新请求扫码服务，就很麻烦。如果谷歌在之后几个月还是修不好这个bug的话，我可能还是要考虑在应用内申请摄像头权限来扫码了。目的地选择完成之后会获得一个Memento的Entity ID，接下来我们拉起负责扫码和发送请求的Activity，并且把这个Entity ID和目的地类型传过去。根据目的地类型的不同，发出的请求也会有所变化：如果目的地是位置，则将箱子或物品的父级位置设置为Entity ID，设置父级容器为null如果目的地是箱子，则将箱子或物品的父级容器设置为Entity ID，设置父级位置为null而扫码的部分也相对简单：调用GMS扫码服务根据扫描到的条码，查询数据库中是否存在该标签，并且该标签是否存在对应的Entity ID若该条码存在，则判断条码的类型，查询设置中对应的库ID和字段ID根据目的地发出请求请求成功后再次调用扫码服务，回到step1这部分是一个循环，用户可以在扫码阶段执行返回操作，进而取消扫码。这时扫码部分的Activity就会顺势结束，退出快速扫描的操作。总结至此，关于我的第一个安卓APP就已经全部讲解完了。这一片拖得有点久，因为一直没有想明白UI部分到底应该怎么写。关于布局的代码又臭又长，而且还没什么新意，但是有些东西用起来很自然，写起来不一定自然，可是说不自然吧，又没达到复杂的程度，这种东西拿出来写显得啰嗦，不拿出来写又觉得过于省略了，实在是很难定夺。最后我觉得还是不要事无巨细的说比较好，毕竟UI做出来是要用的，不是拿来吹的。在交互上有不少细节，你不提没人知道，你加上了别人会觉得很顺畅很舒服，但是你提起来又会显得很啰嗦。目前这个APP已经完全开源在了GitHub上。虽然对于开源软件我不提供任何质量和可用性的保障，但是这个APP毕竟是我自己用的APP，能用肯定是基本要求。-全文完-
【代码札记】从零开始的安卓应用开发 - P2
本文来介绍安卓APP的依赖注入，以及APP中一些机制的设计。依赖注入作为一个Java后端程序员，依赖注入这个概念肯定是不陌生：Spring框架这种理科男设计出来的玩意儿，真是要命。文档里没有几句人话，全是黑话和暗语，AOP，依赖注入，IOC，OOP，动态代理，CGLIB……只有他们能懂。正常人不可能看懂。想用旧代码加上个面向切面编程——看看，全是这种黑话，切面还编程。这是人话吗？弄了半天，一头大汗，也没弄明白。网上所谓的教程，是同一帮人写的，用同一套黑话。不弄了……credit: https://disksing.com/sao-gen-gen/43PS：在中国北方，切面是面条的一种。所以用面条编程，还是挺搞笑的如果读者不了解依赖注入的话，这里简单的举一个例子。按照面向对象的原则，我们在编程的时候希望把不同功能的代码放到不同的类里面。以汽车为例，Car需要有引擎，有电池，有车载电脑，有控制系统，而这些细节的实现不应该放在Car类里面，而是单独作为各自的类，通过接口来实现功能的调用。例如电池接口只需要供电和充电，而不必让调用者考虑这电池用的是什么材料，使用了什么技术。也许电池比较简单，那车的控制系统呢？一个控制系统会有好多输入输出，而这些输入输出也是不同的模块。这样一来，你要实例化一个汽车对象，就要先实例化它依赖的对象，而它依赖的对象又要依赖其他对象。日后如果你要对某个子系统新增一个依赖，那只能祝你好运了。而依赖注入就是来解决这个问题的。你只需要告诉依赖注入的框架如何制作这些基本的元素，而后依赖注入的框架就可以自动地根据需求来“装配”依赖，进而实例化对象。依赖注入经常和反转控制（IOC）一起说，因为在以前，是我们程序员来编写代码，控制不同组件实例化的时机，进而用实例化的结果来做事。引入了依赖注入之后，实例化组件的时机不再受我们的控制，反而是我们的代码将在不确定的时机被框架调用，这就是反转控制大体思想。当然，作为不使用依赖注入的反转控制，你也可以做一个全局共享的类，然后将所有需要的零部件放到这个类里面，通过静态成员按需使用。但这种情况下你还是需要维护一个类似于查找表的东西，我觉得这种手动的东西不如自动的框架来得优雅、简洁。对于安卓上的依赖注入框架，我喜欢叫他Dagger hilt，虽然dagger和hilt是两个分开的框架。关于这些库的历史渊源我就不讲了，谷歌上都可以查到。简单地说Dagger是一个谷歌维护的（官方已经钦定了）安卓上的依赖注入框架。它实现了JSR 330（Dependency Injection for Java），并且主要利用javax.inject.Inject注解来对非私有成员和构造器进行注入。Dagger是在编译时期确定依赖关系的，因此在编译时Dagger会根据你提供的对象和需要被构造的对象生成Factory类的代码，从而实现依赖注入。而Hilt则是一个使用了Dagger，并意在简化Dagger使用的库。在安卓上，一些特定的类是不能直接进行依赖注入的，例如安卓的Activity。要对这些类进行依赖注入，通常需要使用Dagger生成的工厂类。虽然Dagger帮我们生成了这些工厂类，但最终让安卓使用这些工厂类的代码还是要我们来写。而Hilt就是解决了这个问题，只需要一个注解，我们就可以直接享受到完整的依赖注入——不再需要额外的配置代码了。要开始依赖注入，我们首先需要提供一些可供注入的素材。这里为了简单考虑，所有的东西都作为单例模式注入。也就是说，这些对象一经创建，就要跟着整个APP的生命周期走。@Module @InstallIn(SingletonComponent::class) object AppModule { @Provides @Singleton fun provideAppDatabase(app: Application): AppDatabase = Room.databaseBuilder(app, AppDatabase::class.java, "vazan").build() @Provides @Singleton fun provideConfigDao(database: AppDatabase): ConfigDao = database.configDao }这里我们就提供了两个对象。一个是AppDatabase用来访问数据库的；另一个是从数据库里摘出来的DAO。至于最开始的那个Application从哪搞，这也好办：@HiltAndroidApp class VazanApplication : Application()直接创建一个就好了。当然，Provide并不是提供对象的唯一方式。Provide提供了一种教框架构造对象的方法。但有时候只是把参数传来传去，很没意思。例如一个Provide方法就是把接收到的参数传给一个构造器，这种情况下就没有必要使用Provide了，可以使用Bind：@Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Binds @Singleton abstract fun bindConfigRepository( repository: ConfigRepositoryRoomImpl ): ConfigRepository @Binds @Singleton abstract fun bindLabelRepository( repository: LabelRepositoryRoomImpl ): LabelRepository @Binds @Singleton abstract fun bindMementoRepository( repository: MementoRepositoryRetrofitImpl ): MementoRepository }这里我们将之前说的Repository的实现Bind给了对应的Repository接口。这样Dagger在处理依赖时，如果有其他类用到了这些接口，它就知道该使用哪些实现了。很是方便。如果要使用依赖注入，那就更方便了。@HiltViewModel class QuickScanViewModel @Inject constructor( private val configRepo: ConfigRepository, private val labelRepo: LabelRepository, private val mementoRepository: MementoRepository ) : ViewModel() { // ... }这是一个ViewModel，本质上就是一个存储了一堆状态，实现了一些业务逻辑的类。这里我们可以对构造器应用@Inject注解，来让Dagger将我们需要的依赖注入进来。但如果是UI怎么办呢？Activity没法注入构造器，但它可以注入非私有字段：@AndroidEntryPoint class ExampleActivity : AppCompatActivity() { @Inject lateinit var analytics: AnalyticsAdapter }当然，如果是注入ViewModel的话，还可以使用另一种方法：@AndroidEntryPoint class BackupActivity : VazanActivity() { private val viewModel: BackupViewModel by viewModels() }这个viewModels方法是androidx.activity包里面的，严格来说不属于Hilt，但hilt会为ViewModel生成对应的Factory和配置代码，免去了我们手动配置的过程，也是十分的方便。机制设计聊完了依赖注入，下面说说一些细节的设计吧。设置存储首先是设置的存储。前文中我设计了一个Config表来按照键值对存储设置。那么针对键应该有一个比较规范的描述。当然可以用字符串，但是这样的话，万一手滑有个typo，那不出意外的话就是要出意外了。所以这里我使用了枚举类。除了字符串的键本身，考虑到我比较懒，所以希望直接把这个枚举类作为UI的设置列表来用（就是一个键名对应一个选项），所以还要加入一些对UI部分的支持。目前来看，一个枚举类有三个字段：字符串形式的键是否为单行（考虑到前端的TextField，不过目前还没有遇到多行的配置）验证器（也是给前端的，只有这个lambda返回true的时候才是有效的）目前得配置如下：enum class SettingsKey( val key: String, val singleLine: Boolean, val validator: (String) -> Boolean ) { APP_LAST_PRINTER_ADDRESS("app.thermal_printer.last_address", true, { it.isNotBlank() }), APP_LAST_PRINTER_PAPER("app.thermal_printer.last_paper", true, { it.toIntOrNull() != null }), MEMENTO_API_KEY("memento.api_key", true, { it.isNotBlank() }), // location MEMENTO_LOCATION_LIBRARY_ID("memento.location.library_id", true, { it.isNotBlank() }), MEMENTO_LOCATION_FIELD_ID("memento.location.field_id", true, { it.toIntOrNull() != null }), // box MEMENTO_BOX_LIBRARY_ID("memento.box.library_id", true, { it.isNotBlank() }), MEMENTO_BOX_FIELD_ID("memento.box.field_id", true, { it.toIntOrNull() != null }), MEMENTO_BOX_PARENT_LOCATION_FIELD_ID( "memento.box.parent_location.field_id", true, { it.toIntOrNull() != null }), MEMENTO_BOX_PARENT_BOX_FIELD_ID( "memento.box.parent_box.field_id", true, { it.toIntOrNull() != null }), // item MEMENTO_ITEM_LIBRARY_ID("memento.item.library_id", true, { it.isNotBlank() }), MEMENTO_ITEM_FIELD_ID("memento.item.field_id", true, { it.toIntOrNull() != null }), MEMENTO_ITEM_PARENT_LOCATION_FIELD_ID( "memento.item.parent_location.field_id", true, { it.toIntOrNull() != null }), MEMENTO_ITEM_PARENT_BOX_FIELD_ID( "memento.item.parent_box.field_id", true, { it.toIntOrNull() != null }), // sync MEMENTO_SYNC_VERSION("memento.sync.last_version", true, { it.toIntOrNull() != null }), }有了这个枚举类之后，就可以把ConfigRepository里面的key: String改成key: SettingsKey了。标签编码接下来我们说说打印相关的。虽然标签分为DataMatrix和Code128两种，并且分别给箱子和物品使用，但实际上给物品用DataMatrix、给箱子用Code128也可以。DataMatrix的好处在于自带的纠错：箱子在运输过程中难免会有磕碰，万一二维码的部分损坏了，有一点纠错还能救回来；而物品一边都比较小，相比之下Code128更节省空间，当然你要能贴的下DataMatrix那也无所谓。为了防止条码损坏读不出来，我还在标签上连对应的文字版编号也一起打印了出来，但这时候就有一个问题：0O1Il你能分清几个？如果只考虑大写字母和数字，那0O1I你又能分清楚几个？为了避免标签过于依赖字体，同时尽可能避免混淆，我决定使用23456789ABCDEFGHJKLMNPQRSTUVWXYZ这几个字符来编码。好巧不巧，他们正好32个字符，我设计一个标签有10个字符，第一个用来区分类别（B和I分别表示Box和Item），剩下9个用于编码序号，这样一来9个字符可以编码35184372088832个序号（3.5万亿个，从0到32的9次方）。编码的过程非常简单：无非就是把数字从10进制转换成32进制。但为了方便解析和生成，我们采用小端序来表示。以12345678举例：8是这个数字中最小的位数，而1是最大的。从字符串的角度来看，1的索引是0，是最小的；8的索引是最大的。这样一来就成为的低位在高地址，高位在低地址。对于计算机来说就是这样的：87654321。对于人类来说怎么看都有点别扭，但是对于计算机来说是刚刚好：通过求余数，我们先产生低位，后产生高位。在解析的时候，我们先计算出低阶的，然后再计算出高阶的。关于算法就不多说了，进制转换应该没什么难的，余数和除法、乘法和累加的事情。但是直接用这个字符集会比较丑。以0为例，它编码出来是222222222，因为字符2的索引是0。这种看起来就很奇怪。如果我们随机打乱一下，还是会很奇怪：AAAAAAAAA。总之这种连续的字符就很奇怪。好在没人规定我们必须用同一套字符集。由于我们的编码的位数是固定的，因此我们可以针对每一个位置设定一个字符集。例如第一个字符采用P4RQZ8MYLCBSKXWUJ35F6VDG79AN2EHT这个顺序，第二个字符采用TYZK2CG9AWD7RNSPQHEJX36M4FLB58UV这个顺序，以此类推。这样虽然每一位算出来都是0，但第0号索引的字符在每一位上都各不相同，这样算出来的编码显得很有随机性，即便是连续编码也不太容易发现规律。那么我们应该怎么构建这个字符集呢？fun generateRandomDigits( count: Int, seed: Long, chars: List<Char> = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ".toList() ): List<String> { // make sure don't overflow log(Long.MAX_VALUE.toDouble(), chars.size.toDouble()).toInt().let { require(count <= it) { "Too many digits: Max $it" } } val random = Random(seed) while (true) { val digits = Array(count) { chars.shuffled(Random(random.nextLong())).toCharArray() } val allDistinct = digits.indices.map { i -> digits.map { it[i] }.distinct().size }.all { it == count } if (!allDistinct) continue return digits.map { it.concatToString() } } }通过随机产生一些字符序列，我们来判断是否每一位上都具有不同的字符。由于我们的字符有32个，而位置只有9个，根据抽屉原理，肯定存在不重复的组合。产生标签说完了编码，下面我们说说怎么产生标签。在Java中我们可以使用BufferedImage来绘制图片，到了安卓上则变成了Bitmap。这二者有什么区别我暂且没有详细了解，不过后者的性能据说要比BufferedImage要好，我不知道真的假的，反正我没什么感觉。关于条码部分，我使用了zxing这个库来生成。这个库有点年头了，但好在条码的标准也好久没有更新换代了，暂且还是可以使用的： private fun dataMatrix(width: Int, height: Int, content: String): BitMatrix = DataMatrixWriter().encode( content, BarcodeFormat.DATA_MATRIX, width, height, // force square mapOf(EncodeHintType.DATA_MATRIX_SHAPE to SymbolShapeHint.FORCE_SQUARE) ) private fun code128(width: Int, height: Int, content: String): BitMatrix = Code128Writer().encode(content, BarcodeFormat.CODE_128, width, height) private fun BitMatrix.toBitMap(): Bitmap { val image = Bitmap.createBitmap(this.width, this.height, Bitmap.Config.ARGB_8888) for (y in 0 until this.height) { for (x in 0 until this.width) { if (this[x, y]) image.setPixel(x, y, Color.BLACK) else image.setPixel(x, y, Color.WHITE) } } return image }这三套函数一个组合拳下来，就能产生给定大小、给定内容的条码图片。注意这里我使用了ARGB_8888，这块和Java的BufferedImage不一样，必须要使用ARGB_8888才能正确显示和处理。我最开始想用ALPHA_8，但后来发现放在UI上预览的时候是空的，百思不得奇怪。最后无意间才发现是这个的问题。有了条码，接下来也得把文字变成图片： private fun textToBitmap(text: String, fontSize: Double): Bitmap { val paint = Paint().apply { textSize = fontSize.toFloat() color = Color.BLACK typeface = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL) textAlign = Paint.Align.LEFT } val baseline: Float = -paint.ascent() // ascent() is negative val width = (paint.measureText(text) + 0.5f).toInt() // round val height = (baseline + paint.descent() + 0.5f).toInt() val image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(image) canvas.drawText(text, 0f, baseline, paint) return image }这里我们使用了Monospace字体，这个字体能够保证所有字符都是等宽的。我们首先根据Paint对象测量一段文字对应的长和宽，然后创建对应大小的画布，最后在上面绘制文字。最后就是把二维码和文字组合在一起了： private fun generateImage( width: Int, height: Int, barcode: Bitmap, text: Bitmap, xOffset: Float, yOffset: Float, ySpacing: Float ): Bitmap { val image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(image) // white background canvas.drawRect(0f, 0f, image.width.toFloat(), image.height.toFloat(), Paint().apply { color = Color.WHITE }) // barcode canvas.drawBitmap(barcode, (image.width - barcode.width) / 2.0f + xOffset, yOffset, null) // text canvas.drawBitmap( text, (image.width - text.width) / 2.0f + xOffset, yOffset + barcode.height + ySpacing, null ) return image }这里的长和宽是整个图片的大小。后面的xOffset决定了整个图像和水平居中的位移，如果是0的画，图像正好是水平居中的；如果需要偏移的话可以用这个调整。这个参数是考虑到打印机在打印的时候，由于打印头位置的偏差，其实左侧是存在一定留白的，如果这个时候图像还要居中，那打印出来就偏右了。再后面的yOffset则决定了条码的顶部和图像顶部有多少留白；ySpacing则决定了文字的顶部和条码的底部中间有多少留白。注意哦，这些参数不一定非得是正的。如果你用的字体本身就有比较大的留白，而标签又要很紧凑，你可以用负数来调整取消字体本身的留白。这还没完，有时候打印纸不一定是方向正确的，例如80x60毫米的打印纸，有竖着的，也有横着的。对于横着的，我们还得旋转一下： private fun Bitmap.rotate(angle: Float): Bitmap { val matrix = Matrix() matrix.postRotate(angle) return Bitmap.createBitmap( this, 0, 0, width, height, matrix, true ) }标签生成算是搞好了。但是打印呢？打印机使用TPSL语言来传输打印数据。其中图片数据是按照比特传输的。也就是说一个字节就表示了8和横着的点。我们可以就Bitmap转换成打印数据写点代码： private fun Bitmap.getGrayPixel(x: Int, y: Int): Int { val c = this.getPixel(x, y) val r = (c shr 16) and 0xFF val g = (c shr 8) and 0xFF val b = c and 0xFF return (0.3 * r + 0.59 * g + 0.11 * b).roundToInt() } fun Bitmap.toDataArray(): Array<IntArray> = (0 until this.height).map { y -> (0 until this.width).map { x -> this.getGrayPixel(x, y) }.toIntArray() }.toTypedArray() fun Array<BooleanArray>.collapse(): ByteArray { val width = (this[0].size / 8.0).roundToInt() val result = ByteArray(width * this.size) for (y in this.indices) { val row = this[y] for (x in row.indices) { val wordIndex = x / 8 val bitIndex = 7 - (x % 8) var w = result[wordIndex + y * width].toInt() val mask = 1 shl bitIndex w = if (row[x]) { // set the bit to 0 for black w and mask.inv() } else { w or mask } result[wordIndex + y * width] = w.toByte() } } return result }这套代码其实是从Java那边复制过来的。在此之前我在尝试用这个打印机打印图片。但经过一番尝试，这个打印机对于打印头的温度控制的不是太好，并且在位置控制方面也比较松动。总第来说，这个标签打印机就不是设计来给你打图片的。合情合理，不是吗？所以这部分代码可能会有些啰嗦。首先是从Bitmap中获取灰度值，因为输入可能是彩色。这个toDataArray()就是把Bitmap转换成值为0到255的灰度数据。获取灰度值之后原本可以进行一些处理，比如调整曲线，或者应用一个抖动，但这里我们的图像本身就是黑白的了，所以就不需要额外处理了。我们需要在使用的时候做一个简单的map，将0转换成true，将255转换成false。而后的collapse则是将这些黑白数据转换成TPSL格式的图像数据。纸张尺寸前面铺垫的差不多了，再打印之前就剩下最后一件事了：决定纸张尺寸。对于专门的标签打印软件来说，他们有专门的UI来让用户针对不同纸张来设计标签。但我不想那么麻烦，更何况我这布局标签的代码都写出来了。尽管如此，不同的纸张还是要有不同的考量。这里我将字符串形式的标签编码作为输入，根据纸张的大小来决定用什么条码。对于比较大的80x60的条码，我们可以用DataMatrix；对于70x30这种长条形状的，我们可以用Code128这种传统条码。同样的，为了方便前端列出所有可能的纸张，这里依旧使用了枚举类：enum class PaperSize( val displayName: String, val gap: Int, val generatePreview: (String) -> Bitmap, val generatePrintData: (String, Int) -> ByteArray )首先是displayName，这个是在前端中为用户展示的纸张规格，后面的gap表示纸的间隙，一般是2mm。后面有两个lambda，第一个generatePreview是给定标签编码，产生一个预览图片；第二个则是给定一个标签编码和重复打印的次数，直接生成字节数组格式的打印指令。我们以80x60这种横板的纸张为例： PAPER_80_60_2( displayName = "80mm x 60mm", gap = 2, generatePreview = { PrintUtils.generate80By60(it) }, generatePrintData = { str, repeat -> val imageData = PrintUtils.generate80By60(str).toDataArray() "SIZE 80 mm,60 mm\r\n".encodeToByteArray() + "GAP 2 mm,0 mm\r\n".encodeToByteArray() + "DENSITY 2\r\n".encodeToByteArray() + "SPEED 1.5\r\n".encodeToByteArray() + "CLS\r\n".encodeToByteArray() + "BITMAP 0,0,${imageData[0].size / 8},${imageData.size},0,".encodeToByteArray() + imageData.map { row -> row.map { it == 0 }.toBooleanArray() } .toTypedArray().collapse() + "\r\n".encodeToByteArray() + "PRINT ${repeat}\r\n".encodeToByteArray() } ),其中实际产生图片的部分是： fun generate80By60(str: String): Bitmap { val barcode = dataMatrix(320, 320, str).toBitMap() val text = textToBitmap(str, 64.0) val image = generateImage(480, 640, barcode, text, 0f, 50f, 40f) // now we get 640 by 480 -> 80 by 60 under 203 dpi return image.rotate(-90f) }因为纸张的大小都是固定的，所以像是条码的尺寸、留白的大小这些也都可以直接写死。总结至此，所有非UI的部分都已经讲解完毕。这时脑袋尖的读者可能就问了，你这蓝牙打印的部分呢？你不会把蓝牙打印的部分也放到UI里面去了吧？不会吧不会吧不会吧这脑袋尖的读者多少有些烦人了（bushi虽然使用蓝牙调用打印机不算是UI的一部分，但是蓝牙本身的操作就是依赖于Context的。例如一上来你需要调用getSystemService(Context.BLUETOOTH_SERVICE)，光是这一点就让你没办法了。当然，你可以通过依赖注入拿到一个Application，也就是Context，但是我总觉得用这种类似于“飞线”的方式滥用Context，反而不利于整理架构。因此我的做法是创建Activity的时候初始化蓝牙，然后在ViewModel中调用蓝牙打印。关于UI的部分我也分了几块。首先是Material 3，这里面涉及到一些配色之类的问题，尤其是安卓12开始支持系统级的动态配色了，这极大的缓解了我缺乏艺术细胞的问题。然后是分组件介绍，先是主菜单、设置页面和备份页面；然后是Memento同步，接着是标签打印，最后是Quick Scan用来移动东西。预计可能还需要一到两篇才能说完吧。
【代码札记】从零开始的安卓应用开发 - P1
本文来介绍APP的整体架构和数据源设计。前文简单描述了这个APP的思路来源和潜在的应用场景，本文首先介绍一下APP的技术和架构。技术和架构众所周知，我很讨厌在UI中使用XML来定义布局。我不止一次自己写了一个简单的声明式框架来帮助我渲染UI，这其中就包括我的软件课设和毕业设计。所以同样地，我也讨厌在安卓中使用XML文件来定义布局。好在Google整出来了一个Jetpack Compose，它基于Kotlin，允许开发者使用声明式的方式定义和渲染布局，并且整个生态支持的相对还不错，在我看在，大部分情况下要比原来的UI更加便于使用。关于数据库和其他框架部分，几乎都支持Kotlin协程，而不用依赖额外的什么RxJava这些。在我看来甚是得体和优雅。总体来说，APP的界面部分使用Compose来实现，数据库使用Room框架，HTTP请求则使用Retrofit2框架，外观使用Material 3，依赖注入部分使用dagger hilt。对于我的第一个安卓APP来说，这看起来还挺中规中矩的是吧。关于应用程序的架构，好像我并没有遵循一个特定的准则。我更倾向于凭直觉分割代码。例如数据源的部分分成来自Room数据库的和来自API的；基于这些数据源，针对应用的功能提供类似于Repository、用于提供交互的接口（即业务逻辑不考虑这些数据的原始形式）；然后是业务逻辑，通常不考虑UI部分，例如生成条码等；最后是UI部分。我也不知道我这种组织方式叫什么，反正写代码的是我，我自己开心就好。数据源设计接下来我们说说数据源的设计。数据源分两种：数据库和Memento API。Memento在正式开始之前，我们先说一说Memento那边需要怎么建立Library。我打算分为三个Library：位置、容器和物品。位置部分比较简单，唯一的要求就是具有一个能够区分不同位置的字段。这个字段在Quick Scan操作（指定目的地，通过扫码来向API发出请求，实现将被扫描的物品移动到目标位置）中会被显示给用户，让用户挑选一个位置作为目的地。容器则相对麻烦一些：容器可能在一个位置下（即位置与容器是1对N），也可能在其他容器里（容器与容器1对N），但这个容器不可能既在一个位置，又在另一个容器里，遗憾的是Memento数据库没办法提供这种检查，但索性我们可以通过编程来修改（设置字段的时候将另一个字段设置为null）。同时容器还要求一个条码字段。物品与容器类似，但这次条码字段不是必填的，因为考虑到有些物品太小了，没必要贴条码（真的不会有人给每一根数据线都贴上条码吧，不会吧不会吧不会吧）。除了上述必须的字段，剩余字段可以自由发挥。例如在物品这里可以添加一个勾选框，用来标识这个物品有没有内含电池，这样收纳的时候就可以针对有电池的物品特殊照顾。而这些字段都是和APP不相关的，不会干扰APP的运行。数据库首先是数据库设计。由于核心数据都存储在Memento那边，所以APP这块只要记录最基础的数据就好了。实体首先是负责记录配置的键值对，这个表比较通用，充当了一个KV存储：@Entity( tableName = "configs", primaryKeys = ["config_key"], indices = [Index("config_key", unique = true)] ) data class Config( @ColumnInfo(name = "config_key") val key: String, @ColumnInfo(name = "config_value") val value: String, )在使用时，键的部分通常以scope.namespace.something的形式来表示。例如有关APP中记录最后一次使用的打印机地址可以表示为app.thermal_printer.last_address，而用于访问memento API的密钥则表示为memento.api_key，类似地，记录Memento中存储位置的库的ID可以表示为memento.location.library_id，大概就是这种感觉。值的部分则使用String表示，由具体的设置自行决定。然后是记录标签的表。在打印的时候我们需要获知正在使用的标签（来自Memento API的数据），同时也要记录我们曾经打印过但还没有在Memento中使用的标签（由我们加入到数据库中）。另外在Quick Scan操作中，我们需要Entity ID来编辑被移动物品的位置，即通过扫描物品的标签，我们需要记录标签到Entity ID的映射。同时基于标签的编码形式，我们不需要区分这个标签是容器标签还是物品标签，因为容器标签总是以字母B开头，而物品标签总是以字母I开头。数据库设计如下：@Entity( tableName = "labels", primaryKeys = ["label_id"], indices = [ Index("label_id", unique = true), Index("entity_id", unique = true), Index("label_status", "version"), ] ) data class Label( @ColumnInfo(name = "label_id") val labelId: String, @ColumnInfo(name = "label_status") val status: Status, @ColumnInfo(name = "version") val version: Long, @ColumnInfo(name = "entity_id") val entityId: String?, )这里还有一个额外的version字段，这个是为了同步设计的。每次从Memento API拉取数据时，会把获取到的标签的version字段设置为一个时间戳，这样同步结束之后，version字段不一样的标签就是这次同步后被删除的标签。被删除则是因为这个标签曾经在Memento数据库中出现过，但现在消失了，这种场景对应了逻辑上的盒子被丢弃了（可能是被贴上了新的标签，也可能是因为这个盒子被弃用了）。如果是因为网络原因导致这个盒子存在但没有传给APP，那么下次同步的时候还会添加回来，无需担心。关于标签的状态（status），这是区分一个标签来自Memento的（已被使用），还是来来自于APP的（已打印）： enum class Status { IN_USE, PRINTED }DAO关于DAO（Data Access Object），在配置那块没什么好说的，增删改查四个函数：插入或更新按照key获取配置删除配置列出所有配置在标签部分，除了基础的增删改查四个函数之外，还有：按照状态删除所有标签：有时候用户可能希望删除所有已打印标签的记录，或者删除所有正在使用的标签以重新同步按照状态和版本删除标签：在同步完成后，针对所有已使用的标签（有新同步来的，还有需要被删掉的老数据），删除所有version不等于给定version的标签，即删除所有旧的数据Repository每个数据库表对应一个Repository，由于不需要什么复杂的变换，因此直接调用DAO就是了。这是配置的：interface ConfigRepository { suspend fun getConfigByKey(key: String): Config? suspend fun insertOrUpdateConfig(config: Config) suspend fun deleteConfig(config: Config) private suspend fun getConfigOrBlank(settingsKey: SettingsKey): String = getConfigByKey(settingsKey.key)?.value ?: "" /** * Get the item library id (box or item), and the parent location and box fields. * * Item library id might be blank if config is not set. * Fields id might be null if config is not set. * * @return Triple(itemLibId, itemParentLocationField, itemParentBoxField) * */ suspend fun resolveConfig(prefix: String): Triple<String, Int?, Int?> { // ...... } } class ConfigRepositoryRoomImpl @Inject constructor( private val dao: ConfigDao ) : ConfigRepository { override suspend fun getConfigByKey(key: String): Config? = dao.getConfigByKey(key) override suspend fun insertOrUpdateConfig(config: Config) = dao.insertOrUpdateConfig(config) override suspend fun deleteConfig(config: Config) = dao.deleteConfig(config) }配置部分我在后期额外添加了一个resolveConfig，这个是因为同样的解析配置的操作出现在了多个类中，基于面向对象的考量，这部分代码被抽象放到了ConfigRepository里。这个函数的做用就是给定一个标签，来查询这个标签应该使用的库ID（是容器库还是物品库），以及这个库中表示位置的字段。这是标签的：interface LabelRepository { suspend fun getLabelById(labelId: String): Label? suspend fun insertOrUpdateLabel(label: Label) suspend fun deleteLabel(label: Label) suspend fun deleteLabelByStatus(status: Label.Status) suspend fun deleteOldLabelsByStatus(status: Label.Status, latestVersion: Long) } class LabelRepositoryRoomImpl @Inject constructor( private val dao: LabelDao ) : LabelRepository { override suspend fun getLabelById(labelId: String): Label? = dao.getLabelById(labelId) override suspend fun insertOrUpdateLabel(label: Label) = dao.insertOrUpdateLabel(label) override suspend fun deleteLabel(label: Label) = dao.deleteLabel(label) override suspend fun deleteLabelByStatus(status: Label.Status) = dao.deleteLabelByStatus(status) override suspend fun deleteOldLabelsByStatus(status: Label.Status, latestVersion: Long) = dao.deleteOldLabelsByStatus(status, latestVersion) }Memento API数据库部分比较简单，接下来说说Memento API的部分。按照官方的说法，这个API还在Beta阶段，不过用起来感觉已经很稳定了。它的搜索接口有一些小bug，但我们用不上这个接口，无所谓。我们使用的接口有：列举库：列出当前API key有权访问的所有库列举库的详情：列出一个给定库的字段数据列举实体/记录：给定一个库，以分页的方式遍历所有记录更新记录：给定一个库和实体ID，修改指定的字段的值需要注意的是，目前API具有Rate limit，个人付费账户是每分钟30次，平均下来也就是2秒一次。我不想做特别复杂的控制逻辑，因此就单纯地在每次请求完成后等待3秒钟，保证不会触发限制。关于Retrofit怎么使用这里就不多说，这个库真的挺好用的，通过注解就把HTTP请求搞好了：interface MementoService { @GET("v1/libraries") suspend fun listLibraries(@Query("token") token: String): ListLibrariesDto @GET("v1/libraries/{libraryId}") suspend fun getLibraryInfo( @Path("libraryId") libraryId: String, @Query("token") token: String ): GetLibraryDto @GET("v1/libraries/{libraryId}/entries") suspend fun listEntriesByLibraryId( @Path("libraryId") libraryId: String, @Query("token") token: String, @Query("pageSize") pageSize: Int? = null, @Query("pageToken") pageToken: String? = null, @Query("fields") fields: String? = null, @Query("startRevision") startRevision: Int? = null, ): ListEntriesByLibraryIdDto @PATCH("v1/libraries/{libraryId}/entries/{entryId}") suspend fun updateEntryByLibraryIdAndEntryId( @Path("libraryId") libraryId: String, @Path("entryId") entryId: String, @Body updateEntryDto: UpdateEntryDto, @Query("token") token: String ): EntryDto }你看，这里我只定义了一个接口，用注解表示出对应的HTTP动作，对应的参数和返回值，之后Retrofit就可以自动帮我生成代码来发送HTTP请求，并解析返回值了。关于DTO，写起来也不难，这里我选用的moshi这个库来将Json转换成对象，从响应结果转换到对象也是Retrofit框架负责的一部分，我只需要定义对象就好了，例如：data class GetLibraryDto( @field:Json(name = "id") val id: String, @field:Json(name = "name") val name: String, @field:Json(name = "owner") val owner: String, @field:Json(name = "createdTime") val createdTime: String, @field:Json(name = "modifiedTime") val modifiedTime: String, @field:Json(name = "revision") val revision: Int, @field:Json(name = "size") val size: Int, @field:Json(name = "fields") val fields: List<LibraryField> ) { data class LibraryField( @field:Json(name = "id") val id: Int, @field:Json(name = "type") val type: String, @field:Json(name = "name") val name: String, @field:Json(name = "role") val role: String?, @field:Json(name = "order") val order: Int ) }Repository类似地，我们也需要定义一个Repository让业务逻辑无需关心底层实现。在此之前，我们需要处理一些数据类型的转换。例如上面举例的GetLibraryDto.LibraryField，它是作为一个响应体来设计的，因为LibraryField是请求库详情的响应体的一部分。但我们的业务逻辑中也需要获取库的字段列表，如果让业务逻辑拿着这个响应的一部分做事，总觉得好像不太好。因此我们需要单独设计一个业务上的对象：data class LibraryField( val id: Int, val type: String, val name: String, ) { companion object { fun fromDto(obj: GetLibraryDto.LibraryField): LibraryField = LibraryField( id = obj.id, type = obj.type, name = obj.name ) } }这个LibraryField就是业务逻辑中专门描述库字段的模型，虽然定义上和那个响应体差不多，但概念上不同。看起来有些啰嗦，但当程序变得复杂的时候，我想你肯定不希望你的底层API和业务逻辑紧紧绑定在一起。类似地，我们在列出所有库的时候，我们只希望将有用的信息呈现给业务逻辑：data class LibraryBrief( val id: String, val name: String, val owner: String ) { companion object { fun fromDto(obj: ListLibrariesDto.ListLibrariesEntry): LibraryBrief = LibraryBrief( id = obj.id, name = obj.name, owner = obj.owner ) } }也就是表的ID、表的名称和表的所有者。至于什么修改时间、revision这些，我们目前还不关心。Repository的设计也与数据库不同，数据库的DAO给出来的数据就可以直接用，但这个API给出的来的数据不能直接交给业务逻辑，例如那个分页遍历。理想情况下，我们希望使用诸如Sequence或者Flow这样的数据结构来表示一个动态生成的数据流（相比之下，List和数组表示所有数据都在内存中等待使用）。换句话说，业务逻辑并不希望了解你的API是怎么分页的，无论是通过page和pageSize，还是通过nextPageToken，业务逻辑期待一个更加规范的包装。Repository的接口是这样的：interface MementoRepository { suspend fun listLibraries(): List<LibraryBrief> suspend fun getLibraryFields(libraryId: String): List<LibraryField> suspend fun listEntriesByLibraryId( libraryId: String, pageSize: Int? = null, fields: String? = null, startRevision: Int? = null, ): Flow<List<EntryDto>> suspend fun updateEntryByLibraryIdAndEntryId( libraryId: String, entryId: String, updateEntryDto: UpdateEntryDto, ): EntryDto }可以看到在listEntriesByLibraryId那块，它的返回值是Flow<List<EntryDto>>。那么，什么是Flow呢？在此之前我们先介绍一下Sequence。序列是Kotlin中提供的一种生成式流，有点类似于Iterator，但使用了协程。关于协程的定义，我也不是什么严谨的学术工作者，所以这里就不献丑了。下面我来举一个例子，希望读者可以从中体会到Sequence和Iterator的不同。假如我们要按需产生一个从Int最小值到Int最大值的序列，首先来说我们不会把它存在内存里，因为他太多了。但是我们需要在逻辑上使用这样一个序列，怎么办呢？使用Iterator可以实现：object : Iterator<Int> { val counter = AtomicInteger(Int.MIN_VALUE) override fun hasNext(): Boolean = counter.get() < Int.MAX_VALUE override fun next(): Int = counter.getAndIncrement() }我们的迭代器内部有一个计数器。我们通过对比当前计数器的值来判断是否还有下一个元素。我们需要在别人调用next方法时产生下一个元素。Sequence使用协程来产生元素：sequence { for (i in Int.MIN_VALUE..Int.MAX_VALUE) { yield(i) } }这里的yield是sequence {}函数提供的，它是一个suspend装饰的函数，换句话说它是可以被挂起的。这里我们并不需要考虑别人是否调用了next，我们只需要不停的产出下一个元素。在代码执行时，我们在产出第一个元素的时候（调用yield那块），我们的代码执行就会被暂停，直到我们产出的这个元素被使用了（类似于别人调用了next）。使用之后我们的代码继续执行，产出下一个元素（下一次调用yield），然后又被暂停了，等待这个圆度被消耗（别人调用next）。在整个过程中，我们不需要考虑别人什么时候调用next，我们只需要考虑怎么产出下一个元素。在实现上，Sequence和Iterator并没有太大的差距。Iterator的hasNext实际上就是执行sequence里面的方法体，直到方法体调用了一个yield或退出。如果调用了yield，那么就还有下一个；如果退出了，说明方法体执行完毕，没有下一个了。尽管二者在底层实现上类似，但在概念上完全不同。编写Iterator的时候我们需要考虑别人调用的时机，为了避免数据竞争，我使用了AtomicInteger；但编写Sequence的时候就完全不需要考虑，我只想着如何产生这么多元素就是了。而产生一个连续的数列，最简单的方法就是用for循环。你看，两种完全不同的思路，完全不同的实现方法，但实现了完全一样的功能。这就是协程的力量。但是脑子尖的读者可能就要问了：Sequence这么好，那flow又是个什么东西？问得好，下次不许问了。Sequence虽好，但它毕竟是发生在一个线程里的事情。别人调用我们的Sequence时，是一个线程在执行不同的代码。我们管调用我们的角色叫消费者好了。在一个线程上，消费者调用了我们的sequence，于是线程开始执行我们的代码，我们的代码调用了yield，于是线程转而继续调用消费者的代码；消费者再次调用我们的sequence请求下一个元素，这时线程暂停执行消费者的代码，转而执行我们的代码来产生下一个元素。这个过程虽然精妙，但它只发生在一个线程上。幸运的是，产生数字并不需要花费太多时间。但请求API就不一样了。例如我们需要在UI上显示一个列表，这个列表的值源于一个API。如果使用Sequence的话，当UI开始渲染，不断请求元素的时候，负责更新UI的线程就会开始执行我们的代码，请求API。由于涉及到网络IO，在没有得到相应之前，更新UI的线程就会被阻塞，直到API响应，我们的代码将其解析成UI需要的数据，然后执行权回到UI部分。你猜猜看更新UI的线程被阻塞会发生什么事情？更新UI的线程被阻塞，不再响应操作系统的请求，对于用户来说就是APP假死、未响应。如果是了解底细的用户可能会耐心等待，但是不明所以的用户遇到假死的APP，第一反应就是结束应用，这样一来，你的APP看起来就像是一点开下拉列表就假死。很明显，这不是好的用户体验。那有没有办法在Sequence中使用多线程呢？很遗憾，不能。但幸运的是，我们不止有Sequence，我们还有Flow。Flow是一个冷数据流，我更愿意叫他可重复数据流。和Sequence一样，我们可以随时从头生成这些数据，但是和Sequence不同的是，我们可以使用多线程（Kotlin协程）：flow { for (i in Int.MIN_VALUE..Int.MAX_VALUE) { emit(i) delay(2000) } }在flow中，我们可以使用其他suspend装饰的函数了。也就是说，我们可以不只局限于生产者和消费者的代码了。从逻辑上说，别人调用我们的flow，我们可以通过emit来产生下一个元素，同时还可以调用delay来将代码的执行权转移到别的地方，比如在IO线程上执行HTTP请求，或者在其他线程上执行数据库操作。当然，使用flow的另一个好处就是取消。这里我们没有用到，但是取消是一个非常好的特性。以HTTP请求来说，我们不可能无限长时间的等待响应，所以我们应该设定一个超时时间：无论我们有没有获取到全部数据，超过了这个时间，就不再发送HTTP请求了。通过配合withTimeoutOrNull，使用Flow可以轻易的做到这一点。关于Kotlin协程，我这里只是片面的说了些皮毛。使用Flow还有其他很多好处，例如buffer允许生产者不受限制地产生数据，可以被多线程地处理等。如果你想深入了解Kotlin协程，这里我推荐阅读霍丙乾编写的《深入理解Kotlin协程》。P.S.：霍老师记得给我打钱好像说的有点多了，说回我们的MementoRepository，它的实现如下：class MementoRepositoryRetrofitImpl @Inject constructor( private val service: MementoService, private val config: ConfigRepository ) : MementoRepository { private val tag = "MementoRepositoryRetrofitImpl" private suspend fun getToken(): String = config.getConfigByKey(SettingsKey.MEMENTO_API_KEY.key)?.value ?: "" override suspend fun listLibraries(): List<LibraryBrief> = service.listLibraries(getToken()).libraries.map { LibraryBrief.fromDto(it) } override suspend fun getLibraryFields(libraryId: String): List<LibraryField> = service.getLibraryInfo(libraryId, getToken()).fields.map { LibraryField.fromDto(it) } override suspend fun listEntriesByLibraryId( libraryId: String, pageSize: Int?, fields: String?, startRevision: Int?, ): Flow<List<EntryDto>> { val token = getToken() return flow { var pageToken: String? = null do { val r = try { service.listEntriesByLibraryId( libraryId = libraryId, token = token, pageSize = pageSize, pageToken = pageToken, fields = fields, startRevision = startRevision ) } catch (e: IOException) { Log.e(tag, "Error when fetching entries: $libraryId, $pageToken", e) delay(5000) continue } catch (e: HttpException) { Log.e(tag, "Error when fetching entries: $libraryId, $pageToken", e) delay(5000) continue } val t = System.currentTimeMillis() pageToken = r.nextPageToken emit(r.entries) val dt = System.currentTimeMillis() - t // the api has rate limit of 30 request per minute // thus wait 2s for each request if (dt < 3000) { delay(3000 - dt) } } while (pageToken != null) } } override suspend fun updateEntryByLibraryIdAndEntryId( libraryId: String, entryId: String, updateEntryDto: UpdateEntryDto ): EntryDto = service.updateEntryByLibraryIdAndEntryId( libraryId = libraryId, entryId = entryId, updateEntryDto = updateEntryDto, token = getToken() ) }其他函数基本上都是直接调用了HTTP请求，唯独listEntriesByLibraryId使用了flow。我这里并没有使用太花哨的用法，就是简单的记录nextPageToken，然后不断请求下一页。关于错误处理也比较盲目，我简单的假设错误是因为遇到了rate limit，所以出错之后先等几秒钟，然后直接重试。如果获取到了数据，考虑到emit会阻塞线程，所以请求成功那一刻先记录时间，然后从emit处回复执行后再看一次时间，保证继续下一次请求之前等够3秒钟。总结虽然还有很多想介绍的内容，但是篇幅有限，只能将余下的内容放在另一篇文章中了。本次介绍了APP使用的技术和整体架构，随后又说了说数据源的设计。在说明API部分的时候额外多说了一些Kotlin协程的东西。这个在Java里是没有语言级的支持的，需要开发者使用诸如RxJava这些库来做到。而Kotlin中原生提供了协程的支持（suspend关键字），因此在IDE上也有良好的支持（当你看到左边代码行数那块出现一个箭头，你就知道这是一个挂起函数了）。我很喜欢这种简介且自成一体的设计，这可能也是最近几年我一直使用Kotlin的原因。总之，这一次先写到这里。下一篇文章我想简单说一说安卓上的依赖注入，然后说说业务上的设计。-全文完-
【代码札记】从零开始的安卓应用开发 - P0
虽然我以前也尝试过开发安卓APP，但最后都没能成功。好在这一次成功了，姑且就算它是第一次吧。我想写一个个人物品管理的APP。事情的起因源自于我时常找不到曾经买过的东西——这并非我丢三落四，而是我每年都要搬迁几次，每次搬迁都要把自己的东西打包到箱子里。然而安定下来之后，我不会一下子都把他们拿出来，如果没有用到的，那就留在箱子里，这样一来下次搬迁就不用动了。但是几次搬迁累积下来，基本上我也不知道什么东西在哪个箱子里了，尤其是之前没用到的。所以每次找东西都得挨个箱子翻找，搞得一团乱，有时候找到一般就很急躁地不想再找了。有一天我接快递的时候看到快递盒子上面贴的面单，这无意间提醒我了：我可以在箱子上贴一个二维码，然后扫一下不就知道里面是什么了。所以我在春节期间糊了一个APP，也就是本文所介绍的APP的第一个版本。这个版本使用了我之前折腾过的喵喵机P2，通过蓝牙调用打印机在热敏标签纸上打印一个二维码，这个二维码是一个UUID，每个盒子对应这么一个UUID，然后APP提供了简单的日志功能，扫描一个UUID就可以针对这个UUID按时间顺序添加备注。这个备注相对比较自由，你可以写放进去了什么东西，里面有什么，拿出来了什么，把箱子搬到了什么地方之类的。找东西的时候直接全文搜索，找到与关键词相关的备注，继而找到与备注相关的箱子。箱子上的标签除了二维码，还有文字版的UUID方便寻找。总第来说，第一版APP构造十分简单，易用性不高，但足够使用。第一版APP是我一边看文档一边凭感觉写的，使用了Jetpack Compose，没有XML的日子是真的爽啊！第一版的APP在代码上可谓是非常简陋了：只有一个数据库实体，一些抽象出来的类，剩下的页面逻辑就全都放在Activity里面了。那个时候的我也不知道什么MVC、MVVM这些东西，反正就硬写，能用就行。但话是这么说，虽然它在功能上满足了一个最小全功能集，但是这就好比，Java能做的事情汇编也能做，但没有人会用汇编来写复杂的业务。这个第一版也是一样。假如说你把一个物品从A箱子移动到B箱子，你就得先扫A箱子，记上我把东西拿出来了，或者删掉这个东西的备注，然后再扫B箱子，记上我把这个东西放进去了。如果期间疏忽大意了，很可能这个东西就在系统里找不到了，或者在系统里同时出现在多个箱子里，归根到底，还是没有彻底解决问题。我对于这个APP并不满意。首先它不够实用，其次我好像也没学到什么，就是花费了时间和精力编写了一个赛博垃圾。于是我开始寻找现有的解决方案。大部分我见到的解决方案都是针对商用场景的，比如IT资产管理或者供应链、库存管理等。对于我的需求太说，这就像是开宝马去菜市场买菜，实在是大可不必。后来我找到了两个相对合适的APP。第一个是HouseBook，看起来这个APP是由一个独立开发者编写的。这个APP给我带来了不少启发，例如多个实体类型：房屋、位置、容器、物品，其中房屋是最顶级的单位，一个房屋可以有多个位置，一个位置下面可以有容器或物品，而一个容器里也可以有子容器。同时它也提供了一种比较方面的交互界面：容器和物品可以与二维码绑定，容器可以有名字，物品可以有固定字段，例如品牌、型号、序列号、保质期等。在搜索方面也比较方便：可以使用文字搜索相关的容器或物品，也可以直接扫描二维码搜索对应的实体。我虽然很喜欢它的灵活性，但在物品字段方面我认为有些死板了：你只能使用应用中定死的，而不能自己新增字段。同时这个APP是付费的，需要一次性支付30USD来解锁全部功能，同时使用云同步的话，随着物品数量的增多，你可能还需要额外花钱购买云空间。不过这些都是一次性消费，没有持续订阅。第二个APP严格来说不算是物品管理，而是一个通用的个人数据库，叫做Memento Database，背后是一个商业公司在支持和维护（至少看起来像是，也许这个公司就只有开发者一个人也说不准）。同时这个APP支持安卓、iOS和桌面版，其中桌面版是Java写的，因此支持全平台。不过要跨平台使用，就需要订阅他们的高级计划。如果只是在一台设备上使用的话，那么不订阅也是可以享受到大部分功能的。这个App以库（Library）来组织数据，以数据库的语言来说，这个库其实就是一个表。你可以在表中定义字段，但是不需要像关系型数据库那么严格——你的表甚至可以没有唯一字段（因为每一个记录有一个内部的entity id）。同时在字段类型的选择上也非常丰富：字符串、整数、小数这些就不说了，什么布尔啊货币啊时间啊，这些都比较常见。他们还提供了条码字段，可以直接扫描条码来读取其中的信息，同时还提供了列表、树、标签、外键、自定义计算（基于其他字段计算出结果），如果这些还不能满足你的话，他们还支持JavaScript脚本。可以说是非常强大了。后来经过一番探索，他们还提供了一个云API用来操作开启云同步的库和数据，这一下子可玩性就高了啊。基于这种灵活度，我决定使用Memento Database，配合我自己开发的APP来进行操作。虽然这两个APP在输入上都提供了非常好的交互方式，但输出上面还是差了一点。虽然他们都支持扫描条码，但是条码得先打印才能扫描啊。与此同时，我受够了喵喵机的封闭，开始寻找商业化的便携打印机。这次我购买了佳博的M322便携打印机，它的打印质量非常一般（没办法打印精细图片，我尝试了调整曲线和交错打印——是的，通过控制打印机把纸倒回来，我可以分多次打印一幅画，但倒纸的精确度有待商榷——都没有得到满意的结果），只能用来打标签（毕竟人家是标签打印机，没说是热敏图片打印机），但和喵喵机不同，它具有多种传感器，也就是说我们可以使用正经的标签纸，而不是连续纸了。不过归根结底，这个打印机最最重要的一点是厂家提供技术手册：你可以使用SDK（或者和我一样自己写SDK）光明正大的和打印机交互，而不必考虑什么逆向工程之类的东西。总之，在第二个版本中，我的思路是将数据存到Memento Database中，然后利用我的APP与之交互。大体的使用场景就是：先在我的APP中调用打印机打印条码标签，然后在Memento中添加这个标签，与盒子或物品做绑定，记录信息。需要查询的时候就直接在Memento中搜索或扫码。如果需要移动箱子或物品的话，可以在我的APP中选定好目的地位置或箱子，然后连续扫码，将扫到的物品或箱子通过API移动到目的地。这一套组合下载，堪比商用解决方案，岂不美哉？当然了，这个系列的文章是站在事后诸葛亮的角度上写的。其实一开始我也不知道第二版应该怎么做。就是觉得第一版不够好，连我自己都不想用，那肯定是要改。第二版就参照着HouseBook和Memento Database，一边用一边找思路，最终摸索成现在的模样。此外，由于篇幅有限，我想把这个作为一个系列来写，这一次是序言，下一次会介绍整体架构和使用的技术，如果还有篇幅的话就介绍一下数据库设计，再之后是标签设计和蓝牙打印，然后是联动Memento的API，最后说一说UI设计。序言部分暂且先告一段落，我们下一篇文章见。-全文完-
不知道出于何种原因，我突然决定自己造轮子。索性这个轮子造的好像还不是特别没用。本文将记述我从零开始使用Kotlin实现一个Bencode编解码库。前言Java和Kotlin（JVM）具有比较好的互操作性，即便没有给Kotlin设计的库，如果有相应的Java库，在Kotlin上也是可以直接调用的。在此之前我尝试过com.dampcake:bencode这个库，用起来感觉还不错，但它的操作是类似DOM那种，即所有写入和读出的数据全部存在内存里以供操作。这种操作方式固然简单好用，但是如果你有很多数据的话，你可能更希望采用接近XML SAX的方式。后者是基于流的解析方案，也就是说解析器一边读入数据，一边将解析出来的结果交给你的代码。你可以选择将他们收集起来组装成DOM，也可以选择即时处理然后回收。本文将考虑如何构建一个基于流的Bencode编解码库。BencodeBencode好像是一种不太常见的编码。考虑到早年间的XML，以及现在的Json，好像Bencode并没有什么用武之地？但作为一个P2P爱好者和版权左人，大名鼎鼎的BitTorrent协议就是用Bencode编码数据。这种编码形式以字符为基础，肯定不如ProtocolBuf这种二进制编码高效，同时它的结构比较简单，不太可能像XML或Json在功能上那么丰富（Bencode甚至不支持编码小数）。但从某种意义上来说，Bencode确实是最简单且紧凑的格式。举个例子。在Json中表示一个结构是这样的：{ "number": 1234, "string": "hahaha", "list": [ "string in list", 2345, [ "list in list!" ], { "name": "object in array!" } ], "obj": { "string": "An object", "list": [ "List in object!" ], "dict": { "num": 2345 } } }去掉所有多余的空格，一共是195个字符。如果表示成XML，那就更罗嗦了：<?xml version="1.0" encoding="UTF-8" ?> <root> <number>1234</number> <string>hahaha</string> string in list 2345 <2>list in list!</2> <name>object in array!</name> <obj> <string>An object</string> List in object! <dict> <num>2345</num> </dict> </obj> </root>XML需要295个字符，而且它还没有保留list in list的部分。这可能是我是用的Json到XML转换器有问题，也可能是XML并没有名且规定列表的格式。如果将上述数据转换成Bencode，那就显得紧凑了不少：d4:listl14:string in listi2345el13:list in list!ed4:name16:object in array!ee6:numberi1234e3:objd4:dictd3:numi2345ee4:listl15:List in object!e6:string9:An objecte6:string6:hahahaeBencode只要179个字符。而这段数据的所有文本内容需要125个字符（数字由对应的字符表示，且丢弃了所有结构信息）。当然，如果只从格式上看的话，Bencode不是那么的“人类友好”，但这无伤大雅，我对于Bencode的主要用途是简单地导入和导出结构化数据，而不必考虑给人看。说了这么多，不如我们来看看Bencode的编码规则。Bencode的编码规则比较简单：Bencode使用ASCII字符作为分隔符和元素对于数字，采用i42e的形式来编码，其中42是数字42对应的十进制字符串。数字不允许前导0（即00042是非法的）。负数使用-作为前缀，即i-42e，但不允许负0对于字节串（字节序列，并不一定是字符），采用4:spam的形式来编码。其中4表示这个字节串的长度，采用十进制字符串表示，冒号表示分割，随后spam是字节串的实际内容。Bencode并没有提及数据无法被编码成ASCII时应当如何处理，在一些具体实现中，人们主要采用UTF-8来应对其他字符，或使用ISO 8859-1来编码字节数据，亦或采用Base64等编码将字节串编码为ASCII字符串一个列表由l<content>e表示。一个具有字符串“spam”和数字42的列表应当被表示为l4:spami42ee。其中首个字符是小写字母L，不是数字1。列表内的元素类型不受限制。一个字典由d<content>e表示。在字典中，字符串作为键后面总是直接跟着值。所有键必须按照字典序进行排列。例如字典{"bar": "spam", "foo": 42应当被表示成d3:bar4:spam3:fooi42ee。字典的值的类型不受限制。通过上述这几条规则，基本上就没有Bencode表示不出来的结构。同时字典规定了键的排序，保证了编码结果的唯一性。流式编码上面说了半天编码，不如我们就从编码入手，看看怎么基于流来编码。编码器的输入往小了说就是那四个基本类型：整数、字符串、列表和字典，对应Java里面的BigInteger、String、List<*>和Map<String, *>。因为Bencode并没有规定整数的最大上限，因此使用BigInteger来存储整数。整数和字符串对于整数和字符串来说，流失编码好像没有太多的意义：除非一个整数或字符串占用了以G计算的内存，你可能需要流式处理，不然没有必要把他们拆开——就算拆开，字符串还好说，整数怎么拆呢？这个就需要看具体的应用场景了，而作为一个通用的库来说，我们没有办法提供一个不过度复杂同时还通用的机制。因此这两个元素没有什么流式不流式的说法。列表对于列表就不一样。这里我们通过循环的方式来模拟对列表内数据的访问和写入。如果我们有400万个Int，他们在内存中大约占用16MB的内存。如果我们将他们转换成BigInteger，都不使用List，就用最紧凑的数据，在128MB的内存限制（-Xmx128M）下，程序直接OOM了。 val array = IntArray(400_0000) { it } val list = Array(400_0000) { array[it].toBigInteger() } println(list.count())即便没有OOM，这个list所占用的额外内存也必须等待所有数据写完之后才会被垃圾回收——只要编码器还没写完，对于GC来说这个列表就是可达的，那么每一个元素都无法被回收，即便他们的一部分已经被写入了。好在Kotlin提供了Sequence这个东西。Sequence的好处就是可以根据需求（on-demand）产生数据。也就是说，我通过循环的方式统计个数，而每次我只需要一个数据，处理完成之后这个数据就可以被GC回收了。使用Sequence，整个程序只占用了22MB的内存： val array = IntArray(400_0000) { it } val seq = sequence { for (i in array) yield(i.toBigInteger()) } println(seq.count())我们只要使用Sequence，就可以保证数据只有被编码器需要的时候才会产生。这不就是流式处理吗？字典下面我们来看看字典。我们可以借鉴列表的经验，但是Sequence有一个问题：除非提供者保证，否则没法确保数据是有序的，而我们的字典在编码时要求键必须按照字典序排列。要么我们就接受用户的输入，如果检测到违反了字典序，就抛出异常；要么我们就想办法自己构建一个类似Sequence的Map。好在我并不是第一个遇到这种问题的开发者。在Java中就提供了Supplier这个接口，而Kotlin中也有Lazy这种能够提供延迟求值的工具。但最简单的还是一个Lambda：() -> T，只要我们将Map的类型设置成Map<String, () -> T>就可以了。但是这个T怎么取呢？我们的元素有BigInteger、String、List和Map，且不说前两个没有共同的父类，后两者还有泛型擦除的问题，很显然编译器并不能要求传入的List必须是List<String>，而不能是List<UUID>。在传统的Java开发中，这个时候我们就应该已经写出编码器的接口，同时还有BencodeSerializable接口了，但是我个人并不太喜欢接口满天飞。不过在这种情况下，接口可能是无法避免的：sealed interface BEntry { data class BInteger(val value: BigInteger) : BEntry data class BString(val value: String) : BEntry data class BList(val value: Sequence<BEntry>) : BEntry data class BMap(val value: Map<String, () -> BEntry>) : BEntry }由于我们接口下面的实现是已知的，毕竟Bencode中就规定了四种元素，因此完全可以使用密封类来修饰接口。这样，当我们使用BEntry作为when的参数时，编译器就会知道，这个参数只有四种可能的情况，而我们上面的T也就可以取值为BEntry了。编码器既然有了这个BEntry，我们就可以面向这个接口设计编码器了。我们的BencodeEncoder在构建时只需要一个Writer，这个对象负责处理字符的写入，而无需我们考虑字符集编码这些问题。关于成员函数，由于我们的BEntry设计的很清晰，因此只需要一个fun writeEntry(entry: BEntry): Unit就好了。为了方便维护，我们可以针对不同的类型设计不同的私有函数，将writeEntry作为一个集中分派的入口： fun writeEntry(entry: BEntry) = synchronized(writer) { when (entry) { is BEntry.BInteger -> writeBInteger(entry) is BEntry.BString -> writeBString(entry) is BEntry.BList -> writeBList(entry) is BEntry.BMap -> writeBMap(entry) } }这里我们对writer加了锁，这是避免在多线程使用时造成冲突。例如一个列表正在输入，另一个线程突然开始写字典了，很有可能就会得到类似字典把列表截断，而列表又把字典截断的情况。对于四种类型的写入也比较简单： private fun writeBInteger(entry: BEntry.BInteger) { writer.write("i") writer.write(entry.value.toString(10)) writer.write("e") } private fun writeBString(entry: BEntry.BString) { writer.write(entry.value.length.toString()) writer.write(":") writer.write(entry.value) } private fun writeBList(entry: BEntry.BList) { writer.write("l") entry.value.forEach { writeEntry(it) } writer.write("e") } private fun writeBMap(entry: BEntry.BMap) { writer.write("d") entry.value.keys.sorted().forEach { k -> writeBString(BEntry.BString(k)) writeEntry(entry.value[k]!!()) } writer.write("e") }像是字典的键，我们知道它的类型，就可以直接调用writeBString，而对于值，我们并不知道它的类型，因此较为writeEntry进行分派。Writer有了编码器，但好像还不太够：这个编码器只能面向我们的BEntry工作。如果用户想要编码一个Map，还必须得转换成我们的BEntry.BMap，有点麻烦。如果我们能直接编码Map对象，甚至任意的对象呢？我想到了两个思路。第一个思路比较简单粗暴：提供一个接口，让用户可以实现这个接口，从而提供一个BEntry出来：interface BencodeEncodable { fun encodeToBEntry(): BEntry }使用的时候看起来是这样的：data class Point( val x: Int, val y: Int ) : BencodeEncodable { override fun encodeToBEntry(): BEntry = BEntry.BMap(mapOf( "x" to { BEntry.BInteger(this.x.toBigInteger()) }, "y" to { BEntry.BInteger(this.y.toBigInteger()) } )) }第二个思路则相对灵活一些：在实例化Writer的时候提供一系列mapper，他们将对应类型的对象转换为BEntry。如果更进一步扩展这个概念，mapper可以把writer不认识的类型转换成它认识的类型。例如上面那个Point类的mapper可以是：val mapper = BencodeEncoderMapper<Point> { mapOf( "x" to it.x, "y" to it.y ) }你会发现这个Mapper的返回值并不是BEntry，而是一个Map<String, Int>。而我们的Writer可以把这个Map转换成对应的BMap：只需要将值映射为{ it.value }就可以了。而完成转换之后，这个mapper返回的map就会被丢弃，从而可以被垃圾回收。不过在Mapper中不能返回lambda，因为JVM在运行的时候比较难判断一个值到底应该被包装成lambda，还是要作为lambda放进Map。Kotlin在编译时并没有保证两个类型相同的Lambda对于JVM就一定是同一个类型的。因此同样都是() -> BEntry，他们可能是两个不同的类。当然，使用反射可以解决这个问题，查看一个对象是否具有invoke()的方法，并且返回值不是Unit或Nothing，但我本意是想避免这些黑魔法的。另外还需要注意这两种思路的细微差别。对于实现接口来说，因为BEntry要求使用Supplier，所以BEntry.BInteger(this.x.toBigInteger())这一行只有在写入了键x之后才会被调用，可以认为获取到的值就是最新值了。而Mapper虽然也是在写入时调用，在Writer调用完成后，便得到了一个快照（因为在JVM上，对于Int的赋值是值复制，不是引用复制），也就是说，在Writer调用完成后、Encoder写入完成前，无论原来的Point.x怎么变，写入的内容都不会变了（在Encoder中调用的Supplier实际上只返回一个Int，这个Int是mapper中对it.x的求值）。当然，如果对于数据类都是用不可变类型，那自然是极好的。如果涉及可变类型，那除了保证内存可见性之外，还要考虑将对象转换成BEntry的时机对于编码结果的影响。总之，在这个库里面我实现了这两种思路，如果是自己编写类的话，建议使用第一种；如果是别人的类，那么就可以考虑使用Mapper在序列化时自动转换。关于这部分的代码也比较简单，Writer的主要入口： fun write(obj: Any): Unit = encoder.writeEntry(mapToBEntry(obj))而负责转换的函数实际上就是一个lookup table： private fun mapToBEntry(obj: Any): BEntry = when (obj) { is BEntry -> obj is BencodeEncodable -> obj.encodeToBEntry() // for signed value, convert to long is Byte -> BEntry.BInteger(obj.toLong().toBigInteger()) is Short -> BEntry.BInteger(obj.toLong().toBigInteger()) is Int -> BEntry.BInteger(obj.toLong().toBigInteger()) is Long -> BEntry.BInteger(obj.toBigInteger()) // for unsigned value, convert to long so the value is same is UByte -> BEntry.BInteger(obj.toLong().toBigInteger()) is UShort -> BEntry.BInteger(obj.toLong().toBigInteger()) is UInt -> BEntry.BInteger(obj.toLong().toBigInteger()) // for ULong, it's bigger than long, thus turn it into string is ULong -> BEntry.BInteger(BigInteger(obj.toString())) // for chat and string, just write string is Char -> BEntry.BString(obj + "") is String -> BEntry.BString(obj) // for list, transform it to sequence is List<*> -> BEntry.BList(obj.asSequence().map { mapToBEntry(it!!) }) // for map, map the key to string and map value to BEntry is Map<*, *> -> BEntry.BMap(obj.mapKeys { if (it.key is String) it.key as String else it.key.toString() }.mapValues { { mapToBEntry(it.value!!) } }) // for general object, search mapper and apply the transform else -> ((mapper.find { it.canHandle(obj) } ?: error("Mapper not found: ${obj.javaClass}")) as BencodeEncoderMapper<Any>) .invoke(obj) .let { if (it is BEntry) it else mapToBEntry(it) } }关于Mapper的设计，也比较简单：interface BencodeEncoderMapper<T> { fun canHandle(obj: Any): Boolean fun invoke(obj: T): Any }这个canHandle是用来询问一个mapper实例能否处理这个类。因为运行时的泛型擦除，Writer除了知道这是一个Mapper之外，并不知道它所实现的泛型是什么，更不要说什么类型间的继承应该如何处理了——有时候子类可以按照父类处理，有时候子类需要它独有的处理方式。当然，每次实例化Mapper都需要写一个object : Interface { ... }都怪臃肿的，而且这个接口有两个函数，没法用SAM。但是啊，我们可以用同名函数来实现简化：inline fun <reified T> BencodeEncoderMapper(crossinline block: (T) -> Any) = object : BencodeEncoderMapper<T> { override fun canHandle(obj: Any): Boolean = T::class.java.isInstance(obj) override fun invoke(obj: T): Any = block(obj) }当然了，简化的代价就是要服从一些约定。这里关于能否处理的判断，是调用了泛型类的isInstance方法。得益于Kotlin的inline和reified，我们可以在内联函数中访问泛型的具体类型。这也是实现第二个思路的关键。如果要我在Java中实现类似的机制，我肯定要舍弃掉第二个思路——无论使写起来还是用起来，都太臃肿了。写到这里突然想到，使用Mapper可能会出现成环的情况。比如说一个类型A的Mapper返回了类型B，而类型B的Mapper又返回了类型A，这样不停的递归调用就会导致爆栈。不过话又说回来，这样成环肯定说明这家伙的数据类型设计的很不合理，总之不是我的锅就对了。解码说完了编码，再说说解码。你会发现说解码的时候我可没提流式。我在不同的设计方案中思考了半天，最终决定像编码那样提供两个层级的API。底层可以是流式的，但顶层就比较困难了。解码意味着要处理一系列的输入流，而若像编码那样使用Supplier按需解码，就需要解码器能够知道要跳过多少字符以便解析下一个元素，同时还能倒回某一处，在需要的时候解码以前跳过的内容。这对于Reader来说实在是强人所难了。但是这种限制并不意味着我们必须把一个元素完整的解析到内存中。我们可以根据当前的字符构造状态机啊！例如我们读到一个字符i，我们就知道下一个元素应该是一个整数；如果读到1~9中的一个，那么下一个肯定是字符串；类似地，如果读到l或者d，那么下一个肯定是列表或字典。而且他们的终止符都是成双成对的，只要输入的是合法的数据，就不会出问题。举个例子：data class Person( val name: String, val age: Int, val friends: List<Person>, val attributes: Map<String, String> )我们在读取的时候可以构建一个Builder，根据读到的内容填充builder。例如说：在开始之前应该预期一个字符d，因为对象将被处理成字典接下来应该预期字符1~9中的一个，因为字典中总是键值的顺序，而键是字符串根据读到的键，预期不同类型的数据例如读到了name，就应该预期一个字符串读到age，就预期一个整数如果是friends，那就调用处理List<Person>的函数，循环调用当前函数以读取Person列表，最后将读出的列表返回当遇到字符e的时候，检查这个Builder是否已经准备好build了。如果还有缺失数据，说明输入的有问题显而易见地，这是个递归结构。如果你怕爆栈的话，改成链表+循环也可以。但脑子尖的读者应该已经注意到了：这个friends对应的列表，并没有流式解析啊，反倒是只有最顶层的Person是流式解析，而它的内容倒都变成DOM形式了。对，你说得都对。这个问题我也思考了很久，最后得出来的结论就是以实际需求为准。我上面那个例子是根据输入还原对象，也就是说无论如何我们都是避免不了DOM形式的。但如果说你是想即时更新数据库，那相比之下就简单了不少：你只需要在读到id之前暂存数据，读到id之后直接采用多个UPDATE语句的形式，读一个就产生一条语句，然后丢弃结果。总之在这方面，我尽力了。如果一个对象不大的话，那么以对象为单位进行DOM形式的操作，而总体还是流式的话，我反正是能接受的。解码器关于解码部分的代码这里就不多说了，只介绍一下API。较为底层的API是BencodeDecoder，实例化时需要一个Reader来读入字符，它总共有如下方法： fun hasNext(): Boolean fun nextType(): BEntryType fun readInteger(): BigInteger fun readString(): String fun startList(): Unit fun startMap(): Unit fun endEntity(): Unit这其中，hasNext不必多说，就是看Reader是否已经被处理干净了。如果还没有，那么就读入一个字符。而后nextType则是根据当前读入的字符来判断类型。这里你会发现我没有用BEntry，一来是它的实现都不是Object，没法直接用，二来是解码时l和d表示列表和字典的开始，而e则单独表示一个实体的结束，没办法与四种类型对应。因此就额外引入了一个Type类，它也是密封接口，而它的实现，除了EntityEnd比较特殊，其他的都是BEntry的四个实现的companion object，也就是说我们可以直接写BEntry.BList来表示一个BEntryType，我觉得很是优雅。后面两个read就不用说了——直接读取对应类型的值，读完之后还要读入下一个字符，以便后续调用hasNext来判断。而至于列表和字典，这个start函数只是检查一下当前的字符是否正确。例如startList会检查当前字符是不是l，不是的话就会抛出异常，避免错误地开启列表。剩下的startMap和endEntity也是类似的操作。Reader而较高等级的API则提供了一种近似DOM方式的API：class BencodeReader( reader: Reader ) { fun hasNext(): Boolean fun nextType(): BEntryType fun readInteger(): BigInteger fun readString(): String fun readList(): List<*> fun iterateList(callback: Consumer<Any>) fun readMap(): Map<String, *> fun iterateMap(callback: BiConsumer<String, Any>) } 前两个就不多说了，唯一值得一提的就是nextType，因为读出来就是完整的对象，所以不存在EntityEnd这种类型。后面的各种read也不必多说，读出来就是对象。这里额外提供了两个iterate方法，为的是流式遍历顶层对象。比如说一个输入是几百万个小对象组成的列表，那么iterateList就会每次解析一个对象，然后交给callback处理，处理完成后再解析下一个。字典也是一样，每次解析一对键值，然后交给callback处理。后记至此这整个库的设计思路就介绍完了。这个库本身比较简单，不像Ktorm那种复杂的库。但碍于我没有设计库的经验，在设计的过程中考虑了好多有用的和没用的细节，以至于文章写起来很罗嗦。另外写文章的时候突然意识到一些问题，就把文章扔到一边修改代码去了，改完代码再接着写文章，难免会有些思路上的跳脱。好在这又不是出版书籍，有点问题就有点问题了。也许有经验的读者看到我这边文章会说：就设计一个编解码的库，这孙子怎么写的那么啰嗦，这不脱了裤子放屁吗？有的地方还狗屁不通，这写的啥啊这是？我奶奶用脚写的都比这好对，你说的都对，不爱看滚蛋。不过还是要感谢一下能耐心读到结尾的读者，感谢你们的耐心。关于这个库，我已经开源到了GitHub上，可以在JitPack上使用相应的制品。-全文完-
本文记述我使用Kotlin在JVM上实现Conway's Game of Life。游戏规则康威生命游戏并不是什么新鲜玩意儿了，它可以视作一个二维的棋盘，每一个格子被称为细胞（Cell），每一个细胞有两种状态：死亡或存活。每一轮迭代时，所有细胞同时更新状态，更新的规则如下：若细胞当前存活：当周围的存活细胞数量小于2时，这个细胞在迭代时死亡（因人烟稀少）当周围的存活细胞数量大于3时，这个细胞在迭代时死亡（因人口过剩）当周围有2个或3个存活的细胞时，这个细胞在迭代时继续存活若细胞当前死亡：当周围有且只有3个存活细胞时，这个细胞在迭代时复活（模拟繁殖）其他形况下，细胞继续保持死亡状态这个规则其实还挺简单的，并且它可以使用多线程进行运算。实现思路这个游戏的“世界”可以用一个二维数组表示，数组的元素就是一个布尔值，true表示存活，false表示死亡。每次更新细胞时先统计它周围的存活细胞，然后根据细胞的当前状态使用if else做判断，更新细胞状态。考虑到规则要求所有细胞同时更新，因此我们需要两个“世界”，一个表示当前的细胞状态，计算出下一状态后，将新的状态写入另一个世界，表示未来的状态。当所有细胞都产生了新状态后，交换这两个世界的指针，这样一来，未来的状态就成为了现在的状态，而原来的状态则成为记录未来状态的容器。不论使用什么语言，这种设计还都挺合理的对吧？在JVM上也一样，对吧？整体来说还挺对的，但有一些小问题。首先，JVM标准并没有规定一个布尔值应该占多少内存。可能是1比特，也可能是1字节，这完全取决于JVM的实现。如果JVM使用1比特，那么含有1024个元素的布尔数组的大小就是128字节；如果采用1字节表示一个布尔，那么同样大小的数组就要占用1KB。假如说我们要渲染一个1080P的康威游戏，1920乘以1080，一共是2073600（200万）个细胞，每个细胞占用1字节，这就是2MB的内存，而原本它可以只占用不到256KB的。当然了，好在JVM中其他类型的大小是固定的。你可以选择使用byte作为容器，这样一个byte就有8个bit，但鉴于kotlin的类型系统比较生硬，在做位运算的时候需要将byte转换成int，再将int转换为byte存回去，这样子很麻烦，所以我是用Int作为容器，每个Int可以存储32个比特，即便有浪费，也最多是最后一个Int没有完全使用，浪费的部分不超过4字节。熟悉BitSet的读者可能会说，诶呀，你说的这个不就是BitSet嘛！Java中提供了一个可变长度的BitSet，可以方便地做位运算。但这里还有一个问题：内存一致性。关于这一部分，最开始的构想是使用分段锁来保护不同位置，这样既保证了内存一致性，又保证不同位置的独写互不冲突。但是后来发现一个问题：写入一个状态需要读取九个状态，换句话说，如果读写都加锁（读需要加锁保证线程可以看到之前写的数据），那么更新一个细胞就要竞争10次锁，一旦竞争失败，synchronized关键字就会把竞争失败的线程转变为操作系统的阻塞态。这是非常耗费时间的。谈及多线程和内存一致性，包括我在内，第一反应可能都是用同步锁，无论是synchronized关键字还是同步代码块，总之，正确用锁就能保证安全。但是从性能角度来看，监视器锁可能是性能最差的（字节码中使用了monitorenter和monitorexit），因为它依赖于操作系统将竞争失败的线程阻塞，锁释放时再依赖操作系统唤醒线程。在这里，我们更新一个细胞需要读取9次，而每次都要竞争锁，如果竞争失败则需要在阻塞、唤醒上浪费时间。采用自旋锁（ReentrantLock）则能够避开线程调度。选好了锁，我们再想想，到底哪里需要锁？我们使用锁的目的就是利用它的顺序性，也就是所谓的happen before，即后进入锁的线程一定能够看到先前持有锁进程产生的修改——后来的线程在读取世界状态时，必须能够看到之前由多个线程计算出来的状态。可我们真的需要使用锁来保证这一点吗？除了锁，只读内存也是同步安全的，只读就是所谓的immutable，不可变对象。通过游戏规则可知，所有细胞必须同时更新，因此这不可能是就地更新，也就是说，我们从当前世界读取状态，然后将修改写入其他地方。如果当前世界是不可变的呢？这样一来，读取的时候我们就不需要锁了。读取的时候不需要锁并不意味着不需要锁了。上锁的时机从读取时提前到了创建对象时。计算下一步的状态仍然需要一个可修改的状态，我们仍然需要使用锁来写入，而读取的时候也需要锁来保证我们可以看到其他线程的修改。但如果我们在计算完成后抄写一份，我们只需要在这里上一次锁，然后将抄写的结果作为不可变对象使用，那么之后就不再需要上锁了。实现细节WorldMap“世界”的实现如下：package info.skyblond.pocog import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock class WorldMap private constructor( val height: Int, val width: Int, private val data: Array<IntArray> ) { init { require(height == data.size) { "Height doesn't match the data" } val wordWidth = width / Int.SIZE_BITS + if (width % Int.SIZE_BITS == 0) 0 else 1 for (i in 1 until data.size) { require(data[i].size == wordWidth) { "Rows have different width" } } } operator fun get(x: Int, y: Int): Boolean { require(x in 0 until width) { "x out of bound" } require(y in 0 until height) { "y out of bound" } val wordIndex = x / Int.SIZE_BITS val wordOffset = x % Int.SIZE_BITS return data[y][wordIndex] and (1 shl wordOffset) != 0 } class Builder( private val width: Int, private val height: Int ) { // ...... } } 这里可以看到，WorldMap的底层还是一个data数组，但是整个实现中都没有写入数组的代码。没有修改，也就不需要同步，自然也不需要锁了。而为了方便构造实例，我们在内部实现了一个Builder： class Builder( private val width: Int, private val height: Int ) { private val wordWidth = width / Int.SIZE_BITS + if (width % Int.SIZE_BITS == 0) 0 else 1 private val data = Array(height) { IntArray(wordWidth) } private val locks = Array(height) { ReentrantLock() } operator fun set(x: Int, y: Int, newValue: Boolean) { require(y in 0 until height) { "y out of bound" } require(x in 0 until width) { "x out of bound" } val wordIndex = x / Int.SIZE_BITS val wordOffset = x % Int.SIZE_BITS locks[y].withLock { if (newValue) setTrue(wordIndex, wordOffset, y) else setFalse(wordIndex, wordOffset, y) } } private fun setTrue(wordIndex: Int, wordOffset: Int, y: Int) { val mask = 1 shl wordOffset data[y][wordIndex] = data[y][wordIndex] or mask } private fun setFalse(wordIndex: Int, wordOffset: Int, y: Int) { val mask = (1 shl wordOffset).inv() data[y][wordIndex] = data[y][wordIndex] and mask } fun build(): WorldMap { val result = Array(height) { IntArray(wordWidth) } for (i in data.indices) { locks[i].withLock { System.arraycopy(data[i], 0, result[i], 0, wordWidth) } } return WorldMap(height, width, result) } }这个Builder不是线程安全的：当最终调用build时，程序会依次获取每一行的锁，将当前的结果拷贝到一个数组里。如果此时其他行发生了修改，那么就无法保证一致性了。至于选用行锁，单纯是因为可重入锁不涉及阻塞和唤醒，竞争失败的代价很小，同时操作也仅仅是读取、位运算并赋值，没什么耗时操作，因此粗略的选用行锁不会带来太大的性能惩罚。游戏游戏本身也不难实现，这里定义了一些基础的功能：package info.skyblond.pocog import java.util.* import java.util.concurrent.CompletableFuture import java.util.concurrent.ForkJoinPool import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock class ConwaysGame( val gameWidth: Int, val gameHeight: Int, val heightSubdivisionSize: Int, val parallelism: Int ) : AutoCloseable { private val lock = ReentrantLock() private val threadPool = ForkJoinPool(parallelism) val gameSize = gameWidth.toLong() * gameHeight.toLong() @Volatile private lateinit var cellStatus: WorldMap @Volatile private var nextCellStatus: WorldMap? = null fun reset(generator: (x: Int, y: Int) -> Boolean) { val builder = WorldMap.Builder(gameWidth, gameHeight) for (i in 0 until gameWidth) { for (j in 0 until gameHeight) { builder[i, j] = generator(i, j) } } cellStatus = builder.build() } fun countNeighbors(x: Int, y: Int): Int { var counter = 0 for (i in x - 1..x + 1) { for (j in y - 1..y + 1) { if (i == x && j == y) continue // count if in range if (i in 0 until gameWidth && j in 0 until gameHeight) { if (cellStatus[i, j]) counter++ } } } return counter } private fun updateCell(builder: WorldMap.Builder, x: Int, y: Int) { val currentStatus = cellStatus[x, y] val neighbor = countNeighbors(x, y) if (currentStatus) { // Any live cell with two or three live neighbours lives on to the next generation. // Any live cell with fewer than two live neighbours dies, as if by underpopulation. // Any live cell with more than three live neighbours dies, as if by overpopulation. builder[x, y] = neighbor in 2..3 } else { // Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction. builder[x, y] = neighbor == 3 } } private fun updateLine(builder: WorldMap.Builder, y1: Int, y2: Int) { // p1: top left, p2: bottom right for (j in y1 until y2.coerceAtMost(gameHeight)) { for (i in 0 until gameWidth) { updateCell(builder, i, j) } } } fun calculateNextTick(): Unit = lock.withLock { val builder = WorldMap.Builder(gameWidth, gameHeight) val futureList = LinkedList<CompletableFuture<Void>>() for (y in 0 until gameHeight step heightSubdivisionSize) { CompletableFuture.runAsync({ updateLine(builder, y, y + heightSubdivisionSize) }, threadPool).also { futureList.add(it) } } futureList.forEach { it.get() } nextCellStatus = builder.build() } fun useCellStatus( block: ((x: Int, y: Int, isNext: Boolean) -> Boolean) -> Unit ): Unit = lock.withLock { if (nextCellStatus == null) calculateNextTick() val func = fun(x: Int, y: Int, isNext: Boolean): Boolean = if (isNext) nextCellStatus!![x, y] else cellStatus[x, y] block.invoke(func) } /** * For debug only, make sure each cell is updated correctly. * */ fun checkResult() { useCellStatus { query -> // check result for (y in 0 until gameHeight) { for (x in 0 until gameWidth) { val current = query(x, y, false) val next = query(x, y, true) val nc = countNeighbors(x, y) if (current) { // current alive if (next) check(nc == 2 || nc == 3) { "($x, $y) should die, but alive, nc: $nc" } // still alive else check(nc < 2 || nc > 3) { "($x, $y) should alive, but die, nc: $nc" } // die next tick } else { // current die if (next) check(nc == 3) { "($x, $y) shouldn't alive, but did, nc: $nc" } // alive next tick else check(nc != 3) { "($x, $y) should alive, but didn't, nc: $nc" } // still die } } } } } fun swapToNextTick(): Unit = lock.withLock { // done update, swap status if (nextCellStatus == null) calculateNextTick() cellStatus = nextCellStatus!! nextCellStatus = null } override fun close(): Unit = lock.withLock { threadPool.shutdown() } }应该都比较直观。其中reset提供了一种设置游戏初始状态的方式，updateCell用于更新一个细胞，而updateArea则用于更新一个举行范围内的细胞。calculateNextTick函数根据创建实例的参数，分区域指派任务，这个任务由线程池执行，所有计算执行完毕后函数返回。接下来用户可以使用useCellStatus来访问细胞状态。你会发现这几个函数都是同步的，这是保证这些操作是互斥的/顺序的。换句话说，你得先等着状态计算完毕，才能使用状态，或者进行别的操作。同样地，你得等别人操作完了，才能通过swapToNextTick步进到未来。这里checkResult是一个调试用的函数，它通过检查当前和未来的状态来检查游戏是否正确更新了每一个细胞。正确性验证当然，代码写完了还得确保它能正确运行： val game = ConwaysGame(1000, 1000, 100, 23, 8) game.reset { _, _, -> Random.nextBoolean() } for (i in 0 until 1000) { game.calculateNextTick() game.checkResult() game.swapToNextTick() } game.close()这个代码会随机填充世界，并开始迭代。每次迭代都会检查程序的正确性。在这篇文章中，单元测试并不是主要内容，因此只要使用某种方法确认程序运转正确即可。如果实在不想写代码，将每次迭代的结果打印到终端，人肉检查也是可以的。性能调节单元测试不是重点，性能调节才是。你看，在创建Game实例的时候有很大的灵活性。除了世界的大小由用户指定之外，每个线程计算的区域大小，分配多少个线程，这些也要用户来指定。如果每个线程计算的区域太小，那么CPU资源就会浪费在给线程分配任务上；如果计算区域过大，那么线程得不到充分利用，就没法完全释放性能。此外，面积相同的情况下，行和列的尺寸对性能也会有影响（CPU缓存更偏好行）。此外，如果线程数太多，会加剧操作系统调度线程的压力，如果线程太少，那计算任务又分配不过来。而且最重要的是，调节这些参数不能凭感觉，那怎么办呢？我曾经在调试Minecraft服务器的性能的时候做过简单的性能测量，但那样笼统的性能测量在这里是不适用的。要知道，JVM在运行时是一个复杂的系统，除了不确定的垃圾回收之外，还有各种优化器会随时加入进来，将字节码优化成二进制指令。在这方面而言，Java程序并不是幂等的。那怎么办呢？实现JVM的那帮人也有相同的疑问，于是他们写了一个性能测量工具，叫做JMH。它主要用于“微基准测试”，也就是说，用于测量一些微小细节的性能的工具，例如测量i++和++i到底能差出几纳秒。我们当然不需要测量的那么精准，我们只需要这个框架帮我们排除JVM优化以及垃圾回收这些元素的影响。至于JMH框架的具体使用就不说了，这里放一个代码：package info.skyblond.pocog import org.openjdk.jmh.annotations.* import kotlin.random.Random open class TestBench { @State(Scope.Benchmark) open class ExecutionPlan { @Param("1", "10", "50", "100", "300") open var heightSubdivisionSize = 0 @Param("12") open var parallelism = 0 private val random = Random(1234) @Volatile lateinit var game: ConwaysGame @Setup fun setUp() { game = ConwaysGame( 600, 600, heightSubdivisionSize, parallelism ) game.reset { _, _ -> random.nextBoolean() } } @TearDown fun tearDown() { game.close() } } @Benchmark @Fork(1) @Warmup(iterations = 5) @BenchmarkMode(Mode.Throughput) fun conway(plan: ExecutionPlan) { plan.game.calculateNextTick() plan.game.swapToNextTick() } } 这里根据三个参数进行测试，分别是高度和线程数。测试的结果比较直观：高度为10和50时取得了较高的性能：每秒320步迭代。细化测试参数，将高度修改为：5、10、25、50、55，第二轮测试发现高度为25时性能最高。为了避免用户手动设置，可以按照面积恒定的原则来计算：上述测试中宽600，高25，每个线程计算15000个格子。当宽度变化时，可以根据15000÷宽度得到对应的高度：(15000 / gameWidth).coerceAtLeast(1)可视化？文章写到结尾，好像还没有谈及可视化。如果没有可视化，这玩意儿就只是在内存里的0和1。涉及到模拟的东西，它是一种动态的过程，所以我更希望能够以动图或视频的方式展现出来，而不是一堆数字编号的jpg图片。我本来是想用ffmpeg来一边模拟一边生成视频的，但是看起来这个工具好像只能接收图片输入。索性今天看到了一个叫做humble-video的Java库，能够将Java生成的BufferedImage编码成视频文件。在下一篇文章中，我将介绍如何使用这个工具来生成视频。-全文完-
【代码札记】使用JNA在Windows上创建并操作TUN设备
虽然大家一致认为Java不适合做底层，但我觉得不适合不代表不能做。本篇文章比起札记，其实更像是一篇show off。背景最近我在看去中心系统相关的内容，看到了Yggdrasil，且不说它的原理如何，它在使用方式上就让我耳目一新。传统的overlay network都需要通过某种应用层的接口来让用户访问，比如Tor和I2P都需要浏览器支持，后者更是提供了诸如socks这种代理协议来提供访问，这就要求应用程序必须专门考虑并为此设计，才能获得一定程度的兼容性。对于不支持socks协议的程序，很遗憾，它没法接入I2P网络。Yggdrasil不同：它在运行时创建了一个TUN设备。TUN设备是一种虚拟网络适配器，类似TAP。TAP设备工作在二层，换言之它能够得到的是数据链路层的数据包（更细致地说，其实是媒体访问控制MAC包），而TUN设备则工作在三层，它能够得到的数据是IP包。Yggdrasil将网络中的每一个节点的公钥映射成IPv6地址中被废弃的一段0020::0/7，这段地址在可观的未来不会被使用（但Yggdrasil网络中节点的公钥是256位，而这个地址段只有121位，我还不太清楚他们是怎么解决地址冲突的），每个节点分配一个段内的IPv6地址，这样一来，只要在操作系统中访问这个段内的地址，不管你是ICMP还是TCP还是UDP，操作系统都封装成IP包，交给这个TUN设备。而Yggdrasil从这个TUN设备中获取封装好的IP号，看一下目的地地址，在网络内路由，到达对方节点后，直接通过TUN设备写入操作系统的IP栈，之后无论是什么协议，全部由操作系统负责处理和分发。多么优雅的解决方案啊！但是只有一个遗憾，这个软件是Go写的，而我是写Java的。我翻遍了Google和GitHub，也没有发现支持Java在Windows上创建和操作TUN设备的库。我很不爽，于是就自己写了一个。自己造轮子经过一番搜索，我发现了一个叫做wintun的C++库，这个库将win32 API的调用封装成了简单的函数，诸如WintunCreateAdapter这种。要知道，让Java调用C就已经很困难了，你需要处理好Java对象和C结构体之间的映射，要处理好指针、类型的问题。而微软的Win32为了让事情变得更加复杂（其实是历史遗留问题），他们使用了自己的一套类型系统，并且有者自己的一套API设计理念，用起来更加麻烦。看到有前人栽树，现在后人就得想怎么乘凉。好在Java调用C并不是什么稀奇事情：除了原生的JNI之外，我们还有JNA和JNR，还有未来的Project Panama。就目前而言，对于这个项目来讲，JNA是最好的选择。关于编写mapping的过程就不再赘述了，无非就是参照JNA文档，遇到问题了Google一下。不过其中有个小插曲，就是无论怎么调试，一开始得到的结果返回的都是null，我百思不得其解啊。最后无意中想到，这个玩意儿是不是需要管理员权限？但是IDEA并没有提供单独将程序作为管理员运行的选项，不得已，我只能将整个IDEA作为管理员运行。对于来路不明的软件，这个动作其实非常危险：不光是IDEA，由IDEA启动的Gradle也是作为管理员运行的，如果构建脚本里有一些恶意代码，使用管理员权限运行刚好给了他们可乘之机。不过考虑到这个是我自己写的软件，顶多蓝屏，能怎么样？编写好了Mapping，我发现一个问题：这个软件只能创建和操作TUN设备，但是没办法给TUN设备分配IP。虽然我可以在TUN设备中模拟DHCP包，但是这未免有些复杂且太不灵活了。查阅了一大堆文档，最后发现win32 API还是绕不开了。于是开始想办法编写相关的win32 API。具体内容可以参考这个项目的GitHub Repo。因为是C，免不了很多面向过程编程。与其一开始考虑怎么把这个东西做的尽可能OOP，我的建议是每次针对一个功能（例如列出IP，插入MIB ROW等），写一个demo，确保能用之后，再将这个功能封装成对象的成员方法。不然一边设计对象，一边考虑C的过程，实属给自己找不痛快。展示经过了一周的艰苦奋斗，这个库终于是成了。为了验证效果，我借助pcap4j实现了ICMP ECHO（也就是ping）响应。效果就是，运行程序，创建一个TUN设备，程序为这个TUN设备分配IP为0020::100/7，并开始处理IP包。这个时候在系统内ping任意一个同网段的ip，都可以得到相对应的ping响应。总体流程还是简单明了的：package info.skyblond.jna import com.sun.jna.platform.win32.Guid import info.skyblond.jna.wintun.* import org.pcap4j.packet.* import org.pcap4j.packet.namednumber.IcmpV6Code import org.pcap4j.packet.namednumber.IcmpV6Type import org.pcap4j.packet.namednumber.IpNumber import org.pcap4j.packet.namednumber.IpVersion import java.io.EOFException import java.net.Inet6Address import kotlin.concurrent.thread import kotlin.experimental.and import kotlin.random.Random fun main(args: Array<String>) { println("Current wintun version: ${WintunLib.INSTANCE.WintunGetRunningDriverVersion()}") val guid = Guid.GUID.newGuid().toGuidString() val adapter = WintunAdapter("Wintun Demo Adapter", "Wintun", guid) // Ring size: 8MB val session = adapter.newSession(0x800000) try { // clean up old ip adapter.dissociateIp(ip) } catch (t: Throwable) { t.printStackTrace() } val ip = Inet6Address.getByName("0020::100") println("Set ip to: $ip") adapter.associateIp( AdapterIPAddress(ip = ip, prefixLength = 7u) ) TODO("Handle IP packets") println("Closing!") session.close() adapter.close() }整体流程就是创建网卡、启动Session、设定IP、处理IP包，最后关闭资源。处理IP包的部分可以单独放在一个线程里： val t = thread { try { while (true) { val result = session.readPacket() if (result != null) thread { handlePacket(session, result) } } } catch (e: EOFException) { e.printStackTrace() } catch (e: NativeException) { e.printStackTrace() } } while (t.isAlive) { Thread.sleep(1000) }由于读取的时候不一定总会读出数据，因此需要判断一下读出的内容是否为空，不为空再处理。处理起来也相对简单，无非是解析包，拿到目的地IP，然后创建一个回复的包，写回操作系统： private fun handlePacket(session: WintunSession, packet: ByteArray) { val isV6 = packet[0].and(0xf0.toByte()) == 0x60.toByte() println( "Get IPv${if (isV6) "6" else "4"} packet from OS\n" + "\tSize: ${packet.size} bytes\n" ) if (isV6) { val v6Packet = IpV6Packet.newPacket(packet, 0, packet.size) println(v6Packet) (v6Packet.payload as? IcmpV6CommonPacket)?.let { icmpV6Common -> (icmpV6Common.payload as? IcmpV6EchoRequestPacket)?.let { request -> val reply = IpV6Packet.Builder() .version(IpVersion.IPV6) .trafficClass(IpV6SimpleTrafficClass.newInstance(0x00)) .flowLabel(IpV6SimpleFlowLabel.newInstance(0)) .srcAddr(v6Packet.header.dstAddr) .dstAddr(v6Packet.header.srcAddr) .nextHeader(IpNumber.ICMPV6) .hopLimit(127) .correctLengthAtBuild(true) .payloadBuilder( IcmpV6CommonPacket.Builder() .srcAddr(v6Packet.header.dstAddr) .dstAddr(v6Packet.header.srcAddr) .type(IcmpV6Type.ECHO_REPLY) .code(IcmpV6Code.NO_CODE) .correctChecksumAtBuild(true) .payloadBuilder( IcmpV6EchoReplyPacket.Builder() .identifier(request.header.identifier) .sequenceNumber(request.header.sequenceNumber) .payloadBuilder(request.payload.builder) ) ) .build() println("Reply: \n$reply") session.writePacket(reply.rawData) } } } else { val v4Packet = IpV4Packet.newPacket(packet, 0, packet.size) println(v4Packet) } }程序运行起来之后ping 0020::300，效果如下：关于完整的程序和这个库，可以在GitHub上找到。-全文完-
【代码札记】从零开始的点对点聊天系统 P5
本文继续介绍如何将前文的i2p-p2p-chat接入Minecraft中。这是本系列的最后一篇文章。上文说上文说到了启动节点。但是之后还得发消息，除了公屏发消息，还得有一个私信消息的入口，除此之外还要让用户能够在游戏内复制到他的I2P地址，然后发送给其他玩家，让其他玩家在游戏内连接到这个地址，这就引出了一个指令系统。最后为了便别哪些玩家连上了我们，我们还希望有一个地方能展示这些信息。拦截消息为了避免意外发送消息从而被举报/陷害（还记得一开始我提到的gaslighting漏洞吗），最好的办法当然就是直接拦截所有消息，除非用户使用特别的命令要求向MC的公屏中发送消息。经过一番搜索，最终发现在ClientPlayerEntity类中，sendChatMessageInternal方法是整个发送消息过程中最关键的一环，它接收各种形式的调用（例如用户在聊天框中使用回车，或者其他mod以用户的名字发送消息），将实际要发送的消息封装成数据包发送给服务器。也就是说，只要我们能够拦截这个方法，我们就可以终止消息发送，并获得待发送的消息。得益于mixin的cancellable特性，我们可以指示mixin框架提前终止方法的调用，即插入我们自己的return指令：@Mixin(ClientPlayerEntity.class) public abstract class MixinClientPlayerEntity { @Inject(method = "sendChatMessageInternal", at = @At("HEAD"), cancellable = true) private void onSendChatMessageInternal(String message, Text preview, CallbackInfo ci) { if (ChatHelper.shouldSendPlain(message)) { // this message should be sent through minecraft's internal chat // stop further processing return; } if (CommandHelper.isCommand(message)) { CommandHelper.handleCommand(message); } else { // broadcast message var playerList = MinecraftHelper.getPlayerList(); ClientModEntry.getPeer().sendRequest( new TextMessageRequest("broadcast", message), s -> s.useContextSync(c -> c.getUsername() != null && playerList.contains(c.getUsername())) ); // show the message to ourselves ChatHelper.addBroadcastMessageToChat( Objects.requireNonNull(MinecraftHelper.getCurrentUsername()), Text.literal(message) ); } // cancel sending ci.cancel(); } }这里有三个设计，首先是决定要不要通过MC原本的机制发送消息，如果是，则直接跳过我们下面的代码，进入MC原版的流程。然后是判断用户的输入是待发送的消息，还是输入给mod的指令。指令系统的设计将在下一节描述。最后是发送公屏消息，在原版MC中，消息发送给服务器，服务器转发给包括发送者在内的所有玩家，这个时候玩家自己也能看到消息。但在我们的Mod中，玩家并不与自己建立连接，因此把消息发送给其他人后，还要自己把发送的消息添加到聊天框中。关于使用MC原本机制发送消息的判断，这里引入了如下机制： @JvmStatic private val plainMessagePendingQueue = ConcurrentHashMap<String, Int>() private fun addPlainMessage(message: String) { plainMessagePendingQueue.compute(message) { _, oldCount -> // add 1 if is old message, otherwise count as 1 for new messages oldCount?.let { it + 1 } ?: 1 } } @JvmStatic fun shouldSendPlain(message: String): Boolean { var isFound = false plainMessagePendingQueue.compute(message) { _, oldCount -> oldCount?.let { count -> isFound = true if (count <= 1) { // delete this null } else { // count down by 1 count - 1 } } } return isFound } @JvmStatic fun sendPlainMessage(message: String) { addPlainMessage(message) MinecraftClient.getInstance()?.player?.sendChatMessage(message, null) ?: run {// failed to send message logger.warn { "Failed to get current player, thus, failed to send message: $message" } shouldSendPlain(message) // remove the count } }首先是一个plainMessagePendingQueue队列，其实是一个Map，它记录了待发送消息的次数。例如玩家使用命令发送了5次“哈哈哈”，那只需要记录一个“哈哈哈”的键，其值为5即可。每次查询“哈哈哈”是否应当通过原版机制发送，都会引发计数器减一，减为0后直接删除。这一逻辑是通过sendPlainMessage调用addPlainMessage方法来保证的。这里的时序是这样的：用户通过指令发送一条消息，指令将待发送的消息添加到队列中，然后调用MC原版发送消息的逻辑，原版逻辑执行到我们的注入代码时，注入代码检测到待发送的消息在队列中，于是跳过注入，将控制权交还给原版机制。这里的重点在于我们总是先添加队列，再发送消息。而使用并发安全的Map则是因为触发指令的顺序是不固定的。指令系统MC本身有一套指令系统，可以直接拿来用，大多数模组也是这么用的。但这个是客户端模组，也就是说，你注册了原版的MC指令，但加入了服务器，而服务器不知道这个指令，那只有在单机下才能识别指令，加入服务器后指令就不可用了。我们当然不希望这样。但考虑到原版MC的指令也不过是一条特殊前缀的聊天消息，既然我们能够拦截聊天消息，为什么不能一块处理指令？我们的指令以!开头。例如!msg player123 hahaha是让mod发送一条hahaha给玩家player123。当然，我们可以用when和字符串操作来匹配所有指令，但是这样的话整个mixin代码就会非常臃肿。和Lang一样，我们期望一种树状的指令（指令还可以有子指令，例如!i2p status这种，i2p包含了所有I2P相关的指令，而status则是查询状态的子指令）。但与Lang不同，Lang是一种静态资源，它的键在编译时期就已经固定在对应的json文件中了，所以应当使用sealed class来形成一棵静态的树；而指令则更像是一种动态产生的结构，换句话说，它会更频繁的修改，所以这里使用了类似DSL的方式去构造指令树。描述指令的类很简单：class MCACommand( val commandName: String, val description: String, val parameterList: List<Pair<String, String>> = emptyList(), val subCommands: Set<MCACommand> = emptySet(), actionInternal: ((String) -> Unit)? = null ) { private val action: (String) -> Unit = actionInternal ?: { message -> // ...... } init { require(!commandName.contains(" ")) { "Command name contains space" } require(commandName != "help") { "Command help is a internal command" } if (actionInternal == null) { // contains sub command require(subCommands.isNotEmpty()) { "Commands without action should have subcommands" } } } operator fun invoke(parameter: String) = action.invoke(parameter) override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is MCACommand) return false if (commandName != other.commandName) return false return true } override fun hashCode(): Int { return commandName.hashCode() } }每个指令有5个参数：指令名、描述、参数列表（参数名、描述）、子命令集和动作。每个指令是否相等是看指令名的，因为不同的指令不会出现在同一层，我们只需要保证一个set中的指令是唯一的即可。通过init方法可以看到，这些参数有一些限制：首先指令名不能包含空格；然后是指令名不能为help，这个指令稍后会说，它是一个自动生成的帮助命令；最后如果action为空（它是一个中间指令，例如!i2p status中的i2p），那么他的子指令必须不为空。如果构造的时候动作为空，也就是说这个指令是一个中间指令，那么我们给他一个默认的动作来寻找对应的子指令： private val action: (String) -> Unit = actionInternal ?: { message -> var firstSpace: Int = message.indexOf(' ') if (firstSpace == -1) { firstSpace = message.length } val command = message.substring(0, firstSpace) subCommands.find { it.commandName == command } ?.let { it(message.substring(firstSpace).trimStart()) } ?: run { ChatHelper.addSystemMessageToChat(Text.literal("Unknown subcommand: $command")) } }这里提取消息中的第一个命令，也就是第一个空格前面的内容，然后按照寻找与之匹配的子指令，然后把余下的部分交给子指令处理。也就是说，如果模组收到一个i2p status，那么会把i2p对应的指令匹配出来，把剩余的status交给I2P指令去处理，形成一个递归调用。考虑到指令应该不会太长，所以大概率不会爆栈。接下来我们看看DSL语法。最终我们要得到一系列一级命令，而这些命令可能又有自己的子命令，二级三级等等，但那是一级命令本身去调用处理的，我们需要解析用户输入的消息，并找到这些一级命令。我们可以直接setOf(...)，但是这样的话DSL不太好写，因为这里面可以执行任何命令，而我们想让DSL只在特定范围内执行。为了解决这个问题，我们可以用buildSet {...}。与setOf不同之处在于，前者提供了一种操作Set本身的能力。使用setOf只是简单的把参数变成一个Set，而buildSet则传给你一个MutableSet，这个就是最终的结果，你可以在lambda里面增加、删除、对这个set做任何操作，因此我们可以在里面注册命令： private fun MutableSet<MCACommand>.registerCommand( commandName: String, description: String, parameterList: List<Pair<String, String>>, action: (String) -> Unit ) = require( this.add( MCACommand( commandName = commandName, description = description, parameterList = parameterList, actionInternal = action ) ) ) { "Duplicated command name: $commandName" }这里我们注册了一个命令，使用起来类似这样： registerCommand( commandName = "foo", description = "test", parameterList = listOf() ) { logger.info { "Bar!" } }当然，我们也可以注册子命令： private fun MutableSet<MCACommand>.registerCommand( path: String, description: String, block: MutableSet<MCACommand>.() -> Unit ) = require( this.add( MCACommand( commandName = path, description = description, subCommands = buildSet(block) ) ) ) { "Duplicated command name: $path" }用起来是这样的： registerCommand( path = "i2p", description = "I2P related operations" ) { registerCommand( commandName = "regen", description = "generate a new i2p identity", parameterList = listOf() ) { ConfigFile.ModConfig.use { this.discardI2PKey() } ConfigFile.ModConfig.save() ChatHelper.addSystemMessageToChat( Text.literal("Done. Please restart your game.") ) } }值得注意的是，注册中间命令的时候我们也用到了buildSet，也就是说，中间命令的子命令既可以是中间命令，也可以是具有动作的实际命令。只需要两个类似DSL的函数，我们就可以方便且灵活的定义命令了。很优雅，不是么？具体的命令就不一一介绍了，有兴趣的话可以参考这里的代码。这些指令提供了连接命令和将连接命令复制到剪切板的功能。接下来介绍一下处理命令的逻辑： @JvmStatic fun handleCommand(rawMessage: String) { if (!isCommand(rawMessage)) return val message = rawMessage.removePrefix(commandPrefix) // find first word var firstSpace: Int = message.indexOf(' ') if (firstSpace == -1) { firstSpace = message.length } val command = message.substring(0, firstSpace) if (command == "help") { // ...... } else { commands.find { it.commandName == command } ?.let { it(message.substring(firstSpace).trimStart()) } ?: run { ChatHelper.addSystemMessageToChat(Text.literal("Unknown command: $command")) } } }首先需要确保传入的消息是!开头的，去掉开头的叹号，拿到第一个命令。如果是help的话，我们需要特殊处理一下。如果不是的话，那我们直接从命令列表里找命令，然后开始递归调用。对于help命令，处理逻辑如下： val path = mutableListOf<MCACommand>() var searchScope = commands.toList() var rest = if (firstSpace >= message.length) "" else message.substring(firstSpace + 1) while (searchScope.isNotEmpty() && rest.isNotEmpty()) { val c = searchScope.filter { rest.startsWith(it.commandName) }.maxByOrNull { it.commandName.length } if (c != null) { path.add(c) searchScope = c.subCommands.toList() rest = rest.substring(c.commandName.length).trim() } else { val commandFullPath = path.joinToString(separator = " ", prefix = commandPrefix) { it.commandName } ChatHelper.addSystemMessageToChat( Text.literal( "Command $commandFullPath don't have sub command ${rest.split(" ")[0]}" ) ) return } } val commandFullPath = path.joinToString(separator = " ", prefix = commandPrefix) { it.commandName } if (path.isEmpty()) { // a !help with no parameter ChatHelper.addSystemMessageToChat(Text.literal( "Commands:\n" + commands.joinToString("\n") { " " + it.commandName + ": " + it.description } )) } else { if (path.last().subCommands.isNotEmpty()) { ChatHelper.addSystemMessageToChat(Text.literal( "Sub commands for $commandFullPath:\n" + path.last().subCommands.joinToString("\n") { " ${it.commandName}: ${it.description}" } )) } else { val c = path.last() ChatHelper.addSystemMessageToChat( Text.literal( "Command $commandFullPath ${ c.parameterList.joinToString(" ") { "<${it.first}>" } }: " + c.description + "\n" + c.parameterList.joinToString("\n") { " ${it.first}: ${it.second}" } ) ) } }首先有个path来保存搜索的路径。!help会打印所有可用的一级命令，而!help i2p会打印出i2p命令的所有子命令，而!help i2p status会打印关于status子命令的帮助。但status并不知道它之前的命令是什么，因此我们需要想办法保存下来。searchScope是当前搜索的范围，默认从一级命令开始搜索，如果匹配到了二级命令，那就用二级命令的子命令作为搜索范围，以此类推。rest则是剩余的消息，例如!help i2p status的rest首先是i2p status，在搜索到i2p后，rest就变成了status，最后变成空字符串，搜索结束。之后的代码就相当于把递归调用转换成了循环，不断搜索匹配到的命令，并保存到path中。如果中间没有找到命令，则直接提示失败并提前返回。搜索结束后，如果path是空的，则直接打印所有一级命令即可。如果不是的话，取决于最后一个命令的类型：如果是一个具体的命令，那就打印它的参数列表和描述；如果是一个中间命令，那就打印它所有的子命令。至此，让这个模组正常运转的功能就都已经实现了。使用模组的时候可以加入一个服务器，然后使用!gen命令，就会把对应的连接命令复制到剪切板，你可以通过微信等方法发送给你的朋友，然后你的朋友直接在聊天框中贴入，这个会作为!connect命令执行。这样你和朋友就可以连接并发送消息了。借助PEX，你和你的朋友会自动交换双方已经连上的节点，从而省去了和其他玩家一一交换connect命令的麻烦。同时，模组会定期保存已连接的会话地址。当你下次上线时，模组会自动从缓存中找到你朋友的地址并连接——再也不需要!connect命令了。按照设计，这个模组随着使用时间增长，体验也会变得越来越好。增强体验话是这么说，但有没有可能让体验更进一步呢？玩家列表比如说多人模式下按Tab，可以列出玩家列表。能不能在这个玩家列表中标注出谁与我相连了呢？当然可以！阅读了一些mojang的屎山之后，我发现可以借助PlayerListHud类的getPlayerName方法来实现。这个方法原本是针对每个待显示玩家名称进行装饰的。也就是说，每一个玩家名在显示前都会经过这个方法处理，得到的就是即将显示在玩家列表中的名字。修改如下：@Mixin(PlayerListHud.class) public abstract class MixinPlayerListHud { @Inject(method = "getPlayerName", at = @At("RETURN"), cancellable = true) private void onGetPlayerName(PlayerListEntry entry, CallbackInfoReturnable<Text> cir) { var text = cir.getReturnValue(); var realName = entry.getProfile().getName(); if (realName.equals(MinecraftHelper.getCurrentUsername())) { cir.setReturnValue(Text.translatable("%s (%s)", text, Text.literal("self"))); cir.cancel(); return; } try { // potential error: getPeer() not initialized var peerSession = ClientModEntry.getPeer().dumpSessions().stream() .filter(s -> !s.isClosed() && s.useContextSync(c -> realName.equals(c.getUsername()))) .findAny(); if (peerSession.isPresent()) { var source = peerSession.get().useContextSync(MCAContext::getSessionSource); var direction = ""; switch (source) { case CLIENT -> direction = "out"; case SERVER -> direction = "in"; } cir.setReturnValue(Text.translatable("%s (%s)", text, Text.literal(direction))); cir.cancel(); } } catch (Throwable ignored) { } } }这里写的比较粗糙，因为我默认用户名就是玩家名，但像是Hypixel这种会修改玩家名的，我不知道用起来是什么样的，大概率会不好使吧。总之这段代码的做用就是判断给定的用户名是否与已知的会话匹配，如果匹配则标注出方向（出入站，即谁先发起的连接），不匹配的话就什么也不做。这样当我们按下Tab时，就可以直接从玩家列表中看到谁与我们相连了。聊天内容检测地址前面说过，玩家可以通过!gen命令获得连接命令，然后发送给其他玩家。但有时候大家在一起玩，还没有好到要加微信的地步，这怎么办呢？我们可以从过MC本身发送这个命令，但是70个字符长的地址，MC又没有从聊天框复制内容的能力，你指望用户一个一个敲？这不好吧。如果我们可以监听聊天消息，在搜索到地址的时候提示用户，这岂不是更好一些？又看了很久mojang的屎山，我发现MessageHandler类的onChatMessage可以用于截获收到的消息。这里我们不对消息进行修改，只是进行监听：@Mixin(MessageHandler.class) public abstract class MixinMessageHandler { @Inject(method = "onChatMessage", at = @At("RETURN")) private void onChatMessage(SignedMessage message, MessageType.Parameters params, CallbackInfo ci) { var text = message.signedBody().content().plain(); if (text.contains(".b32.i2p")) { // the b32 address: {52 chars}.b32.i2p var prefix = text.split("\\.b32\\.i2p")[0]; if (prefix.length() >= 52) { var b32 = prefix.substring(prefix.length() - 52) + ".b32.i2p"; if (b32.equals(ClientModEntry.getPeer().getMyB32Address())) { // is ourselves return; } ChatHelper.addSystemMessageToChat(Text.translatable( "%s sent a b32 address in chat, %s", params.name(), Text.literal("click [here] to connect").fillStyle(Style.EMPTY.withClickEvent( new ClickEvent( ClickEvent.Action.SUGGEST_COMMAND, "!connect " + b32 ) )) )); } } } }如果我们在聊天消息中发现了I2P的b32地址，也就是包含在connect命令里面的地址，那我们就插入一条消息，告诉用户。并且利用ClickEvent功能让用户在点击消息的时候，自动生成一个connect命令，用户直接回车就可以连接。岂不美哉？后记这一个系列到此为止终于完结了。可喜可贺，可喜可贺。接下来我可能会玩一玩kotlinx coroutine相关的东西，也可能深入一下I2P，谁知道呢。我对未来之事无法给出任何保证，因为我是好奇心驱动的。也许过几天看到了更加感兴趣的事情也说不定。总之，感谢大家的阅读，我们下一篇文章见~（考虑到我每月一更的习惯，可能得明年1月了，LOL）-全文完-
【代码札记】从零开始的点对点聊天系统 P4
本文介绍如何将前文的i2p-p2p-chat接入Minecraft中。准备工作前文中我们完成了i2p-p2p-chat的编写，这个库封装了所有基于I2P的点对点通讯的操作。本文中，我们需要在Minecraft这部分做出修改。首先是要找到拦截和注入消息的位置，拦截到的消息将有我们的模组发送，而模组接收到的消息需要注入到Minecraft原本的聊天框中。然后就是想办法引入一套命令系统：公屏聊天当然不需要，但我们还得能够私聊，况且我们还得想办法让用户分享他们自己的地址。最后我们还得搞一套简单的配置系统，用来持久化地存储我们的私钥和曾经见过的节点。配置系统配置系统应该是整个模组最简单的部分了。我们的模组有两个需要存储的配置：一个是控制模组行为的配置文件；另一个则是存储见过的节点。前者无需过多解释，对于后者，建立这种缓存是为了当我们在服务器中遇到以前见过的用户名，则可以直接向过去见过的地址发起连接。因为我们的隧道私钥是随Minecraft实例持久化保存的，除非用户选择刷新私钥或者更换了实例，在大多数情况下，我们应该可以通过旧的地址直接连上用户，从而跳过了PEX和手工分享地址。这也使得整个mod的体验随着使用时间增加而变好。配置文件的格式当然是JSON，虽然后者用KV数据库性能会更好，但是我的宗旨是能不用数据库就不用数据库，最好一个map就能解决。两个配置的模型定义如下：@Serializable class MCAConfig( @Serializable(with = ByteArrayAsBase64Serializer::class) private var _i2pKey: ByteArray = I2PHelper.generateDestinationKey(), @Serializable(with = DurationSerializer ::class) var pexInterval: Duration = Duration.ofSeconds(45), @Serializable(with = DurationSerializer::class) var lastSeenUpdateInterval: Duration = Duration.ofMinutes(3), @Serializable(with = DurationSerializer::class) var sessionRemoverInterval: Duration = Duration.ofMinutes(2), @Serializable(with = DurationSerializer::class) var autoConnectInterval: Duration = Duration.ofSeconds(15), ) { val i2pDestinationKey get() = _i2pKey fun discardI2PKey() { _i2pKey = I2PHelper.generateDestinationKey() } } @Serializable class PeerRepoEntry( @Serializable(with = DestinationSerializer::class) var destination: Destination, @Serializable(with = ZonedDateTimeAsTimestampSerializer::class) private var _lastSeen: ZonedDateTime ) { val lastSeen get() = _lastSeen fun seen(dest: Destination) { destination = dest _lastSeen = ZonedDateTime.now() } } 其中MCAConfig就是控制行为的配置文件了。里面的_i2pKey就是我们I2P隧道的私钥，他的类型是ByteArray，但实际用的时候需要转换成Destination，于是就有了后面的i2pDestinationKey，当然，为了实现刷新功能，还有discardI2PKey方法。其他的各种Interval则决定了定时任务的执行间隔，比如多久进行一次PEX，多久记录一次最后见到的节点信息，多久进行一次会话检查（关闭不在同一个服务器中的会话），多久尝试一次从缓存中连接节点。下面的PeerRepoEntry就是一个节点缓存条目。它记录了节点的地址和最后一次见到的时间。这里本来想实现一个简单的最久淘汰策略，但是后来给忘了。不过忘了也不会有太严重的后果，一个条目不会超过1KB，因此见过一千个玩家才占用1MB空间，见过一百万个玩家才占用1GB空间。这个文件可以随时被删掉，唯一的影响就是下一次你见到这个玩家，mod不会自动连接了。有了模型，我们得想办法提供一个与配置文件交互的接口。这里我们选择使用sealed class来实现。首先我们需要一个能够输出多行的Json：private val json = Json { prettyPrint = true ignoreUnknownKeys = true encodeDefaults = true }漂亮打印使得用户能够以人类可读的形式修改配置；而忽略未知的键则有助于提供额外的兼容性：让我们在后续版本废弃了一些选项，JSON并不会因为这些已经废弃的选项而报错；最后的编码默认值则是将我们的默认值打印到配置文件中，以便用户修改。我们的sealed class定义如下：sealed class ConfigFile<T>( private val filename: String, private val serializer: KSerializer<T>, defaultValue: T, ) { /** * Current value. It use default value unless we overwrite it when [loadOrCreate] * */ private var value: T = defaultValue /** * The actual [Path] for this config file. * */ private val configPath = FabricLoader.getInstance().configDir.resolve(this.filename) init { reload() // write the latest save() } /** * Load from disk, create with default value if not exist. * */ private fun loadOrCreate(): T { // ensure we don't read and write against the same config at the same time synchronized(this) { return if (Files.exists(configPath)) { // read it json.decodeFromString(serializer, configPath.readText()) } else { //create it @Suppress("BlockingMethodInNonBlockingContext") Files.createDirectories(configPath.parent) configPath.writeText(json.encodeToString(serializer, value)) value } } } /** * Use the config sync. * */ fun <R> use(block: T.() -> R): R { synchronized(this) { return block(value) } } /** * Discard changes and reload latest from disk. * Same as [loadOrCreate] * */ fun reload() { value = loadOrCreate() } /** * Save the current value info disk. * */ fun save() { synchronized(this) { configPath.writeText(json.encodeToString(serializer, value)) } } }ConfigFile有三个构造函数：文件路径、序列化器和一个默认值。每个实例都有一个value，默认是默认值。在实例化过程中，先尝试从磁盘中载入配置，如果配置存在，则从配置中解析并代替默认值；如果文件不存在，则将默认值的内容写入文件，并返回默认值。除了常用的重载和保存，还提供了一个线程安全的use方法。这个方法保证对于value的访问是同步的，这样我们就不必为忘记加锁而烦恼了。具体的两个实例如下： object ModConfig : ConfigFile<MCAConfig>( filename = "minecraft-chat-alternative/config.json", serializer = MCAConfig.serializer(), defaultValue = MCAConfig() ) @Suppress("OPT_IN_USAGE") object PeerRepo : ConfigFile<MutableMap<String, PeerRepoEntry>>( filename = "minecraft-chat-alternative/peer_repo.json", serializer = object : KSerializer<MutableMap<String, PeerRepoEntry>> { val delegate = MapSerializer(String.serializer(), PeerRepoEntry.serializer()) override val descriptor: SerialDescriptor = SerialDescriptor( "MutableMap<String, PeerRepoEntry>", delegate.descriptor ) override fun deserialize(decoder: Decoder): MutableMap<String, PeerRepoEntry> { return decoder.decodeSerializableValue(delegate).toMutableMap() } override fun serialize(encoder: Encoder, value: MutableMap<String, PeerRepoEntry>) { encoder.encodeSerializableValue(delegate, value) } }, defaultValue = mutableMapOf() ) { /** * Update from session list. * */ fun updateLastSeenFromSessions(sessions: List<PeerSession<MCAContext>>) { sessions.forEach { s -> if (s.isClosed()) return@forEach val username = s.useContextSync { username } ?: return@forEach this.use { if (containsKey(username)) get(username)!!.seen(s.getPeerDestination()) else put(username, PeerRepoEntry(s.getPeerDestination(), ZonedDateTime.now())) } } } }MCAConfig那个挺简单的，后面那个PeerRepo就有一些复杂了。对于Map类型，在Java中没有过多的区分，但kotlin这边会区分是只读Map还是可修改的MutableMap。而从json反序列化出来的都是Map（因为内置的MapSerializer）要解决这个问题，有两种方法：一种是实现一个自定义序列化器，底层委托给MapSerializer；另一种则是修改sealed class，加一个mapper，对反序列化结果进行处理。按道理来说，后者应该更好一些（但可能需要更复杂的类型，例如KSerializer和(U) -> T），但不知道怎么回事，当时我选了第一种方法。会话上下文虽然有了配置系统，但我们还没办法直接启动节点：我们还需要一个上下文来保存会话状态。好在这个mod并不需要什么复杂的状态：class MCAContext( override val sessionSource: SessionSource ) : SessionContext { var username: String? = null private set override val nickname: String? by this::username override var peerInfo: PeerInfo? = null set(value) { username = value?.getUsername() field = value } private var authAccepted: Boolean = false override fun onAuthAccepted() { authAccepted = true } override fun isAccepted(): Boolean = authAccepted }这里面有个username，其实用nickname也无妨，但是我觉得username对于MC来说更贴切一些，于是使用属性代理把对nickname的访问变成对username的访问。证书签名与验证等一下！你以为有了上下文就可以启动节点了？在此之前我们还要处理一个问题：怎么用Mojang的证书来签名我们的PeerInfo，以及用它来验证别人发来的PeerInfo呢？签名倒是好办：private fun createPeerInfo(peer: Peer<MCAContext>): PeerInfo { val (session, profileKeys) = MinecraftClient.getInstance() ?.let { it.session to it.profileKeys } ?: error("Minecraft instance is null") val (username, uuid) = session ?.let { (it.username ?: error("Cannot get current username")) to it.uuidOrNull } ?: error("Minecraft session is null") val (pubKey, signer) = profileKeys ?.let { (it.publicKey.orElse(null) ?: error("Cannot get player's public key") ) to (it.signer ?: error("Cannot get signer")) } ?: error("Cannot get profile key") val info = mapOf<String, String>( PeerInfo.INFO_KEY_DEST to peer.getMyDestination().toBase64(), "minecraft.username" to username, "minecraft.profile_uuid" to (uuid?.toString() ?: error("Player's uuid is null")), ) val dataToSign = PeerInfo.getDataForSign(info) val signature = signer.sign(dataToSign) return PeerInfo( info = info, signatureBase64 = Base64.getEncoder().encodeToString(signature), publicKeyBase64 = pubKey.toBase64() ) }你会发现这里除了用户名，我们还额外加了一个profile uuid。这个东西是你每次使用正版登录，mojang随机给你分配的一个uuid，并不保证和你的账号绑定，但一般情况下是一一对应的，而且可以凭这个uuid在mojang的服务中代表你这个正版账号。private fun verifyPeerInfo(peerInfo: PeerInfo): Boolean { try { val profileUUID = peerInfo.info["minecraft.profile_uuid"] ?.let { UUID.fromString(it) } ?: return false val publicKeyData = peerInfo.getPublicKeyBytes().paresPublicKeyData() val pubKey = MinecraftClient.getInstance()?.let { PlayerPublicKey.verifyAndDecode( it.servicesSignatureVerifier, profileUUID, publicKeyData, PlayerPublicKey.EXPIRATION_GRACE_PERIOD ) } ?: error("Minecraft instance is null") val verifier = pubKey.createSignatureInstance() val payload = peerInfo.getInfoBencodeBytes() val signature = peerInfo.getSignatureBytes() return verifier.validate(payload, signature) } catch (_: Throwable) { // if any error, invalid peer info return false } }你会发现，在验证过程中我们调用了MC的代码，这里面用到了uuid，而非用户名。有了签名和验证节点信息的能力，我们就可以启动节点了，对吧？i18n与注入我知道你很急，但是你先别急，一会儿有你急的。我们启动节点之前要提供一个Handler，在接收到TextMessage的时候需要调用我们的代码。而我们的代码需要把接收到的消息注入到MC原本的聊天框中。但是我们不能楞把消息插到聊天框里，我们想要一种格式，例如：[I2p] Hahaha this is a message!类似这种，我们希望来自I2P的消息与来自MC的消息区分开。实现这种效果最简单的方式就是用String format，但是直接把格式写死在代码里好像不太好，例如别人发送私信给我们的时候：[I2P] whispers to you: This is a private message!!!如果是中文的话，我们想翻译成：[I2P] 私信对你说：This is a private message!!!i18n这显然是硬编码格式做不到的。但好在Mojang也考虑到了这一点，引入了Lang。我们可以在en_us.json中写英文的格式，在zh_cn.json中写中文的格式，只要符合对应的语言代码，我们可以翻译成任何一种语言：{ "chat.mca.system": "[MCA] %s", "chat.mca.system.narrate": "MCA: %s", "chat.mca.i2p.broadcast": "[I2P] <%s> %s", "chat.mca.i2p.broadcast.narrate": "%s on I2P says %s", "chat.mca.i2p.incoming": "[I2P] <%s> whispers to you: %s", "chat.mca.i2p.incoming.narrate": "%s on I2P whispers to you: %s", "chat.mca.i2p.outgoing": "You whisper to [I2P] <%s>: %s", "chat.mca.i2p.outgoing.narrate": "You whisper to %s on I2P: %s", "text.mca.system.connection.incoming": "Accept connection from %s", "text.mca.system.connection.outgoing": "Connected to %s", "text.mca.system.connection.disconnected": "Disconnected by %s, because %s", "text.mca.system.connection.socket_closed": "Socket with %s closed", "text.mca.system.error.general": "Error occurred, see log for more info", "text.mca.system.error.peer_stopped": "I2P Peer stopped. This should never have happened.", "text.mca.system.error.on_error_reply": "Player %s replies an error to your previous request. See log for more info." }这个JSON看起来挺结构化的，但是用起来的话完全就是个字符串。例如我需要引用收到私信的格式时，我需要在代码中指定chat.mca.i2p.incoming这个键。这种字符串没法保证这个翻译键存在，而这个键明显有一种树状结构，所以，老规矩，sealed class：sealed class Lang( vararg path: String ) { private val fullPath: String = path.joinToString(".") protected fun translate(vararg args: Any): MinecraftText { require(Language.getInstance().get(fullPath) != fullPath) { "Missing translation key: $fullPath" } return MinecraftText.translatable(fullPath, *args) } }这个sealed class接收一个path数组，用来表示完整的key。例如chat.mca.i2p.incoming就变成了["chat", "mca", "i2p", "incoming"]，而后者可以通过树形结构产生，例如表示Chat类的有一个chat.mca前缀，而Chat的子类有i2p的前缀，借助vararg，我们可以这么写：Lang("chat.mca", *path)，在Chat类实例化的过程中，把自己的前缀加到前面，然后把子类的路径放在后面，有点类似递归的意思。而那个translate方法则起到了一个检查的效果，如果引用了未找到的翻译键，那么直接报错引发崩溃。具体来说，Lang下面有两个子类，一个是直接显示到聊天框里面的chat.mca.*，另一类是作为聊天消息的一部分解析的text.mca.*。对于聊天类消息： sealed class ChatMessage( vararg path: String ) : Lang("chat.mca", *path) { sealed class SystemMessage( vararg path: String ) : ChatMessage("system", *path) { object Text : SystemMessage() object Narrate : SystemMessage("narrate") fun translate(message: MinecraftText): MinecraftText = super.translate(message) } sealed class I2PMessage( vararg path: String ) : ChatMessage("i2p", *path) { sealed class Broadcast( vararg path: String ) : I2PMessage("broadcast", *path) { object Text : Broadcast() object Narrate : Broadcast("narrate") fun translate(username: MinecraftText, content: MinecraftText): MinecraftText = super.translate(username, content) } sealed class Incoming( vararg path: String ) : I2PMessage("incoming", *path) { object Text : Incoming() object Narrate : Incoming("narrate") fun translate(username: MinecraftText, content: MinecraftText): MinecraftText = super.translate(username, content) } sealed class Outgoing( vararg path: String ) : I2PMessage("outgoing", *path) { object Text : Outgoing() object Narrate : Outgoing("narrate") fun translate(username: MinecraftText, content: MinecraftText): MinecraftText = super.translate(username, content) } } }这里面基本上就是对Lang的建模。对于聊天消息，除了显示成文本的键，例如chat.mca.i2p.broadcast，还有对应的需要讲述人念出来的键chat.mca.i2p.broadcast.narrate。对于chat.mca.i2p.broadcast，作为一个可以被引用的键，它应该是Object，但它底下又有一个narrate，它得是sealed class。为了解决这个问题，引入了一个名叫Text，但path为空的object，从而保证Text的路径和父对象的路径相同，而与Text同级的Narrate则用于.narrate后缀。此外每个object还都有一个translate方法，这个调用了超类的translate，保证对引用键的检查，同时用代码约束了参数。类似地，具有text.mca.*前缀的TextMessage也是如此，只不过没有了narrate，更加简单一些： sealed class TextMessage( vararg path: String ) : Lang("text.mca", *path) { sealed class SystemMessage( vararg path: String ) : TextMessage("system", *path) { sealed class Connection( vararg path: String ) : SystemMessage("connection", *path) { object Incoming : Connection("incoming") { fun translate(username: MinecraftText): MinecraftText = super.translate(username) } object Outgoing : Connection("outgoing") { fun translate(username: MinecraftText): MinecraftText = super.translate(username) } object Disconnected : Connection("disconnected") { fun translate(username: MinecraftText, reason: MinecraftText): MinecraftText = super.translate(username, reason) } object SocketClosed : Connection("socket_closed") { fun translate(username: MinecraftText): MinecraftText = super.translate(username) } } sealed class Error( vararg path: String ) : SystemMessage("error", *path) { object General : Error("general") { fun translate(): MinecraftText = super.translate() } object PeerStopped : Error("peer_stopped") { fun translate(): MinecraftText = super.translate() } object PeerReportedError : Error("on_error_reply") { fun translate(username: MinecraftText): MinecraftText = super.translate(username) } } } }当然，这里需要注意的就是不要引用错父类，不然整个路径就都变了。此外，定义sealed class不一定要定义在父类里面，但这样有一个好处，使得我们可以用类似键的语法来使用：Lang.ChatMessage.I2PMessage.Outgoing.Text.translate(......)这就像是chat.mca.i2p.outgoing，我觉得很好。消息注入有了这个方便的i18n接口，我们就可以考虑消息注入了。经过一番摸索，可以使用如下代码插入一条消息： fun addRawMessage(message: Text, narrateMessage: Text) { val minecraft = MinecraftClient.getInstance()!! minecraft.inGameHud?.chatHud?.addMessage(message) minecraft.narratorManager?.narrateChatMessage { narrateMessage } }当然，narrator是可选的，不过既然原版MC都做了，我们也不应该忽略。每个人都应当能够享受游戏的乐趣，对吧？这个方法只是将一条消息插入到聊天框中，为了应用格式，我们可以针对不同的消息类型使用不同的方法，例如： fun addSystemMessageToChat(content: Text) { val textChatComponent = Lang.ChatMessage.SystemMessage.Text .translate(content) val narrateChatComponent = Lang.ChatMessage.SystemMessage.Narrate .translate(content) addRawMessage(textChatComponent, narrateChatComponent) } fun addBroadcastMessageToChat(username: String, content: Text) { val textChatComponent = Lang.ChatMessage.I2PMessage.Broadcast.Text.translate( Text.literal(username).fillStyle( Style.EMPTY.withClickEvent( ClickEvent( ClickEvent.Action.SUGGEST_COMMAND, String.format("!dm %s ", username) ) ) ), content ) val narrateChatComponent = Lang.ChatMessage.I2PMessage.Broadcast.Narrate.translate( Text.literal(username), content ) addRawMessage(textChatComponent, narrateChatComponent) }诸如此类的。启动节点好了，现在可以考虑启动节点了。我们可以在mod加载时启动节点并启动各种定时任务：@Environment(EnvType.CLIENT) object ClientModEntry : ClientModInitializer { private val logger = KotlinLogging.logger { } private val json = Json { ignoreUnknownKeys = true serializersModule = getSerializersModule() } private val threadPool = I2PHelper.createThreadPool(Runtime.getRuntime().availableProcessors()) private var peer: Peer<MCAContext>? = null @JvmStatic fun getPeer(): Peer<MCAContext> = peer ?: error("Peer not initialized") override fun onInitializeClient() { logger.info { "Hello!" } logger.info { "Creating peer..." } peer = createPeer( json = json, username = MinecraftHelper.getCurrentUsername()!!, protocolName = "minecraft-chat-alternative-protocol-20221121", destinationKey = ConfigFile.ModConfig.use { i2pDestinationKey }, threadPool = threadPool, pexInterval = ConfigFile.ModConfig.use { pexInterval } ) logger.info { "Peer created!" } // start doing things with that peer // update last seen runTask({ lastSeenUpdateInterval }) { peer -> ConfigFile.PeerRepo.updateLastSeenFromSessions(peer.dumpSessions()) ConfigFile.PeerRepo.save() } // remove invalid session runTask({ sessionRemoverInterval }) { peer -> peer.disconnect("not in same server") { val username = it.useContextSync { username } ?: return@disconnect false username !in MinecraftHelper.getPlayerList() } } // connect from peer repo runTask({ autoConnectInterval }) { peer -> val alreadyConnected = peer.dumpSessions().mapNotNull { it.useContextSync { username } } ConfigFile.PeerRepo.use { entries.filter { it.key in MinecraftHelper.getPlayerList() && it.key !in alreadyConnected }.forEach { entry -> I2PHelper.runThread { try { peer.connect(entry.value.destination) } catch (_: Throwable) { } } } } } } private fun runTask(duration: MCAConfig.() -> Duration, block: (Peer<MCAContext>) -> Unit) { I2PHelper.runThread { val peer = getPeer() val interval = ConfigFile.ModConfig.use { duration() }.toMillis() while (!peer.isClosed()) { MinecraftHelper.sleep(interval) block(peer) } logger.warn { "Peer stopped! Trigger crash" } MinecraftHelper.crash(IllegalStateException(Lang.TextMessage.SystemMessage.Error.PeerStopped.translate().string)) } } }这里我们启动节点，并拉起了一系列定时运行的任务，例如每隔一段时间就记录当前已经连接的会话；每隔一段时间与不在玩家列表中的玩家断开连接；定时从缓存的列表中寻找玩家地址并连接。在创建节点时，主要的Handler有这些： // for AuthRequest createMessageHandlerWithReply { _, session, messageId, payload: AuthRequest, _ -> if (verifyPeerInfo(payload.peerInfo)) { val username = payload.peerInfo.getUsername() if (username in MinecraftHelper.getPlayerList()) { logger.info { "Authed ${session.getDisplayName()} as $username" } ChatHelper.addSystemMessageToChat( Lang.TextMessage.SystemMessage.Connection.Incoming .translate(Text.literal(payload.peerInfo.getUsername())) ) AuthAcceptedReply(messageId) } else { // not in the same server AuthenticationFailedError("Not in the same server", messageId) } } else { AuthenticationFailedError("Verification failed", messageId) } }, // for AuthAcceptedReply createMessageHandlerNoReply { _, session, _: AuthAcceptedReply, _ -> ChatHelper.addSystemMessageToChat( Lang.TextMessage.SystemMessage.Connection.Outgoing .translate(Text.literal(session.useContextSync { username })) ) }, // for TextRequest createMessageHandlerWithReply { _, session, messageId, payload: TextMessageRequest, _ -> val username = session.useContextSync { username } ?: error("Unauthorized message") when (payload.scope) { "broadcast" -> ChatHelper.addBroadcastMessageToChat( username, Text.literal(payload.content) ) "whisper" -> ChatHelper.addIncomingMessageToChat( username, Text.literal(payload.content) ) else -> logger.warn { "Unknown message scope '${payload.scope}', content: ${payload.content}" } } NoContentReply(messageId) }, // for ByeRequest createMessageHandlerNoReply { _, session, payload: ByeRequest, _ -> ChatHelper.addSystemMessageToChat( Lang.TextMessage.SystemMessage.Connection.Disconnected .translate( Text.literal(session.getDisplayName()), Text.literal(payload.reason) ) ) },大多数回复都是在聊天框中打印对应的提示信息，比如收到了谁的消息，谁断开了连接，我们连上了谁，谁连上了我们，之类的。在验证部分，除了确保对方是正版账号之外，还得确认他确实和我们处于同一个世界/服务器，不然真就跨服聊天了。后续考虑到篇幅，这一篇文章中引用了不少代码，而余下的内容还有不少。主要是拦截消息和一套指令系统，还有一些提升体验的mixin，这些我打算留到P5再写。那么这篇文章先到此为止，我们下一篇再见。-全文完-