-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathatom.xml
More file actions
1383 lines (1359 loc) · 91.5 KB
/
atom.xml
File metadata and controls
1383 lines (1359 loc) · 91.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>https://LiarrDev.github.io</id>
<title>Liarr's Studio</title>
<updated>2026-05-27T16:30:56.302Z</updated>
<generator>https://github.com/jpmonette/feed</generator>
<link rel="alternate" href="https://LiarrDev.github.io"/>
<link rel="self" href="https://LiarrDev.github.io/atom.xml"/>
<subtitle>温故而知新</subtitle>
<logo>https://LiarrDev.github.io/images/avatar.png</logo>
<icon>https://LiarrDev.github.io/favicon.ico</icon>
<rights>All rights reserved 2026, Liarr's Studio</rights>
<entry>
<title type="html"><![CDATA[本地音乐库构建:筛选高码率音频]]></title>
<id>https://LiarrDev.github.io/post/Local-Music-Library-Filtering-High-Bitrate-Audio/</id>
<link href="https://LiarrDev.github.io/post/Local-Music-Library-Filtering-High-Bitrate-Audio/">
</link>
<updated>2026-05-27T16:00:00.000Z</updated>
<content type="html"><![CDATA[<p>有看过之前文章『<a href="https://liarrdev.github.io/post/Reading-and-Manipulating-MP3-ID3-Tags-with-mp3agic/">使用 mp3agic 读写 MP3 的 ID3 标签</a>』的读者们应该知道,前段时间我在整理本地音乐库,本篇是对『<a href="https://liarrdev.github.io/post/Local-Music-Library-Audio-Processing/">本地音乐库构建:音源处理</a>』的补充。</p>
<p>前面提到我本地的音乐库体积庞大,经过这次整理和添加,体积已经突破 30GB,并且还在继续膨胀。</p>
<p>众所周知,最近内存价格疯涨,存储空间越来越宝贵,我不得不考虑如何压缩音乐库的体积。</p>
<p>直接用 ZIP 压缩音乐库首先被我排除。原因很简单:ZIP 需要解压后才能读取文件,使用上不够便捷;而且 MP3 本身就是高度压缩的格式,ZIP 几乎无法进一步压缩。</p>
<p>看来只能从音频本身入手了,要么转换格式,要么降低码率。</p>
<p>而在之前『<a href="https://liarrdev.github.io/post/Local-Music-Library-Audio-Processing/">本地音乐库构建:音源处理</a>』中,我通过 FFmpeg 将 OGG 转为 MP3,主要是为了更好的兼容性,目前没有再迁移格式的打算,那么可行的方案就只有降低码率了,准确来说,应该是比特率。因为目前我手头上已经没有高功率的发烧设备,以后大概率也不会再购买,听音乐的需求停留在能听个响就行。</p>
<p>在音频文件中,kbps 是“千比特每秒”(Kilobits Per Second)的缩写,它代表了音频数据的比特率。比特率是指每秒钟传输或处理的比特(bit)数量,是衡量音频质量和文件大小的一个重要指标。一般来说,比特率越高,音频文件的质量越好,因为它能够编码更多的音频数据,提供更丰富的音质细节。然而,更高的比特率也意味着更大的文件大小。</p>
<p>我们在音乐平台选择不同音质时,本质上就是在选择不同的码率:</p>
<figure data-type="image" tabindex="1"><img src="https://LiarrDev.github.io/post-images/1779815771951.png" alt="音乐平台不同音质选择" loading="lazy"></figure>
<p>为了节约存储空间,我一般都是选择「标准音质」,通常情况下,它是 128kbps。</p>
<p>但部分音频可能是早年用不明来源下载的,码率偏高。比如『<a href="https://melodyinbox.com">MelodyInbox</a>』虽然说有多种码率选择,但很多时候都指向同一个高码率的音频文件。</p>
<p>既然如此,我需要先从数千首歌中筛选出高码率音频,再去寻找低码率替代源。</p>
<p>之前做音源处理时就有读者问:如此繁琐的工作,为什么不交给 AI?</p>
<p>不得不承认,AI 时代确实已经到来,但很遗憾,有些场景下程序员的效率依然更高。</p>
<p>以我使用 Claude Code 搭配 MiniMax M2.7 HighSpeed 模型为例,让它列出所有大于 200kbps 的音频文件,等了 6 分钟仍无结果:</p>
<figure data-type="image" tabindex="2"><img src="https://LiarrDev.github.io/post-images/1779815810800.png" alt="AI 筛选" loading="lazy"></figure>
<p>无奈只能关掉 AI,转而手搓脚本:</p>
<pre><code class="language-bash">#!/bin/bash
# 查找当前目录及子目录下所有码率大于 200kbps 的 MP3 文件
# 使用 ffprobe 获取码率,不修改任何文件
echo "正在扫描 MP3 文件..."
while IFS= read -r -d '' file; do
bitrate=$(ffprobe -v error -show_entries format=bit_rate -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null)
size=$(stat -f%z "$file" 2>/dev/null)
if [ -n "$bitrate" ] && [ "$bitrate" -gt 200000 ]; then
size_mb=$(awk "BEGIN {printf \"%.2f\", $size / 1024 / 1024}")
echo "$file | 码率: $((bitrate / 1000)) kbps | 大小: ${size_mb} MB"
fi
done < <(find . -name "*.mp3" -type f -print0)
echo "扫描完成。"
</code></pre>
<p>脚本比较简单,只运行了 2 分多钟就把我需要的结果筛选出来了。</p>
<figure data-type="image" tabindex="3"><img src="https://LiarrDev.github.io/post-images/1779815830532.png" alt="脚本筛选" loading="lazy"></figure>
<p>接下来就是去寻找低码率的替代源。如果实在找不到,可能会考虑对高码率音频进行二次压缩,但这并非最佳方案——音频本身就是从母带压缩而来,二次压缩会导致失真叠加,还是应该优先寻找原生低码率版本。</p>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[让 JetBrains IDEs 连上本地 Claude Code]]></title>
<id>https://LiarrDev.github.io/post/Connect-JetBrains-IDEs-to-Claude-Code/</id>
<link href="https://LiarrDev.github.io/post/Connect-JetBrains-IDEs-to-Claude-Code/">
</link>
<updated>2026-05-11T16:00:00.000Z</updated>
<content type="html"><![CDATA[<p>如今各种 AI 编程工具层出不穷,谁才是真正的“最强王者”我不敢妄下定论,但我相信 Claude 在广大开发者的心中必有一席之地。</p>
<figure data-type="image" tabindex="1"><img src="https://LiarrDev.github.io/post-images/1778428256446.jpg" alt="Claude" loading="lazy"></figure>
<p>就我目前的体验来看,哪怕使用同样的模型,Claude 在任务调度方面,表现依然优于许多 AI IDE。</p>
<p>但即便我是一名开发者,我依然讨厌面对 Claude Code 那黑压压的终端窗口,GUI 才是我的真爱。所以现在大多数 AI 开发任务我都会先交给 Trae 处理,只有在 Trae 不给力的时候,才“退而求其次”回到 Claude Code。</p>
<figure data-type="image" tabindex="2"><img src="https://LiarrDev.github.io/post-images/1778428305862.webp" alt="Claude Code" loading="lazy"></figure>
<p>为什么不用 Claude Desktop?主要是觉得它的界面设计更适合纯 Vibe Coding,对我这种习惯了“古法编程 + AI 辅助”的开发方式来说,实在不够友好。</p>
<figure data-type="image" tabindex="3"><img src="https://LiarrDev.github.io/post-images/1778428327300.png" alt="Claude Desktop" loading="lazy"></figure>
<p>论写代码,没有什么比“在自己熟悉的 IDE 面板里直接对话”更舒服了。那有没有办法把 Claude Code 接进我日常使用的 Android Studio 或 IntelliJ IDEA 呢?</p>
<p>官方的确发布了一款<a href="https://plugins.jetbrains.com/plugin/27310-claude-code-beta-">面向 JetBrains IDEs 的插件</a>,可我下载下来之后却大呼上当。它本质上就是在 IDE 内置终端里运行 Claude Code,只不过多了个 Diff Viewer 之类的功能,能查看文件差异,但我仍然逃不掉那个熟悉的黑框。</p>
<figure data-type="image" tabindex="4"><img src="https://LiarrDev.github.io/post-images/1778428381072.png" alt="Claude Code for JetBrains" loading="lazy"></figure>
<p>反观<a href="https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code">给 VS Code 的插件</a>,从截图就能看到它提供了专门的对话输入界面,看上去友好得多。</p>
<figure data-type="image" tabindex="5"><img src="https://LiarrDev.github.io/post-images/1778428417163.png" alt="Claude Code for VSCode" loading="lazy"></figure>
<p>既然官方支持有限,那就只能另辟蹊径了。</p>
<p>2025 年底,JetBrains 为 AI Assistant 插件推送了一个重磅功能:支持自定义 <a href="https://www.jetbrains.com/acp/">ACP(Agent Client Protocol)</a> 配置。这意味着你可以把任意一个支持 ACP 的 Agent <a href="https://www.jetbrains.com/zh-cn/help/ai-assistant/2026.1/acp.html#">接入到 AI Assistant</a>,Claude Code 自然也不例外。</p>
<figure data-type="image" tabindex="6"><img src="https://LiarrDev.github.io/post-images/1778428661833.webp" alt="ACP" loading="lazy"></figure>
<p>ACP 是一种开放协议,专门用来规范 AI Agent 与 IDE 之间的通信方式。它有点类似于 Language Server Protocol (LSP),但专注于 AI 代理的集成,让开发者可以在不同编辑器中使用各种各样的 AI 工具,而不用为每个组合单独造适配器。这个协议由 Zed Industries(Zed 编辑器的开发者)主导,与 Anthropic、Google 等合作开发,JetBrains 也已经加入其中。</p>
<p>在 ACP 出现之前,每个 AI Agent 想要接入某个 IDE,都得单独开发一套适配器。Claude Code 写一套,Cursor 写一套,Windsurf 再写一套——大量重复造轮子,而且每当 Agent 更新,适配器也必须跟着改。</p>
<p>ACP 的核心思路就是:定义一套标准通信协议,任何 Agent 只要实现了 ACP Server,任何 IDE 只要实现了 ACP Client,二者就能直接对接。</p>
<p>了解了 ACP,我们开始实操。</p>
<p>首先确认你已经安装了 <a href="https://plugins.jetbrains.com/plugin/15637-jetbrains-ai-assistant">JetBrains AI Assistant</a> 插件。在 IntelliJ IDEA 里它默认就捆绑好了,但在 Android Studio 中需要自行安装。</p>
<figure data-type="image" tabindex="7"><img src="https://LiarrDev.github.io/post-images/1778428477147.png" alt="JetBrains AI Assistan Plugin" loading="lazy"></figure>
<p>同时确保你已经装好并正常配置了 Claude Code。本文不再赘述安装和配置过程,如果你的 Claude Code CLI 无法正常工作,后续操作将无法进行。</p>
<p>接下来,开始配置 ACP。</p>
<p>先安装 <a href="https://www.npmjs.com/package/@agentclientprotocol/claude-agent-acp"><code>@agentclientprotocol/claude-agent-acp</code></a>。这是 Zed 官方提供的 ACP 适配器,用来桥接 Claude Code 和 ACP 协议。它的前身是 <a href="https://www.npmjs.com/package/@zed-industries/claude-code-acp"><code>@zed-industries/claude-code-acp</code></a>,如果你看到这个包名,不用顾虑,它们是一脉相承的。</p>
<pre><code class="language-bash">➜ npm install -g @agentclientprotocol/claude-agent-acp
</code></pre>
<p>注意必须用 <code>-g</code> 全局安装,这样 ACP Server 才能作为独立命令被调用。这里我用的是 <code>npm</code>,如果你习惯其他包管理器(比如 <code>pnpm</code> 等),同样可以替换使用。</p>
<p>打开 JetBrains AI Assistant 聊天面板,在菜单中选择「添加自定义智能体」,会自动打开一个名为 <code>acp.json</code> 的配置文件。</p>
<figure data-type="image" tabindex="8"><img src="https://LiarrDev.github.io/post-images/1778428593059.png" alt="添加自定义智能体" loading="lazy"></figure>
<p>在 <code>acp.json</code> 中添加配置项:</p>
<pre><code class="language-json">{
...
"agent_servers": {
"Claude Code": {
"command": "/Users/Liarr/.nvm/versions/node/v24.14.0/bin/claude-agent-acp"
}
}
}
</code></pre>
<p>将路径替换为你所安装的路径即可。如果不确定安装位置,可以用下面的命令查看:</p>
<pre><code class="language-bash">➜ npm ls -g
</code></pre>
<p>保存文件后,回到 JetBrains AI Assistant 的聊天面板,在 Agent 列表中就能看到 Claude Code 了。选中它,就能够在熟悉的对话面板中直接使用。</p>
<figure data-type="image" tabindex="9"><img src="https://LiarrDev.github.io/post-images/1778428617538.png" alt="JetBrains AI Assistant Agents" loading="lazy"></figure>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[Flutter Dio 抓包配置]]></title>
<id>https://LiarrDev.github.io/post/Flutter-Dio-Proxy-Config/</id>
<link href="https://LiarrDev.github.io/post/Flutter-Dio-Proxy-Config/">
</link>
<updated>2026-04-29T16:00:00.000Z</updated>
<content type="html"><![CDATA[<p>之前介绍了『<a href="https://liarrdev.github.io/post/Capture-Traffic-on-Android-with-Fiddler-Everywhere/">Fiddler Everywhere</a>』和『<a href="https://liarrdev.github.io/post/Reqable/">Reqable</a>』两款应用在手机上的抓包流程,最近我在使用 Flutter 开发 HarmonyOS 应用,调试的时候发现,即使我配置了系统代理,它始终都无法抓包,而使用原生 ArkTS 开发的应用却可以抓包成功。</p>
<p>经过一番了解之后才知道,原因在于 Dart 语言标准库的网络请求不会走 Wi-Fi 代理,常规通过配置 Wi-Fi 代理来抓包的方式行不通。这给我们日常开发测试造成了很大的阻碍,严重降低工作效率。</p>
<p>虽然说配置方法变了,倒不至于说非常复杂,只需在代码中指定代理地址即可。</p>
<p>以我们项目中使用的 Dio 库为例:</p>
<pre><code class="language-dart">class HttpUtils {
...
final Dio dio = Dio();
void init() {
...
if (proxy) {
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.findProxy = (url) => 'PROXY 192.168.1.142:9000'; // 代理地址
client.badCertificateCallback = ((X509Certificate cert, String host, int port) => true);
return client;
};
}
}
}
</code></pre>
<p>除了指定代理地址,这里还禁用 HTTPS 证书校验:这是一个回调函数,正常情况下用于验证服务器证书是否可信。返回 <code>true</code> 表示无条件信任所有证书,即使证书无效或过期也不拦截。</p>
<p>把代理地址硬编码在项目中显然不利于协作开发,而且还会污染版本管理,因此最好有一种方式来动态配置代理地址,于是我就仿造系统 Wi-Fi 代理配置的方式,顺手写了个 UI,这样每一位开发或者测试人员就可以直接在手机上配置了。</p>
<figure data-type="image" tabindex="1"><img src="https://LiarrDev.github.io/post-images/1777474845955.png" alt="" loading="lazy"></figure>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[本地音乐库构建:音源处理]]></title>
<id>https://LiarrDev.github.io/post/Local-Music-Library-Audio-Processing/</id>
<link href="https://LiarrDev.github.io/post/Local-Music-Library-Audio-Processing/">
</link>
<updated>2026-03-30T16:00:00.000Z</updated>
<content type="html"><![CDATA[<p>之前『<a href="https://liarrdev.github.io/post/Reading-and-Manipulating-MP3-ID3-Tags-with-mp3agic/">使用 mp3agic 读写 MP3 的 ID3 标签</a>』提到了我最近在整理本地音乐库,有些朋友也想自行搭建,但苦于音源难以获取,而我的音乐库过于庞大,不适合分享,所以今天来讲讲我的方法论,以作参考。</p>
<p>希望搭建本地音乐库的朋友相信都饱经各音乐软件的版权折磨,更倾向于白嫖入库,但作为互联网上的老手艺人,我在经历了多番尝试之后,最终选择的方案仍然是:给音乐软件充会员。</p>
<p>这听起来很离谱,既然充了会员,那么何苦浪费时间搭建本地音乐库?</p>
<p>网络上的音乐破解工具数不胜数,但经过我的测试,大多数工具都存在音源更新慢、破解成功率低等问题,这就会导致需要浪费大量的时间在寻找音源上。</p>
<p>而充值音乐软件的会员,则是通过较低的金钱成本来降低时间成本。</p>
<p>以『<a href="https://y.qq.com">QQ 音乐</a>』为例,按照自动续费的价格,目前每月 15 元即可开通绿钻豪华版,享受 300 首歌的下载额度,并且可以随时取消,还是比较划算的。</p>
<figure data-type="image" tabindex="1"><img src="https://LiarrDev.github.io/post-images/1773587121075.png" alt="QQ 音乐价格" loading="lazy"></figure>
<p>而且这些音乐软件本身都做了较好的搜索、分类、批量下载等功能,能够大大节省我们的处理时间。</p>
<p>当音乐库搭建得差不多之后,我们就可以随时暂停续费,攒够一波需要下载的音乐时再重新开通即可。</p>
<p>经过多年的攻防,目前几乎所有的音乐软件都对音频文件做了加密,在版权上来说,这是合理的。但这又会产生另外一个问题,在这个音乐软件上下载的音频,就只能使用该音乐软件来播放,更何况在现在短视频发达的年代,我希望给自己的视频配个 BGM 时却发现下载的音频无法使用,那么我下载似乎就没有意义了。</p>
<p>既然攻防是一直迭代的,那么我们就可以寻找一些古早版本的音乐软件来破解。</p>
<p>同样还是以『QQ 音乐』为例,我目前测试 V7.9.1.0 在 macOS 下仍可正常运行。</p>
<figure data-type="image" tabindex="2"><img src="https://LiarrDev.github.io/post-images/1773587199160.png" alt="QQ 音乐版本" loading="lazy"></figure>
<p>这个版本虽然已经对音频文件进行了一定程度的加密,但网上也已经流传了一些破解方案,比较流行的仓库 <a href="https://github.com/unlock-music/unlock-music">@unlock-music/unlock-music</a> 已经被 <a href="https://docs.github.com/zh/site-policy/content-removal-policies/dmca-takedown-policy">DMCA</a>(Digital Millennium Copyright Act,千禧年数字著作权法案)<a href="https://github.com/github/dmca/blob/master/2022/11/2022-11-04-qqmusic.md">下架</a>,不过仍有<a href="https://git.unlock-music.dev/um/web">自托管的仓库</a>,同时 Github 上也有一些预构建版本,可自行搜索。</p>
<p>我目前使用的是由 <a href="https://www.sorkai.com">@Sorkai</a> 维护的<a href="https://music-unlock.lehinet.com">公益镜像</a>,它使用了 PWA 技术,即使没有网络也可以使用,只需将加密的音频文件拖进去,就可以转换为未加密的 OGG 文件。</p>
<figure data-type="image" tabindex="3"><img src="https://LiarrDev.github.io/post-images/1773587234653.png" alt="unlock music" loading="lazy"></figure>
<p>但我的音乐库统一采用 MP3 格式的音频文件,所以还需要做一次转换,这个相对来说比较简单,只需要用到 FFmpeg,FFmpeg 是一个用于处理多媒体内容(如音频、视频、字幕及相关元数据)的开源工具,它的功能非常强大。</p>
<p>安装完成后,只需要执行以下命令即可完成转换:</p>
<pre><code class="language-bash">➜ ffmpeg -i input.ogg output.mp3
</code></pre>
<p>单个文件转换效率太低,所以我就写了一个可以在 macOS 上执行的简易脚本,用于批量转换:</p>
<pre><code class="language-shell">#!/bin/bash
# 设置要转换的音频文件夹路径
input_folder="/Users/Liarr/Desktop/ogg"
output_folder="/Users/Liarr/Desktop/mp3"
# 创建输出文件夹(如果不存在)
mkdir -p "$output_folder"
# 遍历输入文件夹中的所有音频文件
for file in "$input_folder"/*; do
# 获取文件名和扩展名
filename=$(basename -- "$file")
extension="${filename##*.}"
filename_without_ext="${filename%.*}"
# 转换为 MP3 格式
ffmpeg -i "$file" "$output_folder/$filename_without_ext.mp3"
done
echo "转换完成!"
</code></pre>
<p>只需要将需要转换的 OGG 文件放在输入目录,执行脚本之后就会批量生成 MP3 文件到输出目录。</p>
<p>如果命令对于你来说过于复杂,也可以使用一些开发者自行构建的在线 FFmpeg 服务,比如 <a href="https://github.com/xiguaxigua/ffmpeg-online">@xiguaxigua/ffmpeg-online</a>,同样也是将文件拖进去即可,只是效率确实不如本地执行快。</p>
<figure data-type="image" tabindex="4"><img src="https://LiarrDev.github.io/post-images/1773587254173.png" alt="ffmpeg-online" loading="lazy"></figure>
<p>这样就得到了一个 MP3 文件,再使用『<a href="https://picard.musicbrainz.org">MusicBrainz Picard</a>』将标签和封面打进去,一切大功告成。</p>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[使用 mp3agic 读写 MP3 的 ID3 标签]]></title>
<id>https://LiarrDev.github.io/post/Reading-and-Manipulating-MP3-ID3-Tags-with-mp3agic/</id>
<link href="https://LiarrDev.github.io/post/Reading-and-Manipulating-MP3-ID3-Tags-with-mp3agic/">
</link>
<updated>2026-02-26T16:00:00.000Z</updated>
<content type="html"><![CDATA[<h1 id="背景">背景</h1>
<p>数年前为了应对各音乐平台的版权割据,我开始建立自己的离线音乐库。只是当年的我没有意识到,它会庞大到将近 6 千首歌——这还是在我近几年都没有维护的情况下。</p>
<figure data-type="image" tabindex="1"><img src="https://LiarrDev.github.io/post-images/1772293914084.png" alt="Music Lib" loading="lazy"></figure>
<p>为了清晰管理,库建立之初我就采用了统一的文件命名格式:<code>歌手 - 歌曲.mp3</code>。</p>
<figure data-type="image" tabindex="2"><img src="https://LiarrDev.github.io/post-images/1772293964378.png" alt="命名规范" loading="lazy"></figure>
<p>终于有一天我面对着这个 20 几 GB 的文件夹手足无措时,我意识到,它还需要进一步整理。</p>
<p>我的想法是这样的,为了方便索引,我应该为歌手建立一个目录,然后歌手下再建立专辑子目录,最后把每首歌曲归类到对应的专辑中去。</p>
<pre><code>Music
├── Singer 1
│ ├── Album 1
│ │ ├── Song 1
│ │ ├── Song 2
│ │ └── ···
│ ├── Album 2
│ │ ├── Song 1
│ │ ├── Song 2
│ │ └── ···
│ └── ···
├── Singer 2
│ ├── Album 1
│ │ ├── Song 1
│ │ ├── Song 2
│ │ └── ···
│ ├── Album 2
│ │ ├── Song 1
│ │ ├── Song 2
│ │ └── ···
│ └── ···
└── ···
</code></pre>
<p>歌手名字和歌曲名字都好说,得益于我规范的命名,直接解析文件名就可以拿到,那么专辑名应该如何获取呢?</p>
<p>点开 MP3 文件的属性,可以看到它内部就存储了标题、歌手、专辑等信息,这恰好能够满足我的需要。</p>
<figure data-type="image" tabindex="3"><img src="https://LiarrDev.github.io/post-images/1772294056349.png" alt="MP3 Metadata" loading="lazy"></figure>
<p>首先需要了解这些元数据的存储方式。MP3 文件的元数据通常遵循 ID3 标准,主要分为 ID3v1 和 ID3v2 两种。</p>
<p>ID3v1:位于文件尾部,长度为 128 字节,如果 MP3 文件中有 APE 格式信息,则在 APE 信息之后。不支持封面和一些特殊字符,兼容性较好,但信息容量有限。</p>
<p>ID3v2:位于文件头部,可以包含封面和特殊字符等,长度任意,是目前最常用的标准。它由一个标签头和多个帧(Frame)组成。</p>
<p>ID3v2 当前最新版本是 ID3v2.4,但一些老设备或软件可能只支持 ID3v2.3。</p>
<p>ID3v2.4 对 ID3v2.3 做了一些改进:</p>
<ul>
<li>容量和限制:ID3v2.4 相比 ID3v2.3 有更好的扩展性,可以支持更大容量的文件。</li>
<li>标签大小:ID3v2.4 新增了 16 位和 32 位的大小字段,可以表示更大的标签尺寸。</li>
<li>文本编码:ID3v2.4 强制使用 UTF-16 编码,而 ID3v2.3 则默认使用 ISO-8859-1 编码。</li>
<li>帧类型:ID3v2.4 废弃了一些过时的帧类型,并引入了新的帧类型,如:用户自定义文本信息等。</li>
</ul>
<p>既然知道了元数据存储的位置,如何读取呢?直接解析文件的 <code>ByteArray</code> 是一种方案,但是这种方法过于硬核,使用封装过的三方库显然更加简单易用。</p>
<h1 id="使用-mp3agic">使用 mp3agic</h1>
<p>我找到了一个名为 mp3agic 的库,尽管该仓库上一次提交已经是 2022 年,并且在 2024 年已标注为不再积极维护,但我实际测试下来,仍能够满足我的需求。</p>
<p>先来看看使用方法。</p>
<p>引入依赖:</p>
<pre><code class="language-kotlin">dependencies {
implementation("com.mpatric:mp3agic:0.9.1")
}
</code></pre>
<p>读取 MP3 文件:</p>
<pre><code class="language-kotlin">fun open(file: File) {
val mp3File = Mp3File(it)
val length = mp3File.lengthInSeconds
val bitrate = mp3File.bitrate
val sampleRate = mp3File.sampleRate
// ...
}
</code></pre>
<p>支持直接使用路径或者 <code>File</code> 实例构建 <code>Mp3File</code>。</p>
<p>移除 ID3 或者自定义 Tag:</p>
<pre><code class="language-kotlin">fun deleteTag(mp3File: Mp3File, savePath: String) {
if (mp3File.hasId3v1Tag()) {
mp3File.removeId3v1Tag()
}
if (mp3File.hasId3v2Tag()) {
mp3File.removeId3v2Tag()
}
if (mp3File.hasCustomTag()) {
mp3File.removeCustomTag()
}
mp3File.save(savePath)
}
</code></pre>
<p>需要留意的是修改后调用 <code>save()</code> 保存时,不能直接覆盖原文件,需要提供一个新的保存路径,否则会抛出异常。</p>
<p>获取 ID3v1 的值:</p>
<pre><code class="language-kotlin">fun getId3v1(mp3File: Mp3File) {
if (mp3File.hasId3v1Tag()) {
val v1Tag = mp3File.id3v1Tag
val track = v1Tag.track
val artist = v1Tag.artist
val album = v1Tag.album
val title = v1Tag.title
val year = v1Tag.year
val genre = v1Tag.genre
val genreDesc = v1Tag.genreDescription
val comment = v1Tag.comment
val version = v1Tag.version
// ...
}
}
</code></pre>
<p>设置 ID3v1 的值:</p>
<pre><code class="language-kotlin">fun setId3v1(mp3File: Mp3File, savePath: String) {
val v1Tag = if (mp3File.hasId3v1Tag()) {
mp3File.id3v1Tag
} else {
ID3v1Tag().also { mp3File.id3v1Tag = it }
}
v1Tag.track = "5"
v1Tag.artist = "An Artist"
v1Tag.title = "The Title"
v1Tag.album = "The Album"
v1Tag.year = "2001"
v1Tag.genre = 12
v1Tag.comment = "Some comment"
// ...
mp3File.save(savePath)
}
</code></pre>
<p>其实就是简单的调用 Getter 和 Setter。</p>
<p>获取 ID3v2 的值:</p>
<pre><code class="language-kotlin">fun getId3v2(mp3File: Mp3File) {
if (mp3File.hasId3v2Tag()) {
val v2Tag = mp3File.id3v2Tag
val track = v2Tag.track
val artist = v2Tag.artist
val album = v2Tag.album
val title = v2Tag.title
val year = v2Tag.year
val genre = v2Tag.genre
val genreDesc = v2Tag.genreDescription
val comment = v2Tag.comment
val version = v2Tag.version
val lyrics = v2Tag.lyrics
val composer = v2Tag.composer
val publisher = v2Tag.publisher
val origArtist = v2Tag.originalArtist
val albumArtist = v2Tag.albumArtist
val copyright = v2Tag.copyright
val url = v2Tag.url
val encoder = v2Tag.encoder
val albumImage = v2Tag.albumImage
if (albumImage != null) {
val mime = v2Tag.albumImageMimeType
// Write image to file - can determine appropriate file extension from the mime type
val file = RandomAccessFile("album-artwork", "rw")
file.write(albumImage);
file.close();
}
// ...
}
}
</code></pre>
<p>因为 ID3v2 支持设置专辑封面,所以也可以获取到封面图片。</p>
<p>设置 ID3v2 的值:</p>
<pre><code class="language-kotlin">fun setId3v2(mp3File: Mp3File, savePath: String) {
val v2Tag = if (mp3File.hasId3v2Tag()) {
mp3File.id3v2Tag
} else {
ID3v24Tag().also { mp3File.id3v2Tag = it }
}
v2Tag.track = "5"
v2Tag.artist = "An Artist"
v2Tag.title = "The Title"
v2Tag.album = "The Album"
v2Tag.year = "2001"
v2Tag.genre = 12
v2Tag.comment = "Some comment"
v2Tag.lyrics = "Some lyrics"
v2Tag.composer = "The Composer"
v2Tag.publisher = "A Publisher"
v2Tag.originalArtist = "Another Artist"
v2Tag.albumArtist = "An Artist"
v2Tag.copyright = "Copyright"
v2Tag.url = "http://foobar"
v2Tag.encoder = "The Encoder"
v2Tag.setAlbumImage(byteArray, mime)
// ...
mp3File.save("MyMp3File.mp3")
}
</code></pre>
<p><code>Mp3File</code> 支持创建 ID3v2.2、ID3v2.3、ID3v2.4,可以按需创建。</p>
<h1 id="编写整理脚本">编写整理脚本</h1>
<p>有了上面的工具,下面就可以写一个符合我预期的脚本了:</p>
<pre><code class="language-kotlin">object MusicScript {
private const val SOURCE_DIR = "/Users/Liarr/mp3-source"
private const val RESULT_DIR = "/Users/Liarr/mp3-result"
private const val FIXED_DIR = "/Users/Liarr/mp3-fixed"
fun group() {
val dir = File(SOURCE_DIR)
dir.listFiles()?.forEach {
if (it.name.endsWith(".mp3")) { // 避免读到本地 .DS_Store 等非 MP3 文件
val regex = """(.*) - (.*)\.mp3""".toRegex()
val matchResult = regex.find(it.name)
val fileArtist = matchResult?.groupValues?.get(1)
val fileTitle = matchResult?.groupValues?.get(2)
if (fileArtist == null || fileTitle == null) {
return@forEach
}
val singerDir = File(RESULT_DIR, fileArtist)
if (singerDir.exists().not()) {
singerDir.mkdirs() // 歌手文件夹不存在,先创建
}
val mp3File = Mp3File(it)
if (mp3File.hasId3v2Tag()) {
val v2Tag = mp3File.id3v2Tag
// ID3v2 Tag 里面的数据和文件名解析出来的不一致,大概率是乱码了,尝试修正
if (fileArtist != v2Tag.artist || fileTitle != v2Tag.title) {
val tagArtist = v2Tag.artist.correctCharset()
val tagTitle = v2Tag.title.correctCharset()
val tagAlbum = v2Tag.album.correctCharset()
if (tagArtist == fileArtist && tagTitle == fileTitle) { // 转码正确,重设 ID3v2
v2Tag.artist = tagArtist
v2Tag.title = tagTitle
v2Tag.album = tagAlbum
// save 方法不允许覆盖原文件,先存到单独文件夹,后面再处理
mp3File.save(FIXED_DIR + File.separator + it.name)
return@forEach
} else { // 转码后也不匹配,尝试读取 ID3v1
if (mp3File.hasId3v1Tag().not()) {
return@forEach
}
val v1Tag = mp3File.id3v1Tag
if (fileArtist != v1Tag.artist || fileTitle != v1Tag.title) {
return@forEach
}
// ID3v1 里面的数据正确,重设 ID3v2
v2Tag.artist = v1Tag.artist
v2Tag.title = v1Tag.title
v2Tag.album = v1Tag.album
// save 方法不允许覆盖原文件,先存到单独文件夹,后面再处理
mp3File.save(FIXED_DIR + File.separator + it.name)
return@forEach
}
}
if (v2Tag.album.isBlank().not()) {
val albumDir = File(singerDir, v2Tag.album)
if (albumDir.exists().not()) {
albumDir.mkdirs()
}
val f = File(albumDir, it.name)
it.renameTo(f)
}
}
}
}
}
private fun String.correctCharset() = String(this.toByteArray(Charsets.ISO_8859_1), Charset.forName("gbk"))
}
</code></pre>
<p>总结一下我的实现思路:</p>
<ol>
<li>定义三个目录:源文件夹、输出文件夹、修复文件夹。</li>
<li>遍历源文件夹下所有 MP3 文件(防止读取到比如 <code>.DS_Store</code> 等非 MP3 文件,当然你也可以<a href="https://liarrdev.github.io/post/Verify-File-Types-with-Media-Type-in-Java/">使用 Media Type 校验文件类型</a>)。</li>
<li>解析文件名得到歌手和歌曲名,在输出文件夹中创建歌手文件夹。</li>
<li>读取 MP3 的 ID3v2 标签,检查标签中的歌手和歌曲是否与文件名一致。</li>
<li>如果标签中的信息与文件名一致,且专辑名不为空,则将该文件移动到输出文件夹下对应的 <code>歌手/专辑</code> 子目录中。</li>
<li>如果标签中的信息与文件名不一致,尝试将其转换为 GBK 编码,若转换后匹配,则修正标签并保存到修复文件夹,仍不匹配则尝试读取 ID3v1 标签,如果 ID3v1 的信息与文件名一致,则将其写入到 ID3v2,并保存到修复文件夹中。</li>
</ol>
<p>为什么要做文件名和 ID3v2 的匹配判断呢,因为跑脚本的过程中我发现,一些 MP3 文件解析出来的结果是乱码,系统播放器也不能正常识别:</p>
<figure data-type="image" tabindex="4"><img src="https://LiarrDev.github.io/post-images/1772294509391.png" alt="歌曲信息乱码" loading="lazy"></figure>
<p>这种情况下大概率就是上文中提到的 ID3v2.3 默认使用 ISO-8859-1 编码导致的,所以会出现跟文件名跟 ID3v2 不一致的问题,尝试将其转换为 GBK 编码。</p>
<p>执行脚本后,输出文件夹中就归类了本来就符合要求的 MP3 文件,修复文件夹中则得到了经过处理的 MP3 文件,此时可以手动检查修复文件夹中的文件,确认无误后替换源文件,再次运行脚本即可。这样既保证了自动化效率,又留有人工复核的空间。</p>
<p>二次执行脚本之后剩下的就是无法根据我们定义的规则处理的文件,那就需要手动处理了。</p>
<h1 id="遇到的小插曲">遇到的小插曲</h1>
<p>第一次运行脚本时,发现许多文件无法移动,调试到 <code>renameTo()</code> 方法返回了 <code>false</code>,排查发现是权限问题,这些无法被移动的文件都被设置了 <code>uchg</code> 标志,估计是多年前为了防止意外修改而设置的只读属性。</p>
<p>执行命令:</p>
<pre><code class="language-bash">➜ ls -lO "/Users/Liarr/mp3-source"
# -rwxr-xr-x 1 hugecore staff uchg 4199728 Jan 24 2017 陈奕迅 - 天下无双.mp3
# ...
</code></pre>
<p>可以看到该目录下输出的文件信息中都包含 <code>uchg</code> 标志(即 user immutable),有这个标志的文件无法通过脚本进行修改、删除、重命名、移动等操作,所以需要先执行命令解除 <code>uchg</code> 标志:</p>
<pre><code class="language-bash">➜ sudo chflags -R nouchg "/Users/Liarr/mp3-source"
</code></pre>
<p>输入密码后即可解除,后面跑脚本就没有问题了。</p>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[ Jetpack Glance 助力 Compose AppWidget 开发]]></title>
<id>https://LiarrDev.github.io/post/Empowers-Compose-AppWidget-with-Jetpack-Glance /</id>
<link href="https://LiarrDev.github.io/post/Empowers-Compose-AppWidget-with-Jetpack-Glance /">
</link>
<updated>2026-02-01T16:00:00.000Z</updated>
<content type="html"><![CDATA[<h1 id="背景">背景</h1>
<p>之前『<a href="https://liarrdev.github.io/post/Building-Widgets-for-Android-Apps/">为 Android 应用构建 Widget</a>』一文介绍过在 Android 中 Widget 的开发流程,文中的示例是基于『Android Studio』提供的默认模版,也就是 <code>RemoteViews</code> 来构建的,当时的文章反响不错,也引发了不少读者的讨论,其中有不少读者期待在 Jetpack Compose 中使用更加优雅的解决方案,今天它来了。</p>
<p>Google 开发了一个构建在 Jetpack Compose Runtime 之上的框架,名为 Glance,帮助你用更少的代码快速构建响应式 Widget。</p>
<p>事先声明,得益于 Android 良好的兼容方案,之前的 <code>RemoteViews</code> 依然可用,不必为其是否需要迁移感到焦虑。</p>
<h1 id="实践">实践</h1>
<h2 id="基本配置">基本配置</h2>
<p>『Android Studio』的默认创建模版<a href="https://liarrdev.github.io/post/Building-Widgets-for-Android-Apps/">之前的文章</a>中已经介绍过了,它与 Glance 的配置大相径庭,所以本次我们将手动完成配置。</p>
<p>首先确保当前项目的 Compose 环境已经配置完成,Compose 的配置流程本文不再赘述,读者可以自行搜索。</p>
<p>接下来添加依赖:</p>
<pre><code class="language-groovy">dependencies {
// For AppWidgets support
implementation "androidx.glance:glance-appwidget:1.1.1"
// For interop APIs with Material 3
implementation "androidx.glance:glance-material3:1.1.1"
// For interop APIs with Material 2
implementation "androidx.glance:glance-material:1.1.1"
}
</code></pre>
<p>按需添加即可,需要注意的是,Glance 的版本和当前 Compose 的版本也是有关联的,如果不兼容可能会编译出错,修改版本号为对应的版本即可。</p>
<p>然后编写一个继承自 <code>GlanceAppWidget</code> 的类,我们的 Widget 样式将在这里渲染:</p>
<pre><code class="language-Kotlin">class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceTheme {
MyContent()
}
}
}
@Composable
private fun MyContent() {
...
}
}
</code></pre>
<p>实现 <code>provideGlance()</code> 方法,并在 <code>provideContent()</code> 内创建你的 Compose 视图。</p>
<p>有几点需要注意:</p>
<ul>
<li><code>provideGlance()</code> 方法运行在主线程,所以耗时操作建议使用协程处理。</li>
<li>虽然表面上看它是使用 Compose 编写 UI,但实际上 Glance 内部重新构建了一套与 Jetpack Compose 类似的组件库,注意不要导错包。</li>
</ul>
<p>Glance 只支持 <a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/layout/package-summary#box"><code>Box</code></a>、<a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/layout/package-summary#column"><code>Column</code></a>、<a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/layout/package-summary#row"><code>Row</code></a>、<a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/layout/package-summary#spacer"><code>Spacer</code></a>、<a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/text/package-summary#text"><code>Text</code></a>、<a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/package-summary#button"><code>Button</code></a>、<a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/package-summary#image"><code>Image</code></a>、<a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/appwidget/lazy/package-summary#lazycolumn"><code>LazyColumn</code></a>、<a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/appwidget/components/package-summary#scaffold"><code>Scaffold</code></a> 等,尽管随着版本更新还拓展了一些其他的组件,但仍与 <code>RemoteViews</code> 的限制大同小异,事实上,Glance 只是提供了 Compose-Style 的写法,它依然会翻译成 <code>RemoteViews</code> 可用的方案。</p>
<p>如果期望 Glance 能够突破组件限制的可以劝退了😂。</p>
<p>继续创建一个 <code>GlanceAppWidgetReceiver</code> 用于获取刚刚的 <code>GlanceAppWidget</code>:</p>
<pre><code class="language-kotlin">class MyAppWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = MyAppWidget()
}
</code></pre>
<p>看到这个名字相信你已经察觉到了,我们之前传统的构建方式也需要注册一个广播,扒开它的神秘面纱瞅一眼:</p>
<pre><code class="language-kotlin">abstract class GlanceAppWidgetReceiver : AppWidgetProvider() {
...
abstract val glanceAppWidget: GlanceAppWidget
}
</code></pre>
<p>好家伙,还是基于原来的 <code>AppWidgetProvider</code> 封装的。</p>
<p>Glance 注册还需要一个 <code>AppWidgetProviderInfo</code> 元数据,这也是老演员了,需要留意的是,Glance 没有默认的初始布局,你可以使用它内部定义的:</p>
<pre><code class="language-XML"><appwidget-provider
...
android:initialLayout="@layout/glance_default_loading_layout" />
</code></pre>
<p>最后就是在 <code>AndroidManifest.xml</code> 中注册了,同之前一样:</p>
<pre><code class="language-XML"><manifest ...>
<application ...>
...
<receiver
android:name=".widget.MyAppWidgetReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/my_app_widget_info" />
</receiver>
</application>
</manifest>
</code></pre>
<h2 id="更新-widget">更新 Widget</h2>
<p>Glance 官方文档中关于更新 Widget 的内容我认为描述得不够详细,它只告诉你调用 <code>updateAll()</code> 方法即可:</p>
<pre><code class="language-kotlin">object MyAppWidgetManager {
fun updateAll(context: Context) {
GlobalScope.launch {
MyAppWidget().updateAll(context)
}
}
}
</code></pre>
<p>又或者调用 <code>update()</code>:</p>
<pre><code class="language-kotlin">object MyAppWidgetManager {
fun updateAll(context: Context) {
GlobalScope.launch {
val manager = GlanceAppWidgetManager(context)
val widget = MyAppWidget()
val glanceIds = manager.getGlanceIds(widget.javaClass)
glanceIds.forEach { glanceId ->
widget.update(context, glanceId)
}
}
}
}
</code></pre>
<p>由于这两个方法都使用 <code>suspend</code> 修饰,所以建议在非主线程中调用。</p>
<p>如果使用传统开发 Widget 的思维,调用这个方法 Widget 便会执行刷新,但是实际效果却并不能正确执行,请不要认为在任何时候调用这个方法会触发 <code>provideGlance()</code>。</p>
<p>因为 <code>GlanceAppWidget</code> 的更新是通过状态管理的,比如官方的例子:</p>
<pre><code class="language-kotlin">class DestinationAppWidget : GlanceAppWidget() {
...
@Composable
fun MyContent() {
val repository = remember { DestinationsRepository.getInstance() }
val destinations by repository.destinations.collectAsState(State.Loading)
when (destinations) {
is State.Loading -> {
// show loading content
}
is State.Error -> {
// show widget error content
}
is State.Completed -> {
// show the list of destinations
}
}
}
}
</code></pre>
<p>但我们野生开发却很少使用这种状态,比如直接从 <code>SharedPreferences</code> 里面读取值来展示:</p>
<pre><code class="language-kotlin">class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val foo = fetchFromPreferences()
GlanceTheme {
if (foo != null) {
MyContent(foo)
} else {
DefaultContent()
}
}
}
}
@Composable
private fun MyContent(foo: Foo) {
...
}
@Composable
private fun DefaultContent() {
...
}
}
</code></pre>
<p>这种写法会导致调用 <code>update()</code> 判断不到状态的变更,导致不会刷新,当然野生写法也可以手动给它搞一个状态:</p>
<pre><code class="language-kotlin">object MyAppWidgetManager {
const val KEY_NOW = "now"
fun updateAll(context: Context) {
GlobalScope.launch {
val glanceIds = GlanceAppWidgetManager(context).getGlanceIds(MyAppWidget::class.java)
glanceIds.forEach { id ->
updateAppWidgetState(context, id) {
it[longPreferencesKey(KEY_NOW)] = System.currentTimeMillis() // 设置状态
}
MyAppWidget().update(context, id)
}
}
}
}
</code></pre>
<p>每次调用 <code>update()</code> 方法前,都更新一次 Widget 的状态,即可刷新:</p>
<pre><code class="language-kotlin">class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val now = currentState<Preferences>()[longPreferencesKey(MyAppWidgetManager.KEY_NOW)] // 读取状态
val foo = fetchFromPreferences()
GlanceTheme {
if (foo != null) {
MyContent(foo)
} else {
DefaultContent()
}
}
}
}
...
}
</code></pre>
<h2 id="从-widget-中启动应用">从 Widget 中启动应用</h2>
<p>我们知道,Widget 运行在远程进程中,在之前的 <code>RemoteViews</code> 方案,我们通过 <code>PendingIntents</code> 来启动应用,而 Glance 提供了更优雅的实现方案。</p>
<pre><code class="language-Kotlin">class MyAppWidget : GlanceAppWidget() {
...
@Composable
private fun MyContent() {
Column {
Button(text = "Home", onClick = actionStartActivity<MainActivity>())
Text(text = "Settings", modifier = GlanceModifier.clickable(actionStartActivity<SettingsActivity>()))
}
}
}
</code></pre>
<p>Glance 通过 <code>Action</code> 来简化处理用户互动的流程,<code>Action</code> 是一个接口,在不同的启动场景中有不同的实现。</p>
<p>在需要启动 <code>Activity</code> 时,我们只需要调用 <code>actionStartActivity()</code> 方法指定目标 <code>Activity</code> 即可,该方法会返回一个 <code>StartActivityAction</code>,<code>StartActivityAction</code> 也是一个接口,传递不同的参数由 <code>StartActivityClassAction</code>、<code>StartActivityComponentAction</code>、<code>StartActivityIntentAction</code> 等具体实现。</p>
<p><code>Button</code> 可以通过 <code>onClick</code> 接收 <code>Action</code>,其他 <code>@Composable</code> 也可以通过 <code>GlanceModifier.clickable()</code> 接收 <code>Action</code>。</p>
<p>启动的时候需要传递参数怎么办,我们看到 <code>actionStartActivity()</code> 方法有个 <code>Bundle</code> 参数,千万不要被迷惑了!</p>
<pre><code class="language-kotlin">@ExperimentalGlanceApi
inline fun <reified T : Activity> actionStartActivity(
parameters: ActionParameters = actionParametersOf(),
activityOptions: Bundle? = null,
): Action = actionStartActivity(T::class.java, parameters, activityOptions)
@ExperimentalGlanceApi
fun <T : Activity> actionStartActivity(
activity: Class<T>,
parameters: ActionParameters = actionParametersOf(),
activityOptions: Bundle? = null,
): Action = StartActivityClassAction(activity, parameters, activityOptions)
</code></pre>
<p>启动 <code>Activity</code> 所需的参数并不是在这个 <code>Bundle</code> 中传递的,而是通过 <code>ActionParameters</code> 传递的。尽管 <code>ActionParameters</code> 和 <code>Bundle</code> 一样底层实现都是 <code>Map</code>,但不同于 <code>Bundle</code> 的键是 <code>String</code> 类型,<code>ActionParameters</code> 的键是再做了一层封装的 <code>ActionParameters.Key</code>,这意味着我们需要再做一层转换,好处是不怕传递到错误的类型。</p>
<pre><code class="language-kotlin">private val destinationKey = ActionParameters.Key<String>(NavigationActivity.KEY_DESTINATION)
class MyAppWidget : GlanceAppWidget() {
...
@Composable
private fun MyContent() {
Button(
text = "Home",
onClick = actionStartActivity<NavigationActivity>(actionParametersOf(destinationKey to "home"))
)
}
}
</code></pre>
<p><code>ActionParameters.Key</code> 中的泛型指定为要传递的值的类型,然后传入我们 <code>Activity</code> 中定义的键名构造实例。通过 <code>actionParametersOf()</code> 构造 <code>ActionParameters</code> 对象,它内部可以填充多组 <code>Pair</code> 对应我们要传递的参数。将构建好的 <code>ActionParameters</code> 对象传递给 <code>actionStartActivity()</code> 方法即可。</p>
<p>目标 <code>Activity</code> 无需做多余的修改,还是使用 <code>getIntent()</code> 读取:</p>
<pre><code class="language-kotlin">class NavigationActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val destination = intent.getStringExtra(KEY_DESTINATION)
...
}
companion object {
const val KEY_DESTINATION = "destination"
}
}
</code></pre>
<p>其他的操作比如启动 <code>Service</code> 或者发送广播同样是通过 <code>Action</code> 来实现,自定义操作也是如此,大同小异,参照文档即可,这里不再赘述。</p>
<h2 id="remoteviews-兜底"><code>RemoteViews</code> 兜底</h2>
<p>在某些情况下,可能需要使用 XML 和 <code>RemoteViews</code> 来提供视图。比如已经在不使用 Glance 的情况下实现了某项功能,或者该功能在当前 Glance API 尚未提供或无法使用。对于这种情况 Glance 提供了 <code>AndroidRemoteViews</code>,这是一个互操作性 API。不难看出,它提供了与 Compose 中 <code>AndroidView</code> 类似的功能。</p>
<p><code>AndroidRemoteViews</code> 允许将 <code>RemoteViews</code> 与其他 <code>@Composable</code> 搭配使用:</p>
<pre><code class="language-kotlin">class MyAppWidget : GlanceAppWidget() {
...
@Composable
private fun MyContent() {
val packageName = LocalContext.current.packageName
Column(modifier = GlanceModifier.fillMaxSize()) {
Text("Isn't that cool?")
AndroidRemoteViews(RemoteViews(packageName, R.layout.example_layout))
}
}
}
</code></pre>
<p>将 <code>@Composable</code> 放在 <code>AndroidRemoteViews</code> 同样可行:</p>
<pre><code class="language-kotlin">class MyAppWidget : GlanceAppWidget() {
...
@Composable
private fun MyContent() {
val packageName = LocalContext.current.packageName
AndroidRemoteViews(
remoteViews = RemoteViews(packageName, R.layout.my_container_view),
containerViewId = R.id.example_view
) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Text("My title")
Text("Maybe a long content...")
}
}
}
}
</code></pre>
<p>要注意的是这里需要传递布局和容器 ID,意味着这个容器必须是 <code>ViewGroup</code>,同时该 <code>ViewGroup</code> 内的所有子 <code>View</code> 都将被移除,取而代之的是在此 <code>AndroidRemoteViews</code> 中填充的内容。不要忘记,提供的 <code>ViewGroup</code> 必须受 <code>RemoteViews</code> 支持。</p>
<h1 id="写在最后">写在最后</h1>
<p>这篇文章虽然主要是介绍 Glance,但实际上是对『<a href="https://liarrdev.github.io/post/Building-Widgets-for-Android-Apps/">为 Android 应用构建 Widget</a>』一文的补充,因为 Glance 仍是对传统实现 Widget 的封装,提供了 Compose-Style 的写法,并不会超脱原有 Widget 的限制。</p>
<p>Glance 是一个很新的库,目前还在实验阶段,但已基本可用,而且它和 Compose 的结合非常紧密,使用起来非常方便。如果你正在使用 Compose,那么不妨尝试一下 Glance,相信你会爱上它的。</p>
<p>不过我需要在这里打个预防针,经过我们实际在企业项目的验证,目前 Glance 在 vivo 设备上会出现一个 <code>IllegalStateException</code>。</p>
<figure data-type="image" tabindex="1"><img src="https://LiarrDev.github.io/post-images/1768730036222.png" alt="Firebase 崩溃记录" loading="lazy"></figure>
<p>堆栈信息如下:</p>
<pre><code>Fatal Exception: java.lang.IllegalStateException: Broadcast already finished
at android.content.BroadcastReceiver$PendingResult.sendFinished(BroadcastReceiver.java:283)
at android.content.BroadcastReceiver$PendingResult$1.run(BroadcastReceiver.java:256)
at android.app.QueuedWork.processPendingWork(QueuedWork.java:265)
at android.app.QueuedWork.-$$Nest$smprocessPendingWork()
at android.app.QueuedWork$QueuedWorkHandler.handleMessage(QueuedWork.java:285)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loopOnce(Looper.java:223)
at android.os.Looper.loop(Looper.java:324)
at android.os.HandlerThread.run(HandlerThread.java:67)
</code></pre>
<p>我追踪到<a href="https://issuetracker.google.com/issues/257513022">在 Google IssueTracker 上从 2022 年开始就有这个问题</a>,并且至今仍未被修复,尚不明确到底是 Glance 的 Bug 还是 vivo OriginOS 的锅。</p>
<figure data-type="image" tabindex="2"><img src="https://LiarrDev.github.io/post-images/1768730081261.png" alt="Crash with Glance" loading="lazy"></figure>
<h1 id="参考内容">参考内容</h1>
<ul>
<li><a href="https://developer.android.google.cn/develop/ui/compose/glance">Jetpack Glance | Jetpack Compose | Android Developers</a></li>
<li><a href="https://developer.android.google.cn/reference/kotlin/androidx/glance/layout/package-summary">androidx.glance.layout | Android Developers</a></li>
<li><a href="https://stackoverflow.com/questions/77088363/android-jetpack-glance-1-0-0-problems-updating-widget">Android Jetpack Glance 1.0.0 : problems updating widget - Stack Overflow</a></li>
<li><a href="https://medium.com/@Xiryl/unlock-the-secret-real-time-updates-for-glance-widgets-on-android-6907d0e06fa9">Unlock the Secret: Real-Time Updates for Glance Widgets on Android! | by Fabio Chiarani | Medium</a></li>
</ul>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[HarmonyOS ArkWeb 有线调试]]></title>
<id>https://LiarrDev.github.io/post/Debug-HarmonyOS-ArkWeb-via-HDC/</id>
<link href="https://LiarrDev.github.io/post/Debug-HarmonyOS-ArkWeb-via-HDC/">
</link>
<updated>2025-12-28T16:00:00.000Z</updated>
<content type="html"><![CDATA[<p>我们在进行 Android 开发时,如果要调试 <code>WebView</code>,通常只需要连接 <code>adb</code>,然后就可以打开 Chrome DevTools 进行调试。</p>
<p>而在 HarmonyOS 上,虽然有类似 <code>adb</code> 的工具:<code>hdc</code>,但是调试 <code>ArkWeb</code> 却并不方便。</p>
<p>首先需要在需要打开调试的页面开启允许调试的开关:</p>
<pre><code class="language-ts">@Entry(storage)
@Component
struct Index {
...
aboutToAppear(): void {
webview.WebviewController.setWebDebuggingAccess(BuildProfile.DEBUG)
}
}
</code></pre>
<p>这一步与 Android 无异,我们可以通过 <code>BuildProfile.DEBUG</code> 很方便地判断当前是否为 DEBUG 包,就不需要在上线时再将其重置为 <code>false</code> 了。</p>
<p>然后通过 <code>hdc</code> 连接设备,并进入 Shell:</p>
<pre><code class="language-bash">➜ hdc shell
</code></pre>
<p>接着查找相关进程:</p>
<pre><code class="language-bash">➜ hdc shell
➜ cat /proc/net/unix | grep devtools
# 0: 00000002 0 10000 1 1 49902021 @webview_devtools_remote_32259
</code></pre>
<p>执行命令后会输出 <code>ArkWeb</code> 所在 Domain Socket 端口。</p>
<p>退出 Shell:</p>
<pre><code class="language-bash">➜ hdc shell
➜ exit
</code></pre>
<p>当然我们也有其他方式能够查询到 Domain Socket,比如通过包名:</p>
<pre><code class="language-bash">➜ hdc shell ps -ef | grep "com.example.app"
# 20020109 32259 652 0 12:48:30 ? 00:09:04 com.example.app
</code></pre>
<p>输出的第二个参数就是查询到的进程 ID。</p>
<p>将查询到的 Domain Socket 转发至电脑的 TCP <code>9222</code> 端口:</p>
<pre><code class="language-bash">➜ hdc fport tcp:9222 localabstract:webview_devtools_remote_32259
# Forwardport result:OK
</code></pre>
<p>一般输出 OK 就代表转发成功了,你也可以用下面的命令检查一下</p>
<pre><code class="language-bash">➜ hdc fport ls
# FMR0223A24001263 tcp:9222 localabstract:webview_devtools_remote_32259 [Forward]
</code></pre>
<p>最后就可以在熟悉的 Chrome DevTools 上进行调试了。</p>
<figure data-type="image" tabindex="1"><img src="https://LiarrDev.github.io/post-images/1766848500828.png" alt="DevTools" loading="lazy"></figure>
<p>由于步骤比较繁琐,官方也提供了便捷脚本支持一键运行,有需要的朋友可以前往官方文档自行下载。</p>
<p>从 API version 20 开始,官方也支持指定端口号无线调试,简化调试流程。</p>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[Android 基于 WebKit 的 Hybrid 交互方案]]></title>
<id>https://LiarrDev.github.io/post/Android-WebKit-Based-Hybrid-Interaction-Solution/</id>
<link href="https://LiarrDev.github.io/post/Android-WebKit-Based-Hybrid-Interaction-Solution/">
</link>
<updated>2025-11-23T16:00:00.000Z</updated>
<content type="html"><![CDATA[<h1 id="背景">背景</h1>
<p>之前『<a href="https://liarrdev.github.io/post/Interactin-with-JavaScript-in-Android-WebView">Android WebView 和 JavaScript 交互</a>』一文介绍了普遍 Hybrid 开发场景下 Android 和 JavaScript 的通信方式,相信绝大多数项目都是使用这种方式进行交互的,但是这种方案存在一些安全和性能问题。</p>
<p>而 JetPack 给我们带来的 WebKit 库提供了一种新的交互方案。</p>
<h1 id="导入依赖">导入依赖</h1>
<pre><code class="language-kts">dependencies {
implementation("androidx.webkit:webkit:1.14.0")
}
</code></pre>
<h1 id="android-向-javascript-发送消息">Android 向 JavaScript 发送消息</h1>
<p>Android 端代码如下:</p>
<pre><code class="language-Kotlin">class MainActivity : AppCompatActivity() {
...
private fun postMessage(msg: String) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.POST_WEB_MESSAGE)) {
WebViewCompat.postWebMessage(
binding.webview,
WebMessageCompat(msg),
HOST.toUri()
)
}
}
companion object {
const val HOST = "http://192.168.1.108:5500"
}
}
</code></pre>
<p>先判断 <code>WebView</code> 是否支持此方式发送消息,然后调用 <code>WebViewCompat</code> 的 <code>postWebMessage()</code> 发送,该函数接收 3 个参数,第一个是 <code>WebView</code>,不用赘述,第二个是消息的包装类 <code>WebMessageCompat</code>,我们传一个 <code>String</code> 类型的消息即可,第三个参数用于限制哪个站源可以接收我们的消息,一般情况下我们传自有的域名,不限制的话也可以使用通配符 <code>"*"</code> 代替。</p>
<p>在 H5 端使用以下方法监听:</p>
<pre><code class="language-html"><html>
<script type="text/javascript">
...
window.addEventListener("message", function (event) {
console.log("收到消息:", event.data)
}, false)
</script>
...
</html>
</code></pre>
<p>除了简单的 <code>String</code> 类型,它还支持字节流,这在文件传输中十分有用。</p>
<p>修改 Android 端代码如下:</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
...
private fun postArrayBuffer(arrayBuffer: ByteArray) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.POST_WEB_MESSAGE)) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)) {
WebViewCompat.postWebMessage(
binding.webview,
WebMessageCompat(arrayBuffer),
HOST.toUri()
)
}
}
}
companion object {
const val HOST = "http://192.168.1.108:5500"
}
}
</code></pre>
<p>需要注意,发送字节流需要判断是否支持 <code>WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER</code>。</p>
<p>假如传送一个图片,H5 端监听后可以解析:</p>
<pre><code class="language-html"><html>
<script type="text/javascript">
...
window.addEventListener("message", function (event) {
...
if (data instanceof ArrayBuffer) {
const blob = new Blob([data], { type: "image/png" });
const url = URL.createObjectURL(blob);
const img = new Image();
img.src = url;
document.body.appendChild(img);
}
}, false)
</script>
...
</html>
</code></pre>
<h1 id="javascript-向-android-端发送消息">JavaScript 向 Android 端发送消息</h1>
<p>Android 端设置监听:</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
...
private fun addWebMessageListener() {
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(binding.webview, "Android", setOf(HOST)) { view, message, sourceOrigin, isMainFrame, replyProxy ->
Log.e("moji", "Android 端收到消息:${message.data}")
// 收到消息后可以回复
replyProxy.postMessage("got the message @ ${System.currentTimeMillis()}")
}
}
}
companion object {
const val HOST = "http://192.168.1.108:5500"
}
}
</code></pre>
<p>首先同样需要判断是否支持监听,然后调用 <code>WebViewCompat</code> 的 <code>addWebMessageListener()</code> 方法添加监听,该函数接收 4 个参数,第一个是 <code>WebView</code>,不用赘述,第二个是注入 JS 对象的名称,这里命名为 <code>Android</code>,下面会用到,第三个参数用来指定哪些站源会注入这个对象,第四个参数就是回调接口。</p>
<p>收到消息我们可以调用 <code>JavaScriptReplyProxy</code> 对象的 <code>postMessage()</code> 给 H5 端发个回复。</p>
<p>然后 H5 端就可以发送消息了:</p>
<pre><code class="language-html"><html>
<script type="text/javascript">
...
function postMessage(message) {
if (typeof Android !== "undefined") {
Android.postMessage(message);
// 接收来自客户端的回复
Android.onmessage = function (event) {
console.log("Android.onmessage received: ", event);
}
}
}
</script>
...
</html>
</code></pre>
<p>其中 <code>Android</code> 就是上方我们注入对象的名称,调用 <code>postMessage()</code> 发送消息即可,假如 Android 端提供了回复,那么可以用 <code>onmessage</code> 来监听。</p>
<p>同样支持字节流,Android 端修改如下:</p>
<pre><code class="language-kotlin">class MainActivity : AppCompatActivity() {
...
private fun addWebMessageListener() {
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(binding.webview, "Android", setOf(HOST)) { view, message, sourceOrigin, isMainFrame, replyProxy ->
if (message.type == WebMessageCompat.TYPE_ARRAY_BUFFER) {
val imageData = message.arrayBuffer
...
}
}
}
}
companion object {
const val HOST = "http://192.168.1.108:5500"
}
}
</code></pre>
<p>通过 <code>getType()</code> 判断如果是字节流,就直接从 <code>WebMessageCompat</code> 中取出数据即可。</p>
<p>H5 端使用同样的方式发送字节流就可以:</p>
<pre><code class="language-html"><html>
<script type="text/javascript">
...
function postArrayBuffer(buffer) {
if (typeof Android !== "undefined") {
Android.postMessage(buffer);
}
}
</script>
...
</html>
</code></pre>
<h1 id="总结">总结</h1>
<p>这种方式安全,但是需要判断是否支持(遗憾的是国内大多数系统 <code>WebView</code> 升级进度拖沓),一来比较繁琐,二来在不支持的设备上仍要寻求其他解决方案,增加维护成本。</p>
<p>不过目前其他开发平台比如 iOS、HarmonyOS、Flutter 等亦采用类似的方案进行通信,不难看出这类方案将会成为主流。</p>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[如何追踪 Android 依赖来源]]></title>
<id>https://LiarrDev.github.io/post/How-to-Trace-Android-Dependency-Sources/</id>