想在电脑上看小说。至于为什么用JavaFX那当然是因为我不会Qt啦。
upd 2023.11.12
今天突然发现打不开了,会报错:Cannot invoke "com.sun.javafx.font.directwrite.IWICImagingFactory.CreateBitmap(int, int, int, int)" because "<local3>" is null
查了一下可能是Windows10 22H2 19045.3570和JavaFX 12哪里不兼容,更换JavaFX版本可解决,目前17.0.1可用。Java版本:Java16
相关报错讨论:0.3.2 running on Windows10 (#5) · 议题 · Elad / SeratoCrateExporter · GitLab
upd 2022.10.1
前排预警:这份代码写得很拉,唯一的优点是能跑。
最近阅读量暴涨100。很恐慌。
0.前言
本来想一边做一边写的,但开工的时候没个规划,写得乱七八糟,一会儿改一遍容器,最后直接合并了两个类。写完了才想起maven框,又调半天。这个故事告诉我们写代码前先画个图理理逻辑是很重要的。
不过从头自己设计一个小软件还是很开心的啦qwq
1.功能
- 打开本地小说,并解析出目录;
- 无边框,可调节背景透明度(便于隐藏摸鱼;
- 支持键盘翻页(假装钻研代码
2.运行流程
- 打开上次缓存记录,若没有,则提醒没有打开的书;
- 加载小说,如果没有看过,解析目录,按章节始末位置存储,便于下次打开;如果没有分章或者没解析出来,则将内容分段存储。如果看过,直接打开上次的缓存文件;
- 加载UI界面;
- 每次加载一章,显示当前内容;换章时加载下一章内容;
- 关闭时存储当前进度
3.类的说明
- Chapter 记录章节在txt中的起始位置、结束位置和章节名。
- Book 包含文件名、路径、章节、解析章节是否成功。方法包括解析章节并缓存、读入指定章节等。
- BookTemp 用于缓存上次阅读信息。
- GUI 主类。构建场景,添加节点。
- Chapter_GUI 获取当前章节内容并显示到界面中。这名起的不好。
- Apprun 用于启动程序,避免很多加载问题。
工具类:
- DragUtil 用于在Stage取消装饰后添加拖动功能。
- DrawUtil 用于添加拖拽功能。
- 以上两个工具类作者为Light,原博链接
4.部分问题解决方法
按写代码的顺序来。
1.Book类
啊这个类。一开始真的只有Book的模型,处理文本是另一个叫DealText的类(纪念一下它),后来传来传去的重复很多,索性和在一起了。
先说文件读写。考虑到要便于从中抽取章节,用了RandomAccessFile类。
使用方法:
RandomAccessFile in=new RandomAccessFile(filepath,"r"); long start=0; in.seek(start);//指 针 跳 动 t=in.readLine() now_pos=in.getFilePointer();//获取当前位置,为long型 in.length()//全部文本长度 in.close();
判断章节的研究了很久小说的正则表达式:
String r="\\s*(第[0-9|零一二三四五六七八九十壹贰叁肆伍陆柒捌玖拾百千万]+[章卷回话]" + "|[c|C]hapter.*[0-9]|☆、.*|[上中下终]卷" + "|卷[0-9|零一二三四五六七八九十壹贰叁肆伍陆柒捌玖拾]+" + "|[引子|楔子|序][\n\\s]|[Ll][Vv].[0-9]+|-Quiz [0-9]+).*";
正则表达式,小括号表示匹配几项,中括号表示从里面选择。
正则表达式使用:
Pattern p=Pattern.compile(r); Matcher m=p.matcher(now_text); if(m.matches())
考虑到每读完一行指针停留在末尾,也就是正则表达式判完了的位置实际上是标题结束的位置,再记录一下上一行末尾的pos,匹配上用这个。
考虑到每行都匹配一遍很慢,经过我仔细观察章节名是不应当有句号的。但很多人物对话结尾都用!和?,所以再筛一波:“。
有时候明明有第x章,但匹配失败,很可能是中英文空格不同的问题。因此对于要匹配的文字先把中文空格换成英文空格,最后用trim()去掉首尾空格。
now_text=now_text.replaceAll(" "," ").trim();
读取特定章节也差不多,就不写了。BookTemp没几行也没啥好说的。
2.Chapter_GUI类
写这个类的时候有俩需求,一是方便大小调整,二是能区分标题和正文的字体大小。因此在传参的时候加入了容器宽高,字号大小,以及有没有标题。说实话规定宽高初始化的时候挺不方便的,别人绑定一下就好了,这还得手动乘比例。但JavaFX一开始运行它没有宽高,得自己设置,或者是有但我没找到。如果有人看且看的人知道答案记得踢踢我(
接下来找了半天也没找到合适的显示文字的容器。先按宽度把原文分行,放到队列里,然后以VBox为底,在上面添加Text。注意每放一句话就要开一个新的Text,不然VBox会以为你把同一个添加了好几遍,然后报错。计算当地Text高度可以用这个函数:
text.getBoundsInLocal().getHeight();
算宽度的时候不知道这玩意,就用的对照表里的宽度,所以最后做出来右边有空当,但问题不大。
每次换页的时候要先把之前的内容clear()掉。
3.GUI类
啊这个类太恐怖了。
目标:
-
- 能拖动、改变大小;
- 实现翻页功能;
- 能点击目录转到对应章节;
- 能从本地选择小说
- 关闭及最小化
- 调节透明度
如果呢把拖动和拖拽绑在一个容器上,那么拖拽功能是会被覆盖的;因此选择在background上再加一个容器,放目录栏和正文。
考虑background的选择。CenterPane看起来不错,但其子节点大小过于玄学;想要有并列的两个box,可以考虑HBox,但HBox不能确定子节点位置,因此最后用了朴实无华的Pane(。
设置宽高:
background.setPrefHeight(prefsize);
background.setPrefWidth(prefsize);
以前不理解能写一个数的为什么要特地给它一个变量,现在我懂了,这个数还可能会出现n次,改起来那是真的死亡。
Tips:
idea中可以Ctrl+R搜索和修改变量名。如果只是找哪里出现点一下就好了。
绑定拖拽事件:
DrawUtil.addDrawFunc(stage,background);
background上开一个HBox放目录和正文,留个边给拖拽:
hBox.layoutXProperty().bind(background.widthProperty().multiply(5).divide(100)); hBox.layoutYProperty().bind(background.heightProperty().multiply(5).divide(100)); hBox.prefHeightProperty().bind(background.heightProperty().multiply(0.9)); hBox.prefWidthProperty().bind(background.widthProperty().multiply(0.9));
绑定后mutiply一下,就是占比多少的意思。
然后一样的开一个Pane放页面的vBox,和另一个Pane用来点击出现目录。由于点击目录后目录会占一定比例,而我不希望目录直接覆盖部分页面,这样谁知道点到哪了。所以页面的Pane会有两个比例。初始化先绑定pane_width1,起个全局变量,方便之后调整。
最后加一个listview来展示目录。
先把章节名都放到FX自带的Collection里,然后统一加进去。调整一下格子大小。
ObservableList<Label> list = FXCollections.observableArrayList(); for (int i = 0; i < book.getChapter_num(); i++) { list.add(new Label(book.getChapterName(i))); } listView = new ListView<>(); listView.setItems(list); listView.setFixedCellSize(20);
listview的css麻烦一点,要分背景、奇数格子、偶数格子
.list-cell:even{ -fx-background-color:rgba(123,186,238,0.1); -fx-text-fill: black; } .list-cell:odd{ -fx-background-color:rgba(255,255,255,0); -fx-text-fill: black; } .list-view { -fx-background-color: transparent; -fx-text-fill: black; } .list-cell:selected { -fx-background-color:rgba(124,124,124,0.6); -fx-text-fill: black; } .list-cell:hover{ -fx-background-color:rgba(124,124,124,0.5); -fx-text-fill: black; }
介于最后要调节透明度,很多透明度为0.1的叠在一起就不透明了,所以除了background都设置rgba(x,x,x,0)。不能用设置opacity替代,不然文字内容就没了。
关于为什么不统一绑定css,因为用Application那个方法slide会显示不出来。
用slide调节透明度。每当你想不到用什么元件可以打开Scene Builder,里面啥都有。
设成横的:
slider.setOrientation(Orientation.HORIZONTAL);
提醒没有前/后一页了:用一个TimeLine,设定一帧一帧的动画,达到自己出现自己消失的目的:
Timeline t = new Timeline( new KeyFrame(Duration.seconds(0), new KeyValue(label.opacityProperty(), 1)), new KeyFrame(Duration.seconds(4), new KeyValue(label.opacityProperty(), 0)) ); t.setAutoReverse(true); t.setCycleCount(Timeline.INDEFINITE); t.play();
好了基本内容有了,接下来绑定事件。
关于JavaFX事件相应的逻辑:点这里
一些可以用的事件:点这里
其实官方文档还是最方便的。但有中文谁不想看中文呢(
首先是翻页。我希望鼠标点击Pane的左右两侧和用键盘上下左右都能翻页,因此设两个事件。由于Slider也是自带键盘事件的,所以要提前把这个事件消耗掉,键盘事件设在scene那里。
scene.addEventFilter(KeyEvent.KEY_PRESSED, e -> { if (e.getCode() == KeyCode.RIGHT || e.getCode() == KeyCode.DOWN) page_num++; if (e.getCode() == KeyCode.LEFT || e.getCode() == KeyCode.UP) page_num--; turnPage(); e.consume(); });
点击事件就在pane里。
pane.setOnMouseClicked(e->{ if(stage_X==stage.getX()&&stage_Y==stage.getY()) { double x = e.getSceneX(); if (x < pane.getWidth() / 2) page_num--; if (x >= pane.getWidth() / 2) page_num++; turnPage(); } else { stage_X = stage.getX(); stage_Y = stage.getY(); } e.consume(); });
由于pane上还有一个拖动事件,所以判断一下舞台位置有没有移动,没有的话判断为翻页。因此还要记录舞台位置,加俩变量维护一下。
翻页的方法注意前后章节衔接。往前一章在最后一页,往后一章在第一页。
关于拖拽事件,大小变了文字排版也得变啊,每次重新加载一下章节还是可以接受的,但是每次变化途中都要加载是不能接受的。所以不用ChangeListener,当鼠标放下再重新加载页面,注意页面大小调整后当前页码是否仍然存在:
background.setOnMouseReleased(mouseEvent -> { //根据窗口大小判断是否改变文字还是翻页 if(back_width!=background.getWidth()||back_height!=background.getHeight()) { back_width=background.getWidth(); back_height=background.getHeight(); chapter.setSize(pane.getWidth(), pane.getHeight()); page_num=Math.min(page_num,chapter.getPage_num()-1); pane.getChildren().clear(); pane.getChildren().add(chapter.getPage(page_num)); mouseEvent.consume(); } });
还是要特判一下翻页,毕竟鼠标事件对于程序来说没有区别,它也不知道你点在哪。
catalog那里每次都加载一遍目录没必要,所以第一次加载后就存在那,后面直接调用即可。不用设为null。绑定的事件没啥。
Slider要加个listener:
slider.valueProperty().addListener((ov,oldval,newval)->{ background.setStyle("-fx-background-color:rgba("+background_color+newval+")"); });
文件处理和缓存是点细节,懒得写了。
4.关于jar包和调试时文件路径不同的问题
打包又查了半天,最后加了marven一键package,舒服了。
然后用exe4j装成exe,打不开,找不到文件。
研究了一下java的文件默认目录。
File那一堆默认是项目根目录
getClass.getResource和getResourceAsStream不加/是当前文件目录,加了是源根目录。
但是getResource到了jar包里就啥都检测不到啦!(upd补:也许找得到,可能是我放的地方有问题。
因此图片改成:
stage.getIcons().add(new Image(getClass().getResourceAsStream("/cat.png")));
文件改成:
String tempfile=System.getProperty("user.dir")+test+"/resources"+"/lastview";
tempfile=tempfile.replaceAll("\\\\","/");
就css事最多,要url,之前一直用toExternalForm,现在手写url,
String cssfile="file:/"+System.getProperty("user.dir")+test+"/resources"+"/GUI.css";
cssfile=cssfile.replaceAll("\\\\","/");
本地跑了,但到有jre的朋友电脑上又不跑了,难绷。等完修修。
参考资料:
5.Profiler的使用
听说javafx写出来的应用比较占运行内存,于是找了个内存检测软件。
然后在IDEA里装个同名插件
是这样的。
先点左边的按钮,它加载完会告诉你等待连接,然后我等了半天发现并没有连上,实际应该再点右边的图标手动连接。打扰了。
出现界面后直接点attach,找到正在运行的项目,然后一路确定。
upd补:java有垃圾自动回收机制,写点gc的意义可能在于,看到它马上就回收,心理安慰。
inf. 后续以及遗留问题
- 写完想打包成exe发给朋友一起摸鱼的,但让人家下jre也不方便,想打包一起打进去。试了jpackage,不知道运行到哪里去了;javafx似乎在marven上有个打包带jre的插件,但跑不起来,可能是版本问题,国内资料都有点过时,搜javafx还得上google;最后放弃治疗直接一个jre提出来放exe4j里,发出去还是找不到类。累了。先自己凑合用用吧。
- 任务管理器看内存高达117MB,但用JProfiler检测只有14MB,很困惑啊。
- ↑upd:运行内存核提交内存和任务管理器内存是三个东西
- 老忘空指针处理,这个不好,随时可能报错。
- 变量名不够明确。
- 后续想加个输网页url在本地看小说功能,也就是去掉(黄色)小广告。
- 然后把序列化存本地的行为改成存数据库,整个后台管理系统,记录看过的书的进度。或许可以学个fxml?
- 当然不知道什么时候再写就是了
upd: 2022/5/5
- 增加了解码种类,但utf-16LC似乎还是无法解码
根据txt文件头识别编码类型。RandomAccessFile默认编码为ISO-8859-1,因此用getbytes获得byte数组,再按其他编码类型转回String。
res=new String(a.getBytes("ISO-8859-1"),charset);
- 修复加载期间点击会死机的bug
使用JavaFX的Task类。
Task<Void> task = new Task<>() { @Override protected Void call() throws Exception { //子线程获取主线程变量 is_loading=1; book = new Book(name, path); ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(output)); out.writeObject(book); out.close(); is_loading=0; Platform.runLater(new Runnable() { @Override public void run() { showText(); } }); return null; } }; Thread thread = new Thread(task); thread.start();
加载期间不允许目录等被点击。
用runLater指定线程结束后事件。如果用join锁住的话主线程不推进,还是会死机。
-
- 接下来增加手动gc功能,加个识别后缀是否为txt,以及task执行过程中手动取消功能。
upd: 2022/5/7
- 没有手动gc,改成翻页打开新书自动gc;System.gc()这个函数意思是告诉jvm可以gc了,具体gc看jvm心情。但一般还是会执行的。
- 增加手动取消打开书本按钮。
判断当前线程是否被关闭:if(Thread.currentThread().isInterrupted())
但task执行完没找到能判断是否正常结束的方法,所以设了个flag。放在Platform.runLater里
- 修复了一些正则表达式瞎匹配的情况
upd: 2023/12/26
- 推荐个手机阅读器,实现了本人所有梦想中的效果,此贴完结。
- legado
- 直接在github上下安装包就好