天空 Blond - Kotlin
type: channel
link: https://www.skyblond.info/tag/Kotlin/
items: 【代码札记】从零开始的安卓应用开发 - P4
items: 【代码札记】从零开始的安卓应用开发 - P3
items: 【代码札记】从零开始的安卓应用开发 - P2
items: 【代码札记】从零开始的安卓应用开发 - P1
items: 【代码札记】从零开始的安卓应用开发 - P0
items: 【代码札记】Bencode编解码（Kotlin）
items: 【代码札记】康威生命游戏
items: 【代码札记】使用JNA在Windows上创建并操作TUN设备
items: 【代码札记】从零开始的点对点聊天系统 P5
items: 【代码札记】从零开始的点对点聊天系统 P4

【代码札记】从零开始的安卓应用开发 - P4
type: item
date: 2023-05-02T15:31:00+08:00
description: 哈哈，没想到吧，竟然还有续集！其实我也没想到。本文介绍我如何为我的安卓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…

【代码札记】从零开始的安卓应用开发 - P3
type: item
date: 2023-05-01T14:58:00+08:00
description: 本文介绍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: Dial…

【代码札记】从零开始的安卓应用开发 - P2
type: item
date: 2023-04-28T17:07:00+08:00
description: 本文来介绍安卓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提供了一种教框架构造对象的方法。但有时候只是把参数传来传去，很没意思。…

【代码札记】从零开始的安卓应用开发 - P1
type: item
date: 2023-04-26T15:31:00+08:00
description: 本文来介绍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( tabl…

【代码札记】从零开始的安卓应用开发 - P0
type: item
date: 2023-04-24T14:50:39+08:00
description: 虽然我以前也尝试过开发安卓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用来操作开启云…

【代码札记】Bencode编解码（Kotlin）
type: item
date: 2023-04-17T17:49:00+08:00
description: 不知道出于何种原因，我突然决定自己造轮子。索性这个轮子造的好像还不是特别没用。本文将记述我从零开始使用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。列表内的元素类型不受限制…

【代码札记】康威生命游戏
type: item
date: 2023-02-18T20:41:00+08:00
description: 本文记述我使用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 im…

【代码札记】使用JNA在Windows上创建并操作TUN设备
type: item
date: 2023-01-18T12:25:07+08:00
description: 虽然大家一致认为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.pca…

【代码札记】从零开始的点对点聊天系统 P5
type: item
date: 2022-12-07T01:24:00+08:00
description: 本文继续介绍如何将前文的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 …

【代码札记】从零开始的点对点聊天系统 P4
type: item
date: 2022-12-06T18:27:00+08:00
description: 本文介绍如何将前文的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空间。…