Android -- [SelfView] 自定义多行歌词滚动显示器
Android – [SelfView] 自定义多行歌词滚动显示器
流畅、丝滑的滚动歌词控件* 1. 背景透明;* 2. 外部可控制进度变化;* 3. 支持屏幕拖动调节进度(回调给外部);
效果

歌词文件(.lrc)

一. 使用
<com.nepalese.harinetest.player.lrc.VirgoLrcViewandroid:id="@+id/lrcView"android:layout_width="match_parent"android:layout_height="match_parent"/>
private VirgoLrcView lrcView;lrcView = findViewById(R.id.lrcView);
initLrc();//==================================
private void initLrc(){
//设置歌词文件 .lrc
//lrcView.setLrc(FileUtils.readTxtResource(getApplicationContext(), R.raw.shaonian, "utf-8"));lrcView.setLrc(R.raw.shaonian);lrcView.seekTo(0);lrcView.setCallback(new VirgoLrcView.LrcCallback() {@Overridepublic void onUpdateTime(long time) {//拖动歌词返回的时间点}@Overridepublic void onFinish() {stopTask();}});
}public void onStartPlay(View view) {startTask();
}public void onStopPlay(View view) {stopTask();
}//使用计时器模拟歌曲播放时进度刷新
private long curTime = 0;
private final Runnable timeTisk = new Runnable() {@Overridepublic void run() {curTime += INTERVAL_FLASH;lrcView.seekTo(curTime);}
};private void startTask() {stopTask();handler.post(timeTisk);
}private void stopTask() {handler.removeCallbacks(timeTisk);
}private final long INTERVAL_FLASH = 400L;
private final Handler handler = new Handler(Looper.myLooper()) {@Overridepublic void handleMessage(@NonNull Message msg) {super.handleMessage(msg);}
};
二. 码源
attr.xml
<declare-styleable name="VirgoLrcView"><attr name="vlTextColorM" format="color|reference" /><attr name="vlTextColorS" format="color|reference" /><attr name="vlTextSize" format="dimension|reference" /><attr name="vlLineSpace" format="dimension|reference" />
</declare-styleable>
VirgoLrcView.java
import android.animation.Animator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.LinearInterpolator;import androidx.annotation.Nullable;
import androidx.annotation.RawRes;import com.nepalese.harinetest.R;
import com.nepalese.harinetest.utils.CommonUtil;import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;/*** Created by Administrator on 2024/11/26.* Usage:更流畅、丝滑的滚动歌词控件* 1. 背景透明;* 2. 外部可控制进度变化;* 3. 支持屏幕拖动调节进度(回调给外部);*/public class VirgoLrcView extends View {private static final String TAG = "VirgoLrcView";private static final float PADD_VALUE = 25f;//时间线两边缩进值private static final float TEXT_RATE = 1.25f;//当前行字体放大比例private static final long INTERVAL_ANIMATION = 400L;//动画时长private static final String DEFAULT_TEXT = "暂无歌词,快去下载吧!";private final Context context;private Paint paint;//画笔, 仅一个private ValueAnimator animator;//动画private List<LrcBean> lineList;//歌词行private LrcCallback callback;//手动滑动进度刷新回调//可设置变量private int textColorMain;//选中字体颜色private int textColorSec;//其他字体颜色private float textSize;//字体大小private float lineSpace;//行间距private float selectTextSize;//当前选中行字体大小private int width, height;//控件宽高private int curLine;//当前行数private int locateLine;//滑动时居中行数private int underRows;//中分下需显示行数private float itemHeight;//一行字+行间距private float centerY;//居中yprivate float startY;//首行yprivate float oldY;//划屏时起始按压点yprivate float offsetY;//动画已偏移量private float offsetY2;//每次手动滑动偏移量private long maxTime;//歌词显示最大时间private boolean isDown;//按压界面private boolean isReverse;//往回滚动?public VirgoLrcView(Context context) {this(context, null);}public VirgoLrcView(Context context, @Nullable AttributeSet attrs) {this(context, attrs, 0);}public VirgoLrcView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {super(context, attrs, defStyleAttr);this.context = context;init(attrs);}private void init(AttributeSet attrs) {TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.VirgoLrcView);textColorMain = ta.getColor(R.styleable.VirgoLrcView_vlTextColorM, Color.CYAN);textColorSec = ta.getColor(R.styleable.VirgoLrcView_vlTextColorS, Color.GRAY);textSize = ta.getDimension(R.styleable.VirgoLrcView_vlTextSize, 45f);lineSpace = ta.getDimension(R.styleable.VirgoLrcView_vlLineSpace, 28f);ta.recycle();selectTextSize = textSize * TEXT_RATE;curLine = 0;maxTime = 0;isDown = false;isReverse = false;lineList = new ArrayList<>();paint = new Paint();paint.setTextSize(textSize);paint.setAntiAlias(true);calculateItem();}@Overrideprotected void onLayout(boolean changed, int left, int top, int right, int bottom) {super.onLayout(changed, left, top, right, bottom);if (width == 0 || height == 0) {initLayout();}}//控件大小变化时需重置计算private void initLayout() {width = getWidth();height = getHeight();centerY = (height - itemHeight) / 2.0f;startY = centerY;underRows = (int) Math.ceil(height / itemHeight / 3);Log.d(TAG, "itemHeight: " + itemHeight + ", underRows: " + underRows);}@Overrideprotected void onDraw(Canvas canvas) {//提示无歌词if (lineList.isEmpty()) {paint.setColor(textColorMain);paint.setTextSize(selectTextSize);canvas.drawText(DEFAULT_TEXT, getStartX(DEFAULT_TEXT, paint), centerY, paint);return;}if (isDown) {paint.setTextSize(textSize);paint.setColor(textColorSec);//画时间if (locateLine >= 0) {canvas.drawText(lineList.get(locateLine).getStrTime(), PADD_VALUE, centerY, paint);}//画选择线canvas.drawLine(PADD_VALUE, centerY, width - PADD_VALUE, centerY, paint);//手动滑动drawTexts(canvas, startY - offsetY2);} else {//自动滚动if (isReverse) {drawTexts(canvas, startY + offsetY);} else {drawTexts(canvas, startY - offsetY);}}}private void drawTexts(Canvas canvas, float tempY) {for (int i = 0; i < lineList.size(); i++) {float y = tempY + i * itemHeight;if (y < 0 || y > height) {continue;}if (curLine == i) {paint.setTextSize(selectTextSize);paint.setColor(textColorMain);} else {paint.setTextSize(textSize);paint.setColor(textColorSec);}canvas.drawText(lineList.get(i).getLrc(), getStartX(lineList.get(i).getLrc(), paint), y, paint);}}@Overridepublic boolean onTouchEvent(MotionEvent event) {super.onTouchEvent(event);switch (event.getAction()) {case MotionEvent.ACTION_DOWN:isDown = true;if (animator != null) {if (animator.isRunning()) {//停止动画animator.end();}}locateLine = -1;oldY = event.getY();break;case MotionEvent.ACTION_MOVE:offsetY2 = oldY - event.getY();calculateCurLine(oldY - event.getY());//定位时间啊invalidate();break;case MotionEvent.ACTION_UP:isDown = false;postNewLine();break;}return true;}//计算滑动后当前居中的行private void calculateCurLine(float y) {int offLine = (int) Math.floor(y / itemHeight);if (offLine == 0) {return;}locateLine = curLine + offLine;if (locateLine > lineList.size() - 1) {//最后一行locateLine = lineList.size() - 1;} else if (locateLine < 0) {//第一行locateLine = 0;}}//回调通知,自身不跳转进度private void postNewLine() {//返回当前行对应的时间线if (callback == null) {return;}if (locateLine >= 0) {callback.onUpdateTime(lineList.get(locateLine).getTime());}}@Overrideprotected void onDetachedFromWindow() {releaseBase();super.onDetachedFromWindow();}/*** 移除控件,注销资源*/private void releaseBase() {cancelAnim();if (lineList != null) {lineList.clear();lineList = null;}if (callback != null) {callback = null;}}private void calculateItem() {itemHeight = getTextHeight() + lineSpace;}//计算使文字水平居中private float getStartX(String str, Paint paint) {return (width - paint.measureText(str)) / 2.0f;}//获取文字高度private float getTextHeight() {Paint.FontMetrics fm = paint.getFontMetrics();return fm.descent - fm.ascent;}//解析歌词private void parseLrc(InputStreamReader inputStreamReader) {BufferedReader reader = new BufferedReader(inputStreamReader);String line;try {while ((line = reader.readLine()) != null) {parseLine(line);}} catch (IOException e) {e.printStackTrace();}try {inputStreamReader.close();} catch (IOException e) {e.printStackTrace();}try {reader.close();} catch (IOException e) {e.printStackTrace();}maxTime = lineList.get(lineList.size() - 1).getTime() + 1000;//多加一秒}private long parseTime(String time) {// 00:01.10String[] min = time.split(":");String[] sec = min[1].split("\\.");long minInt = Long.parseLong(min[0].replaceAll("\\D+", "").replaceAll("\r", "").replaceAll("\n", "").trim());long secInt = Long.parseLong(sec[0].replaceAll("\\D+", "").replaceAll("\r", "").replaceAll("\n", "").trim());long milInt = Long.parseLong(sec[1].replaceAll("\\D+", "").replaceAll("\r", "").replaceAll("\n", "").trim());return minInt * 60 * 1000 + secInt * 1000 + milInt;// * 10;}private void parseLine(String line) {Matcher matcher = Pattern.compile("\\[\\d.+].+").matcher(line);// 如果形如:[xxx]后面啥也没有的,则return空if (!matcher.matches()) {long time;String str;String con = line.replace("\\[", "").replace("\\]", "");if (con.matches("^\\d.+")) {//timetime = parseTime(con);str = " ";} else {return;}lineList.add(new LrcBean(time, str, con));return;}//[00:23.24]让自己变得快乐line = line.replaceAll("\\[", "");String[] result = line.split("]");lineList.add(new LrcBean(parseTime(result[0]), result[1], result[0]));}private void reset() {lineList.clear();curLine = 0;maxTime = 0;isReverse = false;cancelAnim();}///动画//*** 更新动画** @param lineNum 需跳转行数*/private void updateAnim(int lineNum) {if (lineNum == 0) {return;} else if (lineNum == 1) {//自然变化if (curLine >= lineList.size() - underRows) {//停止动画 仅变更颜色cancelAnim();invalidate();return;}}isReverse = lineNum < 0;cancelAnim();setAnimator(Math.abs(lineNum));doAnimation();}/*** 注销已有动画*/protected void cancelAnim() {if (animator != null) {animator.removeAllListeners();animator.end();animator = null;}}/*** 动态创建动画** @param lineNum 需跳转行数*/private void setAnimator(int lineNum) {animator = ValueAnimator.ofFloat(0, itemHeight * lineNum);//一行animator.setDuration(INTERVAL_ANIMATION);animator.setInterpolator(new LinearInterpolator());//插值器设为线性}/*** 监听动画*/private void doAnimation() {if (animator == null) {return;}animator.addListener(new Animator.AnimatorListener() {@Overridepublic void onAnimationStart(Animator animation) {offsetY = 0;}@Overridepublic void onAnimationEnd(Animator animation) {if (isReverse) {startY += offsetY;} else {startY -= offsetY;}offsetY = 0;invalidate();}@Overridepublic void onAnimationCancel(Animator animation) {}@Overridepublic void onAnimationRepeat(Animator animation) {}});animator.addUpdateListener(animation -> {float av = (float) animation.getAnimatedValue();if (av == 0) {return;}offsetY = av;invalidate();});animator.start();}public interface LrcCallback {void onUpdateTime(long time);void onFinish();}/*** 滑动监听** @param callback LrcCallback*/public void setCallback(LrcCallback callback) {this.callback = callback;}public void setTextColorMain(int textColorMain) {this.textColorMain = textColorMain;}public void setTextColorSec(int textColorSec) {this.textColorSec = textColorSec;}public void setTextSize(float textSize) {this.textSize = textSize;this.selectTextSize = textSize * TEXT_RATE;paint.setTextSize(textSize);calculateItem();}public void setLineSpace(float lineSpace) {this.lineSpace = lineSpace;calculateItem();}/*** 设置歌词** @param lrc 解析后的string*/public void setLrc(String lrc) {if (TextUtils.isEmpty(lrc)) {return;}reset();parseLrc(new InputStreamReader(new ByteArrayInputStream(lrc.getBytes())));}/*** 设置歌词** @param resId 资源文件id*/public void setLrc(@RawRes int resId) {reset();parseLrc(new InputStreamReader(context.getResources().openRawResource(resId), StandardCharsets.UTF_8));}/*** 设置歌词** @param path lrc文件路径*/public void setLrcFile(String path) {File file = new File(path);if (file.exists()) {reset();String format;if (CommonUtil.isUtf8(file)) {format = "UTF-8";} else {format = "GBK";}FileInputStream inputStream = null;try {inputStream = new FileInputStream(file);} catch (FileNotFoundException e) {e.printStackTrace();}InputStreamReader inputStreamReader = null;//'utf-8' 'GBK'try {inputStreamReader = new InputStreamReader(inputStream, format);} catch (UnsupportedEncodingException e) {e.printStackTrace();}parseLrc(inputStreamReader);}}/*** 调整播放位置** @param time ms*/public void seekTo(long time) {if (isDown) {//拖动歌词时暂不处理return;}if (time == 0) {//刷新invalidate();return;} else if (time > maxTime) {//超最大时间:通知结束if (callback != null) {callback.onFinish();}return;}for (int i = 0; i < lineList.size(); i++) {if (i < lineList.size() - 1) {if (time >= lineList.get(i).getTime() && time < lineList.get(i + 1).getTime()) {int temp = i - curLine;curLine = i;updateAnim(temp);break;}} else {//last lineint temp = i - curLine;curLine = i;updateAnim(temp);break;}}}
}
相关文章:
Android -- [SelfView] 自定义多行歌词滚动显示器
Android – [SelfView] 自定义多行歌词滚动显示器 流畅、丝滑的滚动歌词控件* 1. 背景透明;* 2. 外部可控制进度变化;* 3. 支持屏幕拖动调节进度(回调给外部);效果 歌词文件(.lrc) 一. 使用…...
vscode 配置C/C++环境控制台参数
您可以通过以下步骤在VS Code中配置C/C环境的控制台参数: 1,打开VS Code并进入您的C/C项目 2,点击左侧的"调试"图标,然后点击顶部的齿轮图标,选择“launch.json”。 3,在"launch.json&qu…...
【HarmonyOS学习日志(13)】计算机网络之TCP/IP协议族(二)
文章目录 TCP/IP协议族ARPDNS标志字段:协商具体的通信方式和反馈通信状态DNS查询问题的格式资源记录(Resource Record, RR)格式:被用于应答字段、授权字段和额外信息字段 IP协议IP服务的特点无状态无连接不可靠 IP头部结构IPv4头部…...
多系统对接的实现方案技术分析
前言 随着信息化和大数据时代的到来,数据资产变得至关重要,企业纷纷上线多种软件系统和移动端应用以适应这一变化。这些系统和应用虽然发挥了各自的优势,但也导致了信息孤岛问题。为了解决这一问题,数据中台和异构系统集成技术应…...
kv类型算子使用
对kv类型的RDD数据集进行操作。 keys """ 获取所有的key转换算子"""inputRdd sc.parallelize([(laoda, 11), (laoer, 22), (laosan, 33), (laosi, 44)]) print(inputRdd.keys().collect()) # [laoda, laoer, laosan, laosi] values "&…...
3维建模blender
官网稳定版下载:https://www.blender.org/download/lts/ windows有安装版和portable版 教程:https://www.bilibili.com/video/BV1kX4y1m7G5 1. 基础操作 场景操作 场景位移:shift鼠标中键长按场景旋转:鼠标中键长按场景缩放&…...
百问FB网络编程 - UDP编程简单示例
6.5 UDP编程简单示例 UDP服务器首先进行初始化操作:调用函数socket创建一个数据报类型的套接字,函数bind将这个套接字与服务器的公认地址绑定在一起。然后调用函数recvfrom接收UDP客户机的数据报。UDP客户机首先调用函数socket创建一个数据报套接字&…...
面试题:什么是ThreadLocal,如何实现的?
强烈推荐 前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站:人工智能 你是否还在为简历无人阅读而感到沮丧?是否因为寻觅不到理想的工作机会而感到焦虑不安?试试:看看…...
js后端开发之Next.js、Nuxt.js 与 Express.js
后端js之Next.js、Nuxt.js 与 Express.js 在现代 Web 开发中,JavaScript 已经成为前后端通用的编程语言,而选择合适的后端框架则是构建高效、可扩展应用程序的关键。本文将带你深入了解三个流行的 JavaScript 后端框架:Next.js、Nuxt.js 和 …...
飞牛Nas如何实现阿里云盘、百度网盘的资料迁移!
文章目录 📖 介绍 📖🏡 演示环境 🏡📒 如何使用飞牛NAS实现阿里云盘与百度网盘的数据互相迁移 📒📝 操作步骤注意事项⚓️ 相关链接 ⚓️📖 介绍 📖 你是否有将百度网盘的文件迁移到阿里云盘,或是将阿里云盘的资料转移到百度网盘的需求?本文将给大家演示如…...
如何在小米平板5上运行 deepin 23 ?
deepin 23 加入了 ARM64 支持,这里尝试将 deepin 系统刷入平板中,平常使用中,带个笔记本电脑有时候也会嫌比较麻烦,把 Linux 系统刷入平板中既满足了使用需要,又满足了轻便的需求。为什么不使用 Termux ?虽…...
【PlantUML系列】流程图(四)
目录 目录 一、基础用法 1.1 开始和结束 1.2 操作步骤 1.3 条件判断 1.4 并行处理 1.5 循环 1.6 分区 1.7 泳道 一、基础用法 1.1 开始和结束 开始一般使用start关键字;结束一般使用stop/end关键字。基础用法包括: start ... stopstart ...…...
操作系统:进程、线程与作业
背景介绍: 因为单道程序处理器效率低 、设备利用率低 、内存利用率低的问题人们提出了多道程序设计来解决这个问题。 多道程序致力于提高处理机、设备、内存等各种资源的利用率,从而提高系统效率,也就是吞吐量,吞吐量定义为单位时…...
先验地图--slam学习笔记
先验信息 (Prior Information) 先验信息指的是在收集新数据之前已有的知识或假设。这种信息可以来自之前的实验、历史数据、理论模型或专家意见。 地图信息:在无人驾驶中,车辆通常会预先加载高精度地图数据,这些地图数据提供了道路布局、车…...
空指针异常:软件开发中的隐形陷阱
在软件开发的世界里,bug如同隐藏在代码森林中的小怪兽,不时跳出来给开发者们制造惊喜(或惊吓)。其中,空指针异常(Null Pointer Exception, NPE)无疑是最令人头疼的一类。它悄无声息,…...
【Java从入门到放弃 之 GC】
垃圾回收 垃圾回收什么是垃圾引用计数法可达性分析算法 垃圾回收算法标记清除法标记复制法标记整理法分代 常用的垃圾回收器 垃圾回收 如果你学过C,你肯定知道,我们没申请一块内存,都要自己写回收内存的方法。而Java不需要我们管理内存&…...
【C++】等差数列末项计算题解析及优化
博客主页: [小ᶻ☡꙳ᵃⁱᵍᶜ꙳] 本文专栏: C 文章目录 💯前言💯题目描述与输入输出要求💯数学分析与公式推导公差的计算通项公式推导 💯示例解析解题步骤 💯程序实现与解析初版代码代码解析优点与不足…...
vue中父组件接收子组件的多个参数的方法:$emit或事件总线
方法一:使用 $emit 方法 原理 子组件通过 $emit 方法向父组件发送事件,同时可以传递多个参数,父组件通过事件监听来接收这些参数。 示例 子组件代码 <template><div><button click"sendData">发送数据</…...
2024.12.10——攻防世界Web_php_include
知识点:代码审计 文件包含 伪协议 伪协议知识点补充: 在PHP中,伪协议(Pseudo Protocols)也被称为流包装器,这些伪协议以 php://开头,后面跟着一些参数,用于指定要执行的操作或需要…...
【机器学习算法】——数据可视化
1. 饼图:显示基本比例关系 import matplotlib.pyplot as pltplt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False# ——————————————————————————————————————————————————————…...
第8篇 | 逻辑回归
逻辑回归虽然名字中包含"回归",但实际上是一种分类算法。它通过sigmoid函数将线性输出转换为概率,广泛用于二分类问题。本篇将详细介绍逻辑回归的原理、实现和应用。一、逻辑回归概述逻辑回归用于处理二分类问题,输出为样本属于某一…...
从国科大NLP课程笔记出发:手把手教你用Python复现CYK句法分析算法
从理论到实践:用Python实现CYK句法分析算法的完整指南 在自然语言处理领域,句法分析是理解句子结构的关键步骤。CYK算法作为一种经典的句法分析技术,因其简洁高效的特点,成为许多NLP工程师工具箱中的必备武器。本文将带你从零开始…...
OpenClaw镜像体验:无需本地安装快速测试Qwen3.5-4B-Claude
OpenClaw镜像体验:无需本地安装快速测试Qwen3.5-4B-Claude 1. 为什么选择云端镜像方案 上周我在本地尝试部署OpenClaw时,被Node版本冲突和系统权限问题折磨了整整两天。当看到星图平台提供预装好的OpenClawQwen3.5-4B-Claude镜像时,立刻决定…...
无障碍辅助利器:OpenClaw+GLM-4.7-Flash语音控制电脑实操
无障碍辅助利器:OpenClawGLM-4.7-Flash语音控制电脑实操 1. 为什么我们需要语音控制电脑 去年夏天,我的一位程序员朋友因意外导致手部受伤,暂时失去了正常使用键盘鼠标的能力。看着他艰难地用语音输入法逐字敲代码,我开始思考&a…...
cutlass代码架构分析
CUTLASS 代码架构分析 本文档基于 cutlass代码进行梳理,快速理解 CUTLASS 4.x 的模块边界与调用链路。 1. 总体架构 CUTLASS 本质上是一个 header-only 的 CUDA C++ 模板库,外围配套了可选构建目标: include/:核心库(cutlass + cute) tools/:库实例化、性能测试与通用…...
网安学习路线!最详细没有之一!看了这么多分享网安学习路线的一个详细的都没有!
零基础小白,到就业!入门到入土的网安学习路线! 在各大平台搜的网安学习路线都太粗略了。。。。看不下去了! 我把自己报班的系统学习路线,整理拿出来跟大家分享了!点击下图,福利! …...
【亲测】OpenClaw怎么部署?2026年OpenClaw华为云8分钟搭建喂奶级教程
【亲测】OpenClaw怎么部署?2026年OpenClaw华为云8分钟搭建喂奶级教程。OpenClaw能做什么?OpenClaw怎么部署?本文面向零基础用户,完整说明在轻量服务器与本地Windows11、macOS、Linux系统中部署OpenClaw(Clawdbot&#…...
Mac开发者必备:OpenClaw调试QwQ-32B代码补全全流程
Mac开发者必备:OpenClaw调试QwQ-32B代码补全全流程 1. 为什么选择OpenClaw作为代码助手 作为一名长期在Mac上开发的全栈工程师,我一直在寻找能够真正融入工作流的智能编码工具。直到遇到OpenClaw,才发现这个开源的本地化AI智能体框架完美契…...
SEO_避开这些常见误区让你的SEO效果事半功倍
<h2>SEO误区一:忽视关键词优化</h2> <p>在进行SEO优化时,关键词的选择和使用是至关重要的。很多人忽视了关键词优化,导致他们的网站在搜索引擎中的排名一直停滞不前。关键词不仅仅是为了让搜索引擎理解你的网站内容&#x…...
导师推荐!盘点2026年顶流之选的AI论文写作工具
一天写完毕业论文在2026年已不再是天方夜谭。2026年AI论文写作工具全面升级,实测提速超300%,覆盖选题构思、文献综述、数据整理、格式排版等核心场景,高效搞定论文不再是梦想。 一、全流程王者:一站式搞定论文全链路(一…...
