[{"content":"这个项目是什么 写谱的人经常要在不同软件之间倒腾文件：Guitar Pro 的 gp5、MuseScore 的 mscz、MusicXML、MIDI，还有各种谱站导出的 JSON。ScoreTranslate 就是用来在这些格式之间互转的工具，输入支持 Guitar Pro（.gp .gpx .gp3 .gp4 .gp5）、MusicXML、MIDI、歌曲 JSON 与 alphaTex，输出可以到 Guitar Pro、MusicXML、MIDI、JSON、alphaTex，也能导出 MuseScore、PDF、PNG 和 PNG 长图。单文件上限 50 MB。\n它强调的一点是转换都在本机完成：桌面版直接跑在本地，完整 Web 版由自己部署的 Bun 服务处理文件，纯静态 Web 版则在浏览器里用 Web Worker 转换，不会把谱子传到外部服务。\n三种形态，一套核心 项目有桌面版（Tauri）、完整 Web 版（Bun 服务加 Vite 前端）和纯静态 Web 版三种发布形态，共用同一套 TypeScript 转换核心 lib/convert.ts。桌面版不启动本地 HTTP 服务，Rust 侧只负责调用 MuseScore 和文件操作。\nGuitar Pro 与 MusicXML 系输入由 alphaTab 直接解析，读取音轨、导出 GP / GPX / GP5 / JSON / alphaTex 都不经过 MuseScore。真正需要 MuseScore 的是 MIDI 输入，以及 MIDI、PDF、PNG、MSCZ 输出。静态版因此只保留前一类能力，页面上会隐藏依赖 MuseScore 的选项。\nREADME 里比较特别的是几个自研模块：lib/gp5-writer.ts 直接写 GP5 二进制，负责调弦与弦品指派；lib/gp-to-musicxml.ts 是自己写的 MusicXML 序列化器，能保留 TAB 谱表的调弦和弦品；lib/gpx-writer.ts 则处理 GP6 XML、BCFS 扇区容器和 BCFZ 压缩写出。\n使用方法 本地跑 Web 版需要 Bun 和 MuseScore 4：\nbun install bun run dev 打开 http://localhost:3000，开发模式下页面由 Vite 提供，/api 代理到本机 3001 端口的 Bun API。生产部署用 bun run build 和 bun run start，Bun 同时提供 dist/ 静态页面和转换接口。只想部署静态版则执行 bun run build:static，生成的 dist 可以放到 Vercel 等静态托管平台。\n完整 Web 版提供 HTTP 接口，比如把 gp5 转成 PDF：\ncurl -f -O -J \\ -F \u0026#34;file=@score.gp5\u0026#34; \\ -F \u0026#34;target=pdf\u0026#34; \\ http://localhost:3000/api/convert target 可选 mid、musicxml、xml、mxl、gp、gpx、gp5、json、atex、mscz、pdf、png、png-long。另有 POST /api/tracks 读取音轨列表，配合 tracks 参数可以只导出选中的音轨；GET /api/status 用来检查 MuseScore 是否被正确找到。\n踩坑与思考 README 的「已知限制」写得挺实在。旧格式只能读：.gp3、.gp4 仅支持输入，.gp 输出为 GP7/8，.gp5 输出为 GP5 v5.00。GP5 写出器不保留和弦图，非 ASCII 的标题、歌词会降级成 ?，中文歌词建议改导 MusicXML、PDF 或 PNG。纯五线谱输出不写弦号和品位，转回 TAB 时会重新指派把位，需要保留原调弦和把位就选 TAB 或跟随原谱。\nMIDI 不携带声部、连音线、临时记号拼写这些谱面信息，MuseScore 只能靠量化推断，演奏录制的 MIDI 和自动识别的草稿仍然要对照原谱整理。\n还有一处跨软件差异：README 提到《普通朋友》样本的 957 个音符在 alphaTab 回读时音高与起止时间都保留了，但 MuseScore 4 会把结尾一处连音长音拆成两次起音，Guitar Pro 桌面端的实际发声也还没人工试听确认。格式对得上，不代表每个软件听到的都一样，这大概是做乐谱转换最麻烦的地方。\n仓库地址：tpnet/ScoreTranslate\n","permalink":"https://blog.skyhand.vip/posts/scoretranslate/","summary":"支持 Guitar Pro、MusicXML、MIDI 互转与 PDF/PNG 导出的本地乐谱转换工具。","title":"ScoreTranslate：在本机完成乐谱格式转换"},{"content":"这个项目是什么 从别的渠道下载的 mp3，拖进网易云音乐后经常认不出来：没有歌词、没有封面，也匹配不到歌曲信息。网易云在识别本地歌曲时会读取文件里的 Comment 字段，Repair163MusicComment 做的就是把这个字段补成网易云能读懂的格式。\n工具是 C# 写的，带图形界面，支持单首和批量处理。它不下载音频，只改元数据——歌还是本地那首，只是多了一个网易云认得的「身份证」。\n原理 README 把流程写得很清楚：用网易云音乐的 api 接口搜索歌曲，匹配到歌曲后得到歌曲 id，再用 api 获取歌曲详情；根据详情信息生成网易云歌曲的 Comment，写进 mp3 的 metadata。这样网易云再扫描本地文件时，就能把这首歌识别出来。\n也就是说，能否修复成功，取决于搜索接口能不能正确匹配到原曲。歌名、歌手信息干净的文件基本没问题。\n功能 README 列出的功能：\n一键批量修改、增加 mp3 的 Comment 为网易云歌曲的 Comment 单首歌修改 单首歌自定义网易云链接修改 自动更新网易云数据库信息 加解密 Comment 最后一项对排查问题有用：Comment 本身是加密的，能解出来看内容，匹配错了也能对照着查。\n使用方法 输入歌曲 mp3 文件或文件夹的地址，支持拖放 点击修改 等下方转换结果打印出来 批量处理适合整理整个音乐文件夹。如果某首歌批量匹配错了，用「单首歌自定义网易云链接修改」直接指定歌曲页面，比反复调文件名更省事。\n踩坑与思考 README 没有写额外限制。实际使用时需要注意的是匹配环节：文件名带一堆后缀、歌名被翻译过、或者同名的翻唱版本，都可能让搜索匹配到别的歌。批量跑之前先拿几首试一下，确认 Comment 写对了再处理整个文件夹，会少很多返工。\n另外这个工具依赖网易云的接口，接口一旦变化，搜索和详情获取都可能失效，这类工具的生命周期通常和平台接口绑在一起。\n仓库地址：tpnet/Repair163MusicComment\n","permalink":"https://blog.skyhand.vip/posts/repair163musiccomment/","summary":"批量修改 mp3 的 Comment 元数据，让网易云识别从其他渠道下载的歌曲。","title":"Repair163MusicComment：给 mp3 补上网易云的 Comment"},{"content":"这个项目是什么 libGDX 在 Android 上默认用 SurfaceView 渲染游戏画面。SurfaceView 性能好，但它在窗口里的层级比较特殊，不容易像普通 View 那样做透明度、旋转和动画，也很难和原生控件叠在一起。LibgdxTextureView 做的事就是把 libGDX 的渲染 View 从 SurfaceView 换成 TextureView，让游戏画面能正常参与 View 树的布局和层叠。README 注明当前支持到 libGDX 1.14.0。\n为什么用 TextureView README 对比了两种 View 的差别，TextureView 可以像普通 Android View 一样被操作：\n透明度控制：可以直接和原生 UI 组件层叠显示 动态属性修改：setAlpha()、setRotation() 等 动画处理：支持 Android 标准的 View 属性动画 SurfaceView 做这几件事要么做不到，要么得绕不少路。比如想在游戏画面上盖一层半透明的原生按钮，或者让游戏画面本身淡入淡出，换成 TextureView 之后就顺手多了。项目 README 里贴了三张对比图：SurfaceView 置底、SurfaceView 透明置顶，以及改后的 TextureView 与原生 View 透明层叠。\n使用方法 第一步是把 libs 目录里对应版本的 jar 加进项目依赖。\n第二步是在 AndroidApplication（或作为 View 嵌入的 Activity）里配置：\n// 1. 创建配置 val cfg = AndroidApplicationConfiguration() // 2. 核心：启用 TextureView cfg.useTextureView = true // 3. 可选：设置透明通道，和原生 View 透明层叠 cfg.r = 8 cfg.g = 8 cfg.b = 8 cfg.a = 8 // 4. 初始化并在布局中使用 val mGdxAdapter = MyGdxAdapter() val mGdxView = initializeForView(mGdxAdapter, cfg) // 把生成的 View 加进布局容器（例如 FrameLayout） binding.container.addView(mGdxView) 关键就是 useTextureView = true，其余初始化和原来的 libGDX 流程一致，生成的 View 直接 addView 到布局容器里。需要透明层叠时，再把 r、g、b、a 四个通道都设成 8 位。\n踩坑与思考 README 没有单独写限制，但有一处需要留意：jar 是按 libGDX 版本提供的，README 明确说的是 1.14.0。换其他版本的 libGDX 时，要确认用的是对应版本的 jar，否则替换渲染 View 的那部分可能对不上。\n另外，这个项目改的是渲染后端，适合确实需要层叠、透明度或属性动画的场景；如果只是全屏跑游戏，原生的 SurfaceView 依然是更直接的选择。\n仓库地址：tpnet/Libgdx-Backend-TextureView-Android\n","permalink":"https://blog.skyhand.vip/posts/libgdx-textureview-android/","summary":"将 libGDX 的 SurfaceView 渲染改为 TextureView，可与原生 View 透明层叠。","title":"LibgdxTextureView：把 libGDX 的渲染后端换成 TextureView"},{"content":"这个项目是什么 TypeWriterTextView 是一个用 Kotlin 写的 Android 自定义 View，作用是把一段文字逐字显示出来，做出打字机的效果。和最简单的逐字显示相比，它多做了两件事：打字过程带渐变过渡，以及允许在同一段文字里对不同区间设置不同的大小和颜色。\n它适合开场白、剧情文本、引导页这类需要一点节奏感的界面。README 用同一段示例文本，分别演示了普通打字、分段字号、分段颜色、字号加颜色四种调用方式。\n核心实现 从 README 暴露的接口看，这个控件把\u0026quot;打字\u0026quot;拆成了几个可配置的维度：\n节奏：setDurationPerLetter 设置每个字的间隔，setInterpolator 接收一个插值器，示例里用的是 DecelerateInterpolator，让整段文字的推进由快变慢，而不是匀速。 分段样式：setTextWithSize 和 setTextWithColor 都接收两个列表，分别给出若干段文字的起始下标和结束下标；落在这些区间内的字符使用指定的字号或颜色，区间之外保持默认样式。setTextWithColorAndSize 把两者合并到一次调用里。 状态回调：setWriteStatusListener 提供 onWriteStart 和 onWriteFinish，可以在开始和结束时做别的事，比如显示跳过按钮。 立即完成：setComplete 跳过动画直接显示全文，适合用户不想等的情况。 逐字绘制的内部细节，比如文字怎么切分、区间边界怎么过渡，README 没有展开，需要的话直接看源码。\n使用方法 普通打字：\nval textNormal = findViewById\u0026lt;TypeWriterTextView\u0026gt;(R.id.mTVTextNormal) textNormal.setText(\u0026#34;对，控制，我有一种特殊的精神治疗剂，给你注射了之后，你就可以安心入睡了，并且醒来后记忆不会再缺失。 但是一旦12小时之内，你没有再次注射这种药剂，那你的记忆会在短时间内全部消失。注意，即使是你不睡觉，你的记忆还是会消失\u0026#34;) 分段字号，两个列表分别表示每段文字的起止下标：\nvar sizeStartList = arrayListOf(5, 11) var sizeEndList = arrayListOf(8, 15) val textSize = findViewById\u0026lt;TypeWriterTextView\u0026gt;(R.id.mTVTextSize) textSize.setTextWithSize(\u0026#34;对，控制，我有一种特殊的精神治疗剂，给你注射了之后，你就可以安心入睡了，并且醒来后记忆不会再缺失。 但是一旦12小时之内，你没有再次注射这种药剂，那你的记忆会在短时间内全部消失。注意，即使是你不睡觉，你的记忆还是会消失\u0026#34;, 60, sizeStartList, sizeEndList) 分段颜色：\nvar colorStartList = arrayListOf(22, 30) var colorEndList = arrayListOf(26, 35) val textColor = findViewById\u0026lt;TypeWriterTextView\u0026gt;(R.id.mTVTextColor) textColor.setTextWithColor(\u0026#34;对，控制，我有一种特殊的精神治疗剂，给你注射了之后，你就可以安心入睡了，并且醒来后记忆不会再缺失。 但是一旦12小时之内，你没有再次注射这种药剂，那你的记忆会在短时间内全部消失。注意，即使是你不睡觉，你的记忆还是会消失\u0026#34;, Color.RED, colorStartList, colorEndList) 字号和颜色一起设置：\nvar colorSizeStartList = arrayListOf(15, 30) var colorSizeEndList = arrayListOf(20, 35) val textColorSize = findViewById\u0026lt;TypeWriterTextView\u0026gt;(R.id.mTVTextColorSize) textColorSize.setTextWithColorAndSize(\u0026#34;对，控制，我有一种特殊的精神治疗剂，给你注射了之后，你就可以安心入睡了，并且醒来后记忆不会再缺失。 但是一旦12小时之内，你没有再次注射这种药剂，那你的记忆会在短时间内全部消失。注意，即使是你不睡觉，你的记忆还是会消失\u0026#34;, Color.GREEN, 60, colorSizeStartList, colorSizeEndList) 其他设置：\ntextNormal.setDurationPerLetter(20) //设置每个文字的速度 textNormal.setInterpolator(DecelerateInterpolator()) //设置渐变插值器 textNormal.setComplete() //瞬间打字完成 //监听打字状态 textNormal.setWriteStatusListener(object : WriteStatusListener { override fun onWriteStart(view: TypeWriterTextView) { Log.e(\u0026#34;@@\u0026#34;, \u0026#34;开始打字啦\u0026#34;) } override fun onWriteFinish(view: TypeWriterTextView) { Log.e(\u0026#34;@@\u0026#34;, \u0026#34;打字完毕啦\u0026#34;) } }) 踩坑与思考 README 里有一句提示：效果图丢帧，请下载 demo 查看。GIF 录屏对逐字动画的还原本来就有限，帧率一低，渐变过渡的观感会失真，所以评估这类效果时最好跑一遍真机 demo。\n另外，分段样式依赖调用方手算下标。文字一改，列表里的数字就要跟着改，在文案频繁变动的场景里比较麻烦。如果要在自己的项目里用，可以再包一层按关键词或标点自动计算区间的逻辑。\n仓库地址：tpnet/TypeWriterTextView\n","permalink":"https://blog.skyhand.vip/posts/typewritertextview/","summary":"一个支持渐变打字、可分段设置字号和颜色的 Android 自定义 TextView。","title":"TypeWriterTextView：Android 上的渐变打字机效果"},{"content":"这个项目是什么 ProcessorDemo 是一个学习性质的项目，目标是把 ButterKnife 里 BindView 那一套功能自己实现一遍，借此理解 APT（Annotation Processing Tool，编译期注解处理）是怎么工作的。\nREADME 的原话是\u0026quot;仿写ButterKnife的BindView功能，AOP思想的APT方式学习\u0026quot;。重点不在于做一个能替代 ButterKnife 的库，而在于把\u0026quot;写注解、编译期生成代码、运行时调用生成代码\u0026quot;这条链路完整走通。看懂之后，再去看 ButterKnife、Dagger、Room 这类框架，就不会觉得它们在变魔术了。\nAPT 的思路 ButterKnife 这类框架之所以快，是因为它不在运行时用反射找控件。大致流程是：\n在字段上写注解，标记它对应哪个控件 id； 编译时，注解处理器扫描这些注解，生成一段普通的 Java 代码，内容就是 findViewById 加类型转换； 运行时调用生成的绑定类，走的是直接方法调用，没有反射开销。 反射版本的绑定写起来更省事，代码量也少，但每次都要在运行时解析注解、查找字段，性能和可混淆性都吃亏。APT 把这份工作提前到编译期，代价是要多维护一个 processor 模块，还要处理生成代码的命名、增量编译之类的问题。\n这也解释了为什么很多主流库都选择在编译期生成代码：把不确定性留在构建阶段，运行时就只剩确定的分支。\n适合谁看 如果你想搞清楚注解处理器到底在什么时候运行、能拿到哪些信息、生成的代码长什么样，这个仓库可以当作一个最小样本。README 没有给出使用说明，直接读代码比看文档更合适——先看注解的定义，再看 processor 里的处理逻辑，最后看生成出来的绑定代码，顺序理清了，整个机制也就清楚了。\n仓库地址：tpnet/ProcessorDemo\n","permalink":"https://blog.skyhand.vip/posts/processordemo/","summary":"用编译期注解处理（APT）仿写 ButterKnife 的 BindView，作为学习样本。","title":"ProcessorDemo：用 APT 仿写 ButterKnife 的 BindView"},{"content":"这个项目是什么 这个仓库保存的是微信 Android SDK 的源码。README 只有一句话：\u0026ldquo;微信Android SDK的源码，可以用来进行修改。\u0026ldquo;定位很清楚：它不是教你接入微信登录或分享的教程项目，而是一份可以编译、可以改动的 SDK 本体。\n平时接入第三方 SDK，拿到的通常是打包好的依赖，只能按文档调用，遇到文档没写清楚的行为就只能猜。有了源码，事情会不一样：内部怎么处理回调、怎么和微信客户端通信、参数在什么条件下会被忽略，都可以直接读出来。\n能用来做什么 读实现：想看某个接口内部的处理流程，源码比反编译出来的代码可读得多，变量名和结构都是完整的。 改行为：遇到需要调整的地方，可以直接在源码上修改，再重新打包成依赖使用。这也是 README 里\u0026quot;可以用来进行修改\u0026quot;最直接的用法。 做参考：自己封装微信相关功能时，拿它当对照实现，比只看接口文档更容易理解边界情况。 需要注意 README 没有提供构建方式、版本说明或接入文档，仓库本身也不包含使用示例。使用前需要自己确认这份源码对应的是哪个 SDK 版本，以及它与当前微信客户端的兼容情况——第三方 SDK 的源码一旦和线上版本脱节，排查问题会变得很麻烦。\n另外，修改第三方 SDK 并重新分发，涉及原厂的许可与合规要求，这一点需要自行确认后再决定怎么用。\n仓库地址：tpnet/WeiXinSDKSource-Android\n","permalink":"https://blog.skyhand.vip/posts/weixinsdk-source-android/","summary":"微信 Android SDK 的源码副本，方便阅读实现并修改后重新打包。","title":"WeiXinSDKSource-Android：可以改的微信 Android SDK 源码"},{"content":"这个项目是什么 登录、注册、找回密码都要收短信验证码，用户来回切换应用看短信再手动输入，体验很割裂。AutoVerifyCode 把这段流程包成一个库：监听短信、按规则过滤出验证码，自动填进指定的 EditText。运行时权限申请也内置了，不用在每个页面再写一遍短信权限逻辑。\n核心实现 库的配置集中在 AutoVerifyCodeConfig，用 Builder 构造，可配置的项包括：\n验证码长度 验证码类型：大写字母、小写字母、大小写字母、数字、数字字母 短信发送者号码，或发送者号码的前几位 短信正文的开头文字 短信正文包含的关键字 后两项用于区分场景。同一个应用里注册和重置密码的短信文案不同，发送号码也可能不同，用开头文字和包含文字过滤，能避免拿到不该拿的那条。比如验证码短信统一以「【守护APP】」开头，重置类短信正文含「重置」，两个条件一卡，基本不会误匹配。\n除了自动填入，短信内容、发送者、解析出的验证码都通过 SmsCallBack 回调出来，可以自己做后续处理。权限走 PermissionCallBack，onFail() 返回 true 表示重试申请；README 的注释里也说明，可以自己手动去申请权限，申请完再返回 true。\n使用方法 最简单的用法，适用于 4 到 6 位数字验证码：\nAutoVerifyCode.getInstance() .with(MainActivity.this) .into((EditText) findViewById(R.id.et_code)) .start(); 需要精细控制时，先建配置再链式设置回调：\nAutoVerifyCodeConfig config = new AutoVerifyCodeConfig.Builder() .codeLength(6) .smsCodeType(AutoVerifyCodeConfig.CODE_TYPE_NUMBER) .smsSenderStart(\u0026#34;650\u0026#34;) .smsSender(\u0026#34;6505551212\u0026#34;) .smsBodyStartWith(\u0026#34;【守护APP】\u0026#34;) .smsBodyContains(\u0026#34;重置\u0026#34;) .build(); AutoVerifyCode.getInstance() .with(MainActivity.this) .config(config) .smsCallback(new MessageCallBack()) .permissionCallback(new PerCallBack()) .inputCompleteCallback(new OnInputCompleteListener() { @Override public void onInputComplete(String text) { // 自动输入完毕，可以继续登录流程 } }) .into((EditText) findViewById(R.id.et_code)) .start(); 回调接口按 README 的写法实现即可：SmsCallBack 有 onGetCode、onGetMessage、onGetSender 三个方法，PermissionCallBack 有 onSuccess 和 onFail。\n踩坑与思考 README 特意提了释放。这个库一般只用一次，所以页面销毁时要调用：\n@Override protected void onDestroy() { super.onDestroy(); AutoVerifyCode.getInstance().release(); } 短信权限属于敏感权限，申请被拒后不建议在 onFail() 里无条件返回 true 死循环重试，给个提示、引导用户去设置页打开会更稳妥。\n仓库地址：tpnet/AutoVerifyCode\n","permalink":"https://blog.skyhand.vip/posts/autoverifycode/","summary":"Android 短信验证码自动输入库，内嵌权限申请，支持长度、类型与内容过滤。","title":"AutoVerifyCode：自动读取并填入短信验证码"},{"content":"这个项目是什么 TPBluetooth 是一个用 Java 写的 Android 蓝牙 SDK，目标是把传统蓝牙（Classic）和低功耗蓝牙（BLE）放进同一套封装里处理。它不是只做\u0026quot;连上设备读数据\u0026quot;的客户端工具，按 README 的说法，两种模式都可以作为客户端或服务端来读写数据。\n对于需要在两台 Android 设备之间直接传数据，或者让手机充当外设的场景，这类封装能省掉不少重复的蓝牙协议代码：扫描、配对、建立连接、收发数据，每一步单独写都不复杂，拼在一起却很容易乱。\n能力范围 README 里明确提到的几点：\n支持 Classic 传统蓝牙与 BLE 低功耗蓝牙； 既可以作为客户端读写数据，也可以作为服务端读写数据； 传统蓝牙的服务端支持一对多，也就是一个服务端同时对接多个客户端。 最后一点是它和多数只做点对点连接的蓝牙封装不太一样的地方。一对多意味着服务端要为每条连接单独维护状态，收到数据时也要能区分来源，再决定回给谁。这部分逻辑通常比客户端复杂得多。\n当前状态 README 写得很直接：目前还在开发，待完成。所以它更适合当作学习蓝牙连接流程的参考，或者在你愿意跟进代码变化的前提下使用。如果要用在正式项目里，建议先通读一遍源码，确认自己需要的功能是否已经实现，再决定是否接入。\n使用方法 README 没有给出示例代码，也没有接入文档，所以这里没法贴出可靠的调用片段。想上手的话，可以从源码里的入口类看起，按 Classic / BLE、客户端 / 服务端这四种组合分别对照，理清各自的连接和数据读写流程。\n仓库地址：tpnet/TPBluetooth\n","permalink":"https://blog.skyhand.vip/posts/tpbluetooth/","summary":"一套 Android 蓝牙封装，Classic 与 BLE 都能作为客户端或服务端读写数据。","title":"TPBluetooth：同时覆盖经典蓝牙与 BLE 的 Android 蓝牙 SDK"},{"content":"这个项目是什么 RetrofitDownloadManager 解决的是 App 里\u0026quot;多个文件同时下载、中途断了能接着下、进度要实时显示在列表里\u0026quot;这件事。README 给出的技术组合是 RxJava + Retrofit + SqlBrite + SqlDelight，作者另有一篇文章专门讲源码，链接放在仓库里。\n断点续传本身不算难，难的是把下载状态持久化，再把状态变化推回界面。这个项目的做法是用数据库存任务和进度，用响应式的方式把数据库变化变成列表更新，整体思路值得参考。\n技术组合的分工 Retrofit 负责网络请求，下载任务最终都落到它身上； RxJava 串起下载流程和线程切换，也是回调和进度传递的基础； SqlDelight 用 SQL 生成类型安全的 Java 接口，省掉手写 DAO 的重复劳动； SqlBrite 把 SQLite 查询包成可观察对象，数据一变，订阅方就能收到通知。 这四个库各管一段：网络、异步、持久化、状态推送，拼起来正好覆盖一个下载管理器的完整职责。\n使用方法 以 module 形式添加到项目。\n在 Application 里初始化下载器：\nDownManager.init(this); 添加下载任务，参数依次是文件保存路径、完整的下载 URL、任务名称： //创建基本任务，参数为:文件保存的路径、下载的url要全路径、下载任务的名称 DownInfo downInfo = DownInfo.builder().create(getPath(url), url, name); //开始下载 DownManager.getInstance().startDown(downInfo); 查询当前的下载列表： //查询当前所有的下载 DBUtil.getInstance().getAllDown() .observeOn(AndroidSchedulers.mainThread()) .subscribe(adapter); 设置 View 的回调监听器。README 特别提醒要设置两个：一个在 ViewHolder 初始化时通过 RxBus 挂上，另一个在 onBindViewHolder 时添加。 public ListViewHolder(View itemView) { super(itemView); // 初始化View mBtHandle.setOnClickListener(this); //因为(downInfo.downUrl()用来传递进度信息，这里使用两个(downInfo.downUrl()进行标识 RxBus.with().setEvent(DownManager.DOWN_ADD_SUBSCRIBE) .onNext(new Action1\u0026lt;Events\u0026lt;?\u0026gt;\u0026gt;() { @Override public void call(Events\u0026lt;?\u0026gt; events) { //添加监听器，在列表点击开始回调 String link = events.getContent(); link = link.replace(DownManager.DOWN_ADD_SUBSCRIBE, \u0026#34;\u0026#34;); DownManager.getInstance().addListener(link, listener); } }).create(); } @Override public void onBindViewHolder(final ListViewHolder holder, final int position) { //在Holder里面设置数据 //添加View监听器 DownManager.getInstance().addListener(downInfo.downUrl(), listener); } 踩坑与思考 README 强调\u0026quot;要设置两个 View 的回调监听器\u0026quot;，这是使用这套代码时最容易漏的地方。一个可能的原因是列表复用：同一个 item 在不同位置会绑定不同的下载任务，只在初始化时挂一次监听，滚动之后进度就会串到别的行上。所以绑定数据时要重新 addListener。\n另外，进度是按 downInfo.downUrl() 来对应任务的，README 的注释里写得很直白。用 URL 作为标识，意味着同一个地址在管理器里只对应一份任务状态；如果你的场景需要同一个文件发起多个独立任务，这一点要提前考虑。\n最后，这套代码基于 RxJava 1 时代的 Action1、Events 等类型，直接搬到新项目里需要先做一轮升级。\n仓库地址：tpnet/RetrofitDownloadManager\n","permalink":"https://blog.skyhand.vip/posts/retrofitdownloadmanager/","summary":"RxJava + Retrofit + SqlBrite + SqlDelight 实现的多文件断点续传下载。","title":"RetrofitDownloadManager：多文件断点续传下载的完整实现"},{"content":"这个项目是什么 ImoocVideoMerge 是一个 Android 应用，用来把慕课网上的课程视频按分类另存到手机里，方便离线观看。README 里的截图展示了完整流程：主界面列出视频分类，进入分类后是视频列表，选中后批量保存，完成后文件落在存储卡根目录。\n它属于那种\u0026quot;自己用得上就顺手写一个\u0026quot;的小工具，界面不复杂，功能路径很短：找到视频、选好、保存、去目录里看。\n主要功能 根据 README 的截图和更新记录，它能做这些事：\n按分类浏览：主界面按视频分类组织内容，进入分类后是具体的视频列表； 批量保存：可以选中某个分类下的视频一起保存，不用一条条点； 自定义保存位置：默认保存到存储卡根目录，也可以设置保存到 SD 卡； 读取外置存储：5 月 19 日的更新加入了读取外插 SD 卡内容的能力； 打开所在目录：菜单里新增了\u0026quot;打开视频所在目录\u0026quot;，保存完可以直接跳过去查看。 5 月 19 日的更新还包括一项修复：带斜杠的文件名保存失败。视频标题里出现斜杠是常事，而斜杠在文件系统里是路径分隔符，直接拿来当文件名就会出错，这类问题只有实际用起来才会碰到。\n踩坑与思考 README 里最值得记的一条限制写得很坦白：不能另存收费的视频。\n原因是收费课程用 m3u8 把视频切成了多个 ts 分片，而且做了加密，作者当时没有研究出解密的方法。这条限制其实划出了这类工具的能力边界：能直接拿到完整文件地址的普通视频，下载下来就行；一旦服务端把内容切片并加密，客户端拿到的只是一堆加密分片，没有密钥就拼不出可播放的视频。\n也正因为如此，后续的更新把精力放在了存储路径、文件名处理这些工程细节上，而不是继续和加密较劲。对一个自用工具来说，这个取舍是合理的：能覆盖的场景做好，覆盖不了的直接说明。\n仓库地址：tpnet/ImoocVideoMerge\n","permalink":"https://blog.skyhand.vip/posts/imoocvideomerge/","summary":"一个 Android 小工具，按分类把慕课网课程视频批量保存到本地。","title":"ImoocVideoMerge：把慕课网的课程视频另存到本地"},{"content":"这个项目是什么 TextView 支持 Spannable，但真用起来，一个页面往往要写一堆 setSpan、ClickableSpan、ImageSpan 的样板代码：给某段文字加颜色、下划线，让某几个字可点击，在文字中间插一张图，文字太长时缩略显示或者跑马灯。SpanTextView 把这些局部操作收进一个 TextView 子类，调用从一堆 View 操作变成一行。\n按 README 的说法，如果不做这层封装，这些代码都得堆在 Activity 里。\n能做什么 README 里的操作大致分几类：\n局部点击：给指定文字设置点击事件，回调里带 text、position 和 sign 标识 局部格式：字号、颜色、背景色、圆角背景、下划线 图片：在最前面、中间、最后插入图片，把某段文字替换成图片，图片也可以带点击回调 跑马灯：setMarquee(true) 开启，可设置重复次数 清除：按设置时返回的对象清除单个 span，或者 clearAllSpan() 全部清除 用 sign 字符串把设置和回调对应起来，是这套 API 的关键设计。设置时给一个标识，点击回调里原样带回来，不用靠位置去猜点的是哪一个。图片的点击回调也是同样的思路，onImageLinkClick 里拿到 position 和 sign。\n使用方法 设置局部点击：\n// 设置点击事件 tvTest.setSpanLink(\u0026#34;链接1\u0026#34;, SIGN_ONE, false, Color.GREEN); tvTest.setSpanLink(\u0026#34;链接2\u0026#34;, SIGN_TWO, false); tvTest.setSpanLink(\u0026#34;链接3\u0026#34;, SIGN_THREE); // 点击事件回调 tvTest.setOnTextLinkClickListener(new SpanTextView.onTextLinkClickListener() { @Override public void onTextLinkClick(View view, String text, int position, String sign) { // 根据 sign 区分点的是哪一个 } }); 插入和替换图片：\n// 添加图片到最前面 tvTest.addImageToFirst(R.drawable.one, SpanTextView.TEXT_SIZE); // 添加图片到中间（\u0026#34;中间添加\u0026#34;的后面） tvTest.setImage(R.drawable.two, \u0026#34;中间添加\u0026#34;, SpanTextView.AFTER_IMAGE); // 添加图片到最后面 tvTest.addImageToLast(R.drawable.four); // 替换文本为图片 tvTest.replaceTextToImage(\u0026#34;图片标识\u0026#34;, R.drawable.three); 带点击的图片，在方法最后多传一个标识字符串，再监听 onImageLinkClickListener；回调里还能用 replaceImageSpan 把图片换成另一张。\n格式类操作同样是按文字内容定位：\ntvTest.setSpanTextSize(\u0026#34;大小\u0026#34;, 30); tvTest.setSpanTextBack(\u0026#34;普通背景\u0026#34;, Color.BLUE); tvTest.setSpanTextBack(\u0026#34;圆角背景\u0026#34;, Color.GREEN, 10); tvTest.setSpanTextColor(\u0026#34;颜色\u0026#34;, Color.RED); tvTest.setUnderLine(\u0026#34;下划线\u0026#34;, true); 踩坑与思考 README 末尾写「还有其他的功能，具体请查看源码方法」，说明文档没有覆盖全部方法，接入前最好过一遍源码。\n这类封装最容易出问题的地方是文本变化之后 span 的位置失效：文字一改，按位置设置的 span 就对不上了。SpanTextView 的接口大多用文字内容或 sign 来定位，而不是直接传下标，用起来会稳一些，但也意味着同一段文字出现多次时要留意到底匹配到的是哪一处。\n仓库地址：tpnet/SpanTextView\n","permalink":"https://blog.skyhand.vip/posts/spantextview/","summary":"封装 TextView 局部 Span 操作：局部点击、格式、图片插入替换、跑马灯。","title":"SpanTextView：把 TextView 的局部 Span 操作封装起来"},{"content":"这个项目是什么 GlideUtils 是对 Glide 图片加载库的一层 Java 封装。项目里加载图片的代码翻来覆去就是那几种：普通图片、GIF、需要占位图和错误图的图片，各自还要配监听器处理成功和失败。这个类把这些调用收敛成静态方法，调用方少写一堆样板代码。\nREADME 里特别强调的一点是：占位符不会覆盖 CircleImageView。用 Glide 给圆形头像加占位图时，方形的占位图会盖住圆形裁剪，这是早期项目里很常见的一个坑，封装层把这个问题处理掉了。\n封装了什么 普通图片：loadImage(url, imageView) 一行即可，需要传入上下文、占位图和错误图时用参数更长的重载。 动图与静态图：分别有加载入口，都可以挂 ImageLoadListener，在 onLoadingComplete 和 onLoadingError 里收尾，比如隐藏进度条、切换到失败布局。 直接取资源：README 提到除了把图片加载到 View，也可以获取 Bitmap 或 Drawable。 内存清理：clearMemory 用于页面销毁时释放占用的内存。 使用方法 普通加载图片，支持 CircleImageView：\nGlideUtils.loadImage(url, imageview); //GlideUtils.loadImage(this, url, imageview, null); 需要占位图和错误图时，在参数后面补上：\nGlideUtils.loadImage(this, url, imageview, null, R.drawable.default_article_image, R.drawable.default_article_image); 加载 Gif，带监听：\nGlideUtils.loadImageGif(mImageUrl, mImageView, new GlideUtils.ImageLoadListener\u0026lt;String, GifDrawable\u0026gt;() { @Override public void onLoadingComplete(String uri, ImageView view, GifDrawable resource) { progressBar.setVisibility(View.GONE); } @Override public void onLoadingError(String source, Exception e) { LogUtil.e(\u0026#34;im查看图片加载失败:\u0026#34; + e); mEntry.setIsDownload(false); progressBar.setVisibility(View.GONE); mFailLayout.setVisibility(View.VISIBLE); } }); 加载静态图片：\nGlideUtils.loadImage(this, mImageUrl, mImageView, new GlideUtils.ImageLoadListener\u0026lt;String, GlideDrawable\u0026gt;() { @Override public void onLoadingComplete(String uri, ImageView view, GlideDrawable resource) { progressBar.setVisibility(View.GONE); mEntry.setIsDownload(true); } @Override public void onLoadingError(String source, Exception e) { LogUtil.e(\u0026#34;im查看图片加载失败:\u0026#34; + e + \u0026#34;连接:\u0026#34; + source); mEntry.setIsDownload(false); progressBar.setVisibility(View.GONE); mFailLayout.setVisibility(View.VISIBLE); } }); 不需要监听时，把监听参数传 null 即可。\n踩坑与思考 README 用加粗标出了最重要的一条：多图界面在 onDestroy 里要调用 GlideUtils.clearMemory(this)，示例后面还跟了一句 System.gc()。\n手动触发 GC 在今天不算推荐做法，垃圾回收什么时候跑应该交给系统；但在当年的内存环境下，它常被当作兜底手段。如果现在还要用这个类，建议保留 clearMemory，把 System.gc() 去掉，避免每次退出页面都触发一次全局 GC 影响流畅度。\n另外，示例里用的是 GlideDrawable 和 GifDrawable 这两个类型，属于 Glide 3 时代的 API。如果项目已经升级到 Glide 4，类型和加载接口都变了，这部分需要跟着调整。\n仓库地址：tpnet/GlideUtils\n","permalink":"https://blog.skyhand.vip/posts/glideutils/","summary":"Glide 的封装类，统一处理占位图、GIF、加载监听与内存清理。","title":"GlideUtils：把 Glide 的常用调用收拢成一个工具类"},{"content":"这个项目是什么 BarrageView 是一个用来做弹幕、滚屏效果的自定义 View，适合直播、视频播放器、活动页这类需要在画面上滚动一排排文字的场合。README 很短，项目本身也只做一件事：让文字从屏幕一侧滚到另一侧，并且滚得自然一点。\n实现要点 README 列出的特性有五条：\n字体大小在一个范围内随机 颜色随机 滚动速度在一个范围内随机 支持点击回调 隐藏后恢复不会有停滞 前三条是为了让弹幕看起来不那么整齐。如果每条弹幕字号、颜色、速度都一样，视觉上会排成规整的队列，反而像列表在滚动。把这三个参数都做成范围内的随机值，弹幕之间自然错开，也更接近真实弹幕池的效果。\n点击回调意味着 View 需要判断点击落在哪一条弹幕上，而不是把整个区域的点击都交给同一个处理者。滚动中的文字位置一直在变，命中判断得跟着每一条的当前位置走。\n「隐藏后恢复不会有停滞」是滚动类 View 常见的坑。View 被切到后台或者从窗口移除后再回来，如果位置按帧累加，恢复时容易出现时间对不上、弹幕挤成一团或者突然跳一大段的情况。README 把这一点单独列出来，说明作者专门处理过。\n使用方法 README 没有贴接入代码，集成方式就是普通自定义 View 的流程：把 BarrageView 放进布局，在代码里设置弹幕内容和滚动参数，需要响应点击时注册点击回调，具体方法名以仓库源码为准。仓库里的 test.gif 是效果演示，接入前可以先看一眼实际滚动效果是否符合预期。\n踩坑与思考 README 没有写限制和注意事项。从随机化这个设计出发，实际使用时最好给字号、速度和颜色都定一个合理区间：跨度太小看不出随机，跨度太大又会显得杂乱，而且速度快的弹幕如果字号也大，很容易盖住别的弹幕。另外弹幕数量多时要注意绘制开销，这一点和所有自绘 View 一样。\n仓库地址：tpnet/BarrageView-Android\n","permalink":"https://blog.skyhand.vip/posts/barrageview-android/","summary":"一个支持随机字号、颜色与速度，带点击回调的 Android 弹幕滚屏 View。","title":"BarrageView：Android 弹幕与滚屏自定义 View"},{"content":"关于我 Android 开发者，日常写 Kotlin / Java，偶尔碰 TypeScript、C# 和一点逆向。\n感兴趣的方向：\nXposed / LSPosed 模块开发 Android 逆向与加固对抗 自定义 View、音视频与各种小工具 服务端：Spring Boot、Kotlin 关于这个博客 这里按项目归档我写过的开源仓库，每个项目一篇文章：它解决什么问题、核心实现是什么、怎么用、踩过哪些坑。\n代码都在 GitHub @tpnet。\n本站由 Hugo + PaperMod 构建，纯静态部署。\n","permalink":"https://blog.skyhand.vip/about/","summary":"关于我和这个博客","title":"关于"}]