序-Prologue

耗时近半月之后,弹幕笔记开发完成。她是在我老是记混知识点时,灵感闪过,想着要是能在屏幕上时不时的提醒我一下多是一件美事。

灵感就是这样神奇,若不实现她,彻夜难安。我在寻找这类软件时,也偶遇了【弹幕记忆】APP。她很不错,但是我想要的功能,嗯……要VIP。于是,我决定自己开发。

对于安卓开发,我并不擅长。甚至可以说,我基本不会。我的专业是医学数据,计算机只能说沾边,但完全不深入。对于java这些语言,我更是完全没有系统学习。

但对于我构思的这款软件,她其实并不难。甚至可以是用AutoJs这类脚本就可以很好的展现出来。但脚本着实不好管理,且不优雅。我不喜欢,我还是更喜欢用APP的形式展现她。

我决定使用Lua这个小而轻量的语言完成我的构思。恰好,Lua在手机上就可以很好的完成编程,相当于自带了真机调试环境,省去了很多繁琐的步骤。

Lua这一小而轻量的语言,本身就省去了很多繁琐的东西。恰好我在很早之前也曾接触过她一点点,不用从头学起。

“从闭源到开源是一次伟大的飞跃”,我一直很认可这句话。无论是在我学习的途中,还是遇到问题的过程中。开源都给予了我莫大的帮助。

当我也终于学会了其中的一点点知识,我也毫不犹豫的决定,开源她。


展示,构思与实现

效果

很多时候,我都喜欢先看看最终效果来衡量一篇文章或者项目对我是否有效。所以我也把我的『弹幕笔记』效果摆出来瞅瞅先。

『弹幕笔记』将在任何界面上随机弹幕出你添加的笔记内容,但它不会影响你的操作。

自我感觉UI设计勉勉强强还行,起码不是xp风。

基础构思

其一,我准备实现的目标是添加笔记之后,可以随时随地在手机屏幕上“弹幕”出我的笔记,来加深我的印象。所以其实基础功能就这两个:

  • 数据存取
  • 弹幕内容

但我们不能直接摆个文本文档上去。我们得交互,得优雅。所以,我们起码得完成以下功能:

  • 安卓activity绘制
  • 基础界面交互
  • 应用内数据库操作
  • 全局管理
  • 悬浮窗

开始实现

准备工作

开发环境:Redmi K30 安卓12
开发工具:AndroidLua+
开发语言:lua
相关助手:AluaJ助手

在进一步之前,你应该具备基础的Lua语言知识。

由于开源程序包已放出,所以本篇文档将只会阐述各Lua脚本的作用,以及注意事项!

概览

文件概览

大部分文件在我都已经表明了它的作用


Bullet.db文件详解

安卓本地数据库一般都采用Sqlite数据库,因为它足够轻巧,灵敏。该有的东西它一个不少。

你即使删掉这个文件也不会造成影响。在我开源的文件中,你也不会找到它,因为『弹幕笔记』将自动创建这个数据库用来保存数据。

删掉这个文件即会删掉所有的用户数据。『弹幕笔记』的笔记内容,用户内容以不同的表保存。

图中的Bullet.db-jurnal文件为程序自动生成的临时日志文件。删掉一般无影响(反正还会生成)。

main.lua详解

main是程序启动后首先执行的文件。也就是入口文件,我在里面放了程序启动时引入的大量包,以及程序启动就应该执行的事件

需要注意其中导入”Mlua/fun“和”Mlua/incident“的顺序。这两个文件非常重要!其中fun文件几乎纪录了程序所有已封装成函数的自定义事件。而incident记录几乎所有的事件,即用户执行的反馈内容。

  • 这两个文件会在后面说明,在这里你只需要知道,尽量不要去调整main函数内的内容及顺序关系。

Mlayout文件夹

该文件夹内全部为各布局文件。就像CSS样式表一样,想改UI界面,就在里面自己去找相应的的内容。

  • 并没有太需要注意的事项。

Mlua文件夹

该文件夹就全部是脚本文件了。就是关于逻辑设计,事件处理等,是软件的核心内容。

  • 它们大概的作用如上

fun函数文件

该文件为最重要的函数文件,其中包含了几乎所有的自定义函数和不便于分类简单布局

查看实现悬浮窗代码
  • 该功能实现依赖于很多函数控制,关联太多,在incident文件与fun文件中交叉进行。
  • 需要自己仔细研究
  • 这里只是展现了部分框架
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
--悬浮窗page页面

local function addTab(t)
return {
CardView;
cardBackgroundColor=0x00000000,
elevation="0";
radius="38dp";
id="___",
layout_marginLeft="12dp",
onClick=function(v)
for i=0,v.parent.getChildCount()-1 do
if v.parent.getChildAt(i).id==v.id then
pg.setCurrentItem(i)
return
end
end
end,
{
LinearLayout;
layout_width="-2";
layout_height="-2";
padding="4dp",
paddingLeft="14dp",
paddingRight="14dp",
orientation="vertical";
{
TextView;
textSize="14sp",
textColor=0xFFFFD6E2,
gravity="center";
text=t;
};
};
};
end

local function setWidth(a,b)
local q=a.layoutParams
q.width=b
a.layoutParams=q
end

function dp2px(dpValue)
local scale = activity.getResources().getDisplayMetrics().scaledDensity
return dpValue * scale + 0.5
end

bar.addView(loadlayout(addTab("添加记录"),nil,bar.class))
bar.addView(loadlayout(addTab("翻译系统"),nil,bar.class))

bar.getChildAt(0).getChildAt(0).getChildAt(0).textColor=0xffffffff

choose.addView(loadlayout({
CardView;
cardBackgroundColor=0xFFF2BECA,
elevation="0";
radius="38dp";
layout_height="24dp",
layout_marginLeft="12dp",
},nil,choose.class))

bar.getChildAt(0).post {
run=function()
setWidth(choose.getChildAt(0),bar.getChildAt(0).width)
end
}

--[[for i=1,2 do
pg.adapter.add(loadlayout{
LinearLayout,
layout_width="fill",
layout_height="fill",
})
end]]

local data={
scrollData={},

}

pg.setOnPageChangeListener{
onPageSelected=function(t)

for i=0,bar.getChildCount()-1 do
bar.getChildAt(i).getChildAt(0).getChildAt(0).textColor=0xFFFFD6E2
end
bar.getChildAt(t).getChildAt(0).getChildAt(0).textColor=0xffffffff
setWidth(choose.getChildAt(0),bar.getChildAt(t).width)
choose.getChildAt(0).x=bar.getChildAt(t).x
end,
onPageScrollStateChanged=function(i)
data.scrollData.scroll=i>0
end,
onPageScrolled=function(a,b,c)
local nowView=bar.getChildAt(a)

local nextView=bar.getChildAt(a==bar.getChildCount() and bar.getChildCount() or a+1)

if data.scrollData.scroll and b~=0 then
if data.scrollData.last and data.scrollData.last<b then
if nextView.width<nowView.width then
setWidth(choose.getChildAt(0),nowView.width-((nowView.width-nextView.width)*b))
else
setWidth(choose.getChildAt(0),nowView.width+((nextView.width-nowView.width)*b))
end
choose.getChildAt(0).x=nextView.x-((nextView.x-nowView.x)*(1-b))
else

local lastView=bar.getChildAt(a)
local nowView=bar.getChildAt(a+1)

if lastView.width>nowView.width then
setWidth(choose.getChildAt(0),nowView.width+((lastView.width-nowView.width)*(1-b)))
else
setWidth(choose.getChildAt(0),nowView.width-((nowView.width-lastView.width)*(1-b)))
end

choose.getChildAt(0).x=lastView.x+((nowView.x-lastView.x)*b)
end

end

if b==0 or c==0 then
for i=0,bar.getChildCount()-1 do
bar.getChildAt(i).getChildAt(0).getChildAt(0).textColor=0xFFFFD6E2
end
bar.getChildAt(pg.getCurrentItem()).getChildAt(0).getChildAt(0).textColor=0xffffffff
end

data.scrollData.page=a
data.scrollData.last=b
end,
}
查看数据库操作相关代码
  • 数据库控制大部分函数均在fun文件中,各个功能调用使用
  • 而一些特殊的需求,或者不便于调用的函数,又分布在各文件中。
  • 需要自己研究
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
import "android.database.sqlite.*"

--sqlite数据库操作

db = SQLiteDatabase.openOrCreateDatabase(this.getLuaDir() .. "/bullet.db",MODE_PRIVATE, nil);

--执行方式

--rawQuery()方法用于执行select语句。
function raw(sql,text)
cursor=db.rawQuery(sql,text)
end

--execSQL()方法可以执行insert、delete、update和CREATE TABLE之类有更改行为的SQL语句

function exec(sql)
db.execSQL(sql);
end

--创建表
function create_config()
local sql="create table config(name varchar primary key NOT NULL,value varchar ,bool boolean)"
if pcall(exec,sql) then
else
print("创建配置表失败")
end
end

--示例:执行语句,拿到结果。这个语句用来统计总数。
local sql="select count(*) from sqlite_master where type='table' and name='config'"
if pcall(raw,sql,nil) then
cursor.moveToFirst(); --移动指针
local result = cursor.getLong(0);
if result==0 then
create_config()
end
else
print("数据库查询失败!")
end

--其他lua数据库操作

--创建表
local CreatrTableSql="create table "..tablename.."(id integer primary key,cyan varchar,remark varchar)"
--创建user表,integer类型为自动增长

--删除表记录
local sql="delete from config where name='"..record_name.."'"

--重命名表
local sql="ALTER TABLE "..old_name.." RENAME to "..new_name

--为表插入新纪录
function addrecord(tablename,newrecord,newremark)
local newrecord=newrecord:gsub('&&',"•") --单词分词的替换
local newremark=newremark:gsub('&&',"•")
local sql="insert into "..tablename.."(cyan,remark) values('"..newrecord.."','"..newremark.."')"
--print(sql)
if pcall(exec,sql) then
--MD提示("添加新记录成功",0xFF2196F3,0xFFFFFFFF,4,10)
else
print("添加新纪录失败")
end
end

查看通知栏推送相关代码
  • 在实现屏幕弹幕后,遇到了一个问题。
  • 软件不能长时间驻留后台,即使加锁和关闭省电策略。
  • 我查到应该使用安卓前台service服务来确保应用的后台运行。当时我并未找到相关的案例,即LuaService的使用。
  • 但我尝试在通知栏中长期驻留,以达到类似的效果,但是并没有什么作用。
  • 我只是实现了通知栏推送,但未实现前台service。于是留下了这个坑。
  • 注:该方法仅适用于安卓8以上

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

--通知栏推送
function push_notise(notice_id,title,content,value)
--设置标题
builder.setContentTitle(title);
--设置内容
builder.setContentText(content);
--设置状态栏显示的图标,建议图标颜色透明
builder.setSmallIcon(R.drawable.icon)

builder.setWhen(System.currentTimeMillis())--通知时间
builder.setShowWhen(true)--是否显示通知时间
builder.setLargeIcon(loadbitmap("null"))--右侧图标

--添加完成事件
--intentDone = Intent(mDoneAction)
NotificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE)

--创建 Intent,点击后通过包名跳转应用
NotificationIntent = Intent("android.intent.action.VIEW",Uri.parse(""));
NotificationIntent.setClassName(--[[this.getPackageName()]]"com.nrhs.deskbullet","com.androlua.Main")
--创建 PendingIntent
pendingIntent = PendingIntent.getActivity(activity.getApplicationContext(),0,NotificationIntent,1)

hangIntent = Intent();
hangIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
--hangIntent.setClass(Context,activity.getApplicationContext())
--如果描述的PendingIntent已经存在,则在产生新的Intent之前会先取消掉当前的
hangPendingIntent = PendingIntent.getActivity(activity.getApplicationContext(),0,hangIntent,PendingIntent.FLAG_CANCEL_CURRENT)
builder.setFullScreenIntent(hangPendingIntent,true)
builder.setContentIntent(pendingIntent)--点击后要跳转的intent

--builder.addAction(R.drawable.icon, "弹幕笔记", pendingIntent)--通知上的操作需要Android10
--builder.addAction(R.drawable.icon, "关闭", pendingIntent)--通知上的操作需要Android10
if value=="long" then
builder.setOngoing(true); --常驻通知,开启后用户的清除操作不可清除该通知
builder.setAutoCancel(false); --自动取消
else
builder.setOngoing(false);
builder.setAutoCancel(true); --自动取消
end
--builder.setContentIntent(pendingIntent); --点击事件
builder.setPriority(Notification.PRIORITY_HIGH);--通知等级

--builder.setLargeIcon(BitmapFactory.decodeResource(activity.getResources(), R.drawable.icon));

if Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
channel = NotificationChannel("5996773", "弹幕笔记", NotificationManager.IMPORTANCE_DEFAULT);
channel.enableLights(true);--是否在桌面icon右上角展示小红点
channel.setLightColor(Color.GREEN);--小红点颜色
channel.setShowBadge(false); --是否在久按桌面图标时显示此渠道的通知
mNManager.createNotificationChannel(channel);
end
notification=builder.build();

mNManager.notify(notice_id,notification);
end

function clear_push(notice_id)
mNManager.cancel(notice_id);--消除相应ID的通知
--mNotificationMgr.cancleAll();
end

incident文件

该文件为全局控制文件,几乎包含了所有事件的处理和逻辑

查看弹幕传参代码
  • 最开始使用了属性动画,多个函数间相互传参。
  • 最后在动画监听部分一直有奇奇怪怪的冲突,也没找到问题,于是改成了补间动画。
  • 再干脆把所有事件写一起算了,反正机器读而已。
  • 这个日志可有可无,当初不报错误但内容奇奇怪怪的时候才写的,只是输出了每次循环的信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

--开启弹幕传参
function ballistic() --弹道一事件
home_view()
local random=math.random(1,#home_list_name) --随机数取当前弹幕序列中的卡片
bullet_text.setText(random_sqlite(home_list_name[random])) --设置随机记录到Text上去
bullet_cars.measure(View.MeasureSpec.makeMeasureSpec(0,View.MeasureSpec.UNSPECIFIED),View.MeasureSpec.makeMeasureSpec(0,View.MeasureSpec.UNSPECIFIED));
local width =0-(bullet_cars.getMeasuredWidth()); --获取设置文字后的控件宽度。这里需注意,必须在setText下面,否则读不到正常宽度。
bullet_cars.startAnimation(TranslateAnimation(获取屏幕宽(),width,0,0).setDuration(20000).setFillAfter(true).setAnimationListener(AnimationListener{
onAnimationEnd=function() --设置补间动画结束监听
if read_config("bullet_state") == true then
ballistic()
end
end}))
if read_config("log")==true then
local log= "弹道1,表:"..home_list_name[random].."内容:"..random_sqlite(home_list_name[random]).."弹幕状态:"..tostring(read_config("bullet_state"))
addrecord("log","弹道一",log)
end
end

  • 其他事件都不是很特殊,自己看看就行

volume文件

该文件为实现弹幕的核心文件,包括了弹幕容器,标志等

查看相关代码
  • 其中尤为重要的是flags标志,它决定你的悬浮窗是否与用户交互,是否响应事件
  • 在这里我们的弹幕不需要任何交互,因为它不能影响用户操作
  • 而悬浮窗不仅需要与用户交互,还得输入内容,对数据库操作。
  • 但这里有一个坑。我无法解决
  • 其中悬浮窗函数的flags设置为的是:
    1
    悬浮窗口容器.flags = WindowManager.LayoutParams().FLAG_NOT_TOUCH_MODAL;

悬浮窗交互时,由于需要输入框,所以要获取输入焦点,但这样会导致悬浮窗后无法获取输入焦点
也就是说,用户在悬浮窗上输入内容时,无法在悬浮窗外输入内容(触摸正常),收起悬浮窗后正常。

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
窗口 = activity.getSystemService(Context.WINDOW_SERVICE)

--弹幕容器
弹幕窗口容器 = WindowManager.LayoutParams()
if Build.VERSION.SDK_INT >= 26 then -- 判断SDK版本
弹幕窗口容器.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
弹幕窗口容器.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
end

--不发生任何交互的flags

弹幕窗口容器.flags = WindowManager.LayoutParams().FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS;
弹幕窗口容器.gravity = Gravity.LEFT | Gravity.TOP
弹幕窗口容器.format = 1
弹幕窗口容器.alpha = 0.8
弹幕窗口容器.y = activity.getWidth()/5
--弹幕窗口容器.width = WindowManager.LayoutParams.WRAP_CONTENT
弹幕窗口容器.width=activity.getWidth()
弹幕窗口容器.height = WindowManager.LayoutParams.WRAP_CONTENT
bullet=loadlayout("Mlayout/bullet_lay") -- 加载弹幕界面

-- 悬浮窗内容

--悬浮球容器
窗口容器 = WindowManager.LayoutParams()
if Build.VERSION.SDK_INT >= 26 then
窗口容器.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
窗口容器.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
end
窗口容器.flags = WindowManager.LayoutParams().FLAG_NOT_FOCUSABLE;
窗口容器.gravity = Gravity.LEFT | Gravity.TOP
窗口容器.format = 1
窗口容器.y = activity.getWidth()/3
窗口容器.width = WindowManager.LayoutParams.WRAP_CONTENT
--窗口容器.width=activity.getWidth()
窗口容器.height = WindowManager.LayoutParams.WRAP_CONTENT

--悬浮窗容器

--由于FLAGS设置的冲突,只好分开悬浮窗与悬浮球,分别设置Flags
--在悬浮窗显示时,屏幕后输入框将无法弹出输入法。悬浮球时正常
--详见搜索Flags属性

悬浮窗口容器 = WindowManager.LayoutParams()
if Build.VERSION.SDK_INT >= 26 then
悬浮窗口容器.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
悬浮窗口容器.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
end
悬浮窗口容器.flags = WindowManager.LayoutParams().FLAG_NOT_TOUCH_MODAL;
悬浮窗口容器.gravity = Gravity.LEFT | Gravity.TOP
悬浮窗口容器.format = 1
悬浮窗口容器.y = activity.getWidth()/3
悬浮窗口容器.width = WindowManager.LayoutParams.WRAP_CONTENT
--窗口容器.width=activity.getWidth()
悬浮窗口容器.height = WindowManager.LayoutParams.WRAP_CONTENT

悬浮窗=loadlayout("Mlayout/xfc")
悬浮球=loadlayout("Mlayout/xfq")

  • 其余依赖的函数太零碎,自己全局搜索咯

files文件夹

开源的文件管理器所有配置文件,不建议操作。

libs文件夹

软件class和百度统计的dex文件

1
2
3
4
--百度统计服务
StatService()
.setAppKey("") --统计id
.start(this)

res文件夹

软件使用的图片,字体

开源相关

致谢
软件开发以来,使用了部分开源项目。有作者信息的统计如下:

  • Nimoaix制作(QQ:1650656895
  • 由XMSUI1二改(XMSUI1作者QQ:1650656895)
  • XMSUI1由悬浮窗3d二改(悬浮窗3d作者QQ:1650656895)
  • 作者 Error
  • 作者:Paixs
  • 爱梅の日常
  • oゞ夜色乄未央ぁ
  • 苦小怕
  • 狸猫🐱

部分作者在开源项目中留下了QQ,我就默认允许公布其QQ。其他作者未主动公开,就仅留名。

『弹幕笔记』开源相关
体验APP:https://nrhs.eu.org/app/
开源文件:弹幕笔记历史版本及开源文件 自部署云盘,报错请多次刷新
备用资源:APP alp文件 提取密码:bullet
Andlua+:由于官方网站已关闭,附我使用的版本
讨论区:软件讨论区及问题反馈 保姆使用流程
开源协议:你可以不经作者许可二次修改,分发软件。但你需要留下作者信息,同时,你的软件不得用于商业用途。

若有相关问题可在博客下或者讨论区留言