2024年6月

原文:
Libgdx游戏开发(6)——游戏暂停-Stars-One的杂货小窝

暂停也是一个游戏的必要功能了,本文研究了Libgdx实现游戏暂停

例子以桌面端游戏实现讲解为主,至于移动端,可能之后会进行补充...

本文最终实现的就是

按下esc暂停,之后会出现一个界面提示,表示当前已经暂停

重新按下esc,则返回继续游戏

本篇稍微学习了下libgdx里的输入事件监听

最初方案1

最初看的教程是,通过一个boolean变量来控制render渲染,这里我们以上文例子
Libgdx游戏开发(5)——碰撞反弹的简单实践-Stars-One的杂货小窝
代码为例,增加一个暂停功能

注:下面贴的是全部代码,发布各位自行运行,但后续代码例子只看
CircleBallTest
这个类,为了方便阅读其他类就不再贴出了...

import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.GL20
import com.badlogic.gdx.graphics.glutils.ShapeRenderer

class CircleBallTest : ApplicationAdapter() {
    lateinit var shape: ShapeRenderer

    val ball by lazy { Ball() }
    val line by lazy { MyBan() }

    override fun create() {
        shape = ShapeRenderer()
    }

    //增加一个变量标识
	var isPause = false
	
    override fun render() {
        
        //监听按下esc键,修改标识
        if (Gdx.input.isKeyPressed(Input.Keys.ESCAPE)) {
            isPause = !isPause
        }
        
        if (isPause) {
            //如果是暂停,则不再进行绘制
            return
        } 
        
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)

        line.control()
        ball.gundon()

        line.draw(shape)
        ball.draw(shape)

        ball.checkFz()
        //检测碰撞到数横条
        ball.checkLineP(line)

    }
}

class MyBan {
    var width = 200f
    var height = 10f

    var x = 0f
    var y = height

    fun draw(shape: ShapeRenderer) {
        shape.begin(ShapeRenderer.ShapeType.Filled)
        //这里注意: x,y是指矩形的左上角
        shape.rect(x, height, width, height)
        shape.end()
    }

    fun control() {
        if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
            x -= 200 * Gdx.graphics.deltaTime
        }

        if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
            x += 200 * Gdx.graphics.deltaTime
        }

        //这里屏蔽y坐标改变,只给控制左右移动
        return

        if (Gdx.input.isKeyPressed(Input.Keys.UP)) {
            y += 200 * Gdx.graphics.deltaTime
        }

        if (Gdx.input.isKeyPressed(Input.Keys.DOWN)) {
            y -= 200 * Gdx.graphics.deltaTime
        }
    }
}

class Ball {
    var size = 5f

    var x = 50f
    var y = 50f

    var speedX = 5f
    var speedY = 5f

    fun checkLineP(myBan: MyBan) {
        if (y - size <= myBan.y) {
            speedY = speedY * -1
        }
    }

    fun gundon() {
        x += speedX
        y += speedY
    }

    fun draw(shape: ShapeRenderer) {
        shape.begin(ShapeRenderer.ShapeType.Filled)
        shape.circle(x, y, size)
        shape.end()
    }

    fun checkFz() {
        //到达右边缘,x变反
        if (x + size >= Gdx.graphics.width) {
            speedX = speedX * -1
        }

        //到达下边缘,y变反
        //todo 这个是判输条件!
        if (y - size <= 0) {
            speedY = speedY * -1
        }

        //到达上边缘,y变反
        if (y + size >= Gdx.graphics.height) {
            speedY = speedY * -1
        }

        //到达左边缘,x变反
        if (x - size <= 0) {
            speedX = speedX * -1
        }
    }
}

效果:

可能上面的效果看得不明显,我已经按了3次esc,但是发现好像没暂停,什么原因导致的?

原因很简单,
render()
方法是每帧进行渲染的,所以导致我们的监听会执行多次,我们加个日志打印就能发现端倪,如下图:

按下了一次,但由于每帧都会渲染,所以触发了多次

优化方案2 - 事件拦截器监听按键

经过了一番百度和GPT询问,得知Libgdx里有一个输入事件的拦截器接口
InputProcessor

为了方便我们不必重写每个此接口的每个方法,我们可以使用
InputAdapter
(这个是
InputProcessor
接口的空实现类),之后重写需要的方法即可

我们以上面需求,实现监听esc键的监听,代码如下:

class InputP:InputAdapter(){
    //暂停标志
    var isPause = false

    override fun keyDown(keycode: Int): Boolean {
        if (keycode == Input.Keys.ESCAPE) {
            //按下esc按键则修改状态
            isPause=!isPause
        }
        
        //ps:如果想监听android上的返回键,则可以使用Input.Keys.BACK,不过得先调用
        return true
    }
}

接着通过
Gdx.input.inputProcessor
(我这里是kotlin写法,java的话则是个setinputProcessor*()方法)进行设置拦截事件的拦截

class CircleBallTest : ApplicationAdapter() {
    lateinit var shape: ShapeRenderer

    val ball by lazy { Ball() }
    val line by lazy { MyBan() }

    //懒加载创建拦截器对象
    val inputPro by lazy { InputP() }
    
    override fun create() {
        shape = ShapeRenderer()
        
        //开始之前就设置拦截器
        Gdx.input.inputProcessor = inputPro
    }


    override fun render() {
        //通过拦截器里的标志判断当前是否暂停绘制
        if (inputPro.isPause) {

        } else {
            Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
            line.draw(shape)
            ball.draw(shape)

            line.control()
            ball.gundon()
            ball.checkFz()

            //检测碰撞到数横条
            ball.checkLineP(line)
        }

    }
}

效果:

从动图效果来看,暂停功能是实现了,但是出现了闪动的问题

这里虽然具体原理不清楚,但是根据之前做过Android动态壁纸的研究,知道这种底层还是使用OpenGl绘制,所以直接猜测OpenGl渲染缓存中有上一帧数据,导致了此问题

优化方案3 - 暂停状态重绘

根据上面的原因,所以有下解决思路:

暂停状态下,重新绘制当前的UI,但不改变物体的x,y坐标

这里就提到了上章节说的,为什么要将绘制和坐标逻辑计算分开不同方法来写的原因了

如下代码(只贴核心代码):

override fun render() {
    if (inputPro.isPause) {
        //这里重新绘制
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
        line.draw(shape)
        ball.draw(shape)

        return
    }
    Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
    line.draw(shape)
    ball.draw(shape)

    line.control()
    ball.gundon()
    ball.checkFz()

    //检测碰撞到数横条
    ball.checkLineP(line)
}

效果如下图:

但个人感觉这种方案,在暂停了但仍然会不停的绘制,感觉有些性能浪费,于是有了下面的方案4

优化方案4

再回到问题上来,因为是上一帧和当前帧切换导致的问题,所以我们将上一帧和当前帧整成一样,绘制的时候就不会出现闪动的状态了吧,得到一个新的解决思路:

每次进入暂停状态后,绘制2遍帧数据,保证上一帧和当前帧相同,之后即可跳过绘制过程,由于前2帧一直是相同的,所以就不会出现抖动的效果,即则完成我们需要的效果和优化的效果

class InputP : InputAdapter() {
    var isPause = false

    override fun keyDown(keycode: Int): Boolean {
        if (keycode == Input.Keys.ESCAPE) {
            isPause = !isPause
        }
        //如果不是暂停状态,则重置
        if (isPause.not()) {
            count=0
        }
        return true
    }

    var count = 0

    fun handlePase(drawAction: () -> Unit) {
        //这里保证绘制完2帧
        if (count > 1) {
            return
        } 
        
        drawAction.invoke()
        count++
        
    }
}

class CircleBallTest : ApplicationAdapter() {
    lateinit var shape: ShapeRenderer

    val ball by lazy { Ball() }
    val line by lazy { MyBan() }

    override fun create() {
        shape = ShapeRenderer()
        Gdx.input.inputProcessor = inputPro
    }

    val inputPro by lazy { InputP() }


    override fun render() {
        if (inputPro.isPause) {
            inputPro.handlePase {
                draw()
            }

            return
        }

        draw()

        line.control()
        ball.gundon()
        ball.checkFz()

        //检测碰撞到数横条
        ball.checkLineP(line)
    }

    private fun draw() {
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
        line.draw(shape)
        ball.draw(shape)
    }
}

由于效果与上面相同,这里就不上图了

优化方案5

上面已经完成实现暂停功能了,现在我们在上面基础上个加个暂停文字提示(之前章节已经讲过如何绘制文字了),这里简单起见,我们直接显示pause单词

class CircleBallTest : ApplicationAdapter() {
    lateinit var shape: ShapeRenderer

    val ball by lazy { Ball() }
    val line by lazy { MyBan() }

    val batch: SpriteBatch by lazy { SpriteBatch() }
    val font: BitmapFont by lazy { BitmapFont() }
    
    override fun create() {
        shape = ShapeRenderer()
        Gdx.input.inputProcessor = inputPro
    }

    val inputPro by lazy { InputP() }


    override fun render() {
        if (inputPro.isPause) {
            inputPro.handlePase {
                draw()
                
                //绘制暂停提示
                Gdx.gl.glClearColor(0f, 0f, 0f, 0.8f); // 设置清屏颜色为透明度80%的黑色
                batch.begin()
                font.draw(batch, "Pause", 100f, 150f)
                batch.end()
            }

            return
        }

        draw()

        line.control()
        ball.gundon()
        ball.checkFz()

        //检测碰撞到数横条
        ball.checkLineP(line)
    }

    private fun draw() {
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
        line.draw(shape)
        ball.draw(shape)
    }
}

效果如下:

补充 - 监听android手机的返回键

如果想要监听android手机的返回键,则需要先设置
Gdx.input.setCatchKey(Input.Keys.BACK, true)
,之后和上述一样监听
keycode==Keys.BACK
即可实现,如下图代码示例

这里就不放演示动图了,实际测试效果按下返回键即可暂停,但好像分辨率没有兼容,导致小球特别小,后续优化的时候再研究了...

参考

一、监听器模式图

二、监听器三要素

  • 广播器:用来发布事件
  • 事件:需要被传播的消息
  • 监听器:一个对象对一个事件的发生做出反应,这个对象就是事件监听器

三、监听器的实现方式

1、实现自定义事件

自定义事件需要继承ApplicationEvent类,并添加一个构造函数,用于接收事件源对象。
该事件中添加了一个SysUser对象,用于传递用户信息。

package com.ruoyi.web.listener;

import com.ruoyi.common.core.domain.entity.SysUser;
import org.springframework.context.ApplicationEvent;

/**
 * @Description: 自定义事件
 * @Author: baiwen
 * @createTime: 2024年06月19日 13:10:07
 */
public class MyEvent extends ApplicationEvent {

    private SysUser sysUser;

    public MyEvent(Object source, SysUser sysUser) {
        super(source);
        this.sysUser = sysUser;
    }

    public SysUser getSysUser() {
        return sysUser;
    }
}

2、实现自定义监听器

自定义监听器需要实现ApplicationListener接口,并重写 onApplicationEvent方法。
接口中的泛型参数为自定义事件类型,表示监听该类型的事件。
可以从该事件中获取用户信息,并进行相应的处理。

package com.ruoyi.web.listener;

import com.ruoyi.common.core.domain.entity.SysUser;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

/**
 * @Description: 自定义监听器
 * @Author: baiwen
 * @createTime: 2024年06月19日 13:12:39
 */
@Component
public class MyEventListener implements ApplicationListener<MyEvent> {
    @Override
    public void onApplicationEvent(MyEvent event) {
        SysUser sysUser = event.getSysUser();
        System.out.println("监听到了事件,用户名:" + sysUser.getUserName());
    }
}

3、发布自定义事件

在需要发布事件的地方,使用ApplicationEventPublisher的publishEvent方法来发布事件。
这里使用Test类来模拟事件发布,实际应用中可以根据具体需求来选择合适的发布场景。

package com.ruoyi.test;

import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.web.listener.MyEvent;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.context.junit4.SpringRunner;
import javax.annotation.Resource;

/**
 * @Description:
 * @Author: baiwen
 * @createTime: 2024年06月19日 13:16:33
 */
@SpringBootTest
@RunWith(SpringRunner.class)
public class MyEventPushTest {

    @Resource
    private ApplicationEventPublisher applicationEventPublisher;

    @Test
    public void testpublishEvent() throws InterruptedException
    {
        SysUser sysUser = new SysUser();
        sysUser.setUserName("zhangsan");

        System.out.println("发布MyEvent事件。。。");
        applicationEventPublisher.publishEvent(new MyEvent(this, sysUser));
    }
}

4、测试

运行MyEventPushTest类中的testpublishEvent方法,控制台会输出以下内容:

发布MyEvent事件。。。
监听到了事件,用户名:zhangsan

5、其他实现方案

主要是监听器的注册方式不同,目的只有一个,把监听器加入到spring容器中。

方式一
,就是上面的MyEventListener类是通过@Component注解将该类注册为Spring的Bean,从而实现监听器的功能。

方式二
,可以通过在启动类中添加监听器的方式,使监听器生效。

package com.ruoyi;

import com.ruoyi.web.listener.MyEventListener;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;

/**
 * 启动程序
 * 
 * @author baiwen
 */
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
public class RuoYiApplication
{
    public static void main(String[] args)
    {
        new SpringApplicationBuilder(RuoYiApplication.class).listeners(new MyEventListener()).run(args);
    }
}

方式三
,可以通过配置spring.factories,使监听器生效。

在resource文件夹下创建META-INF/spring.factories文件。

配置内容如下:

# 监听器
org.springframework.context.ApplicationListener=com.ruoyi.web.listener.MyEventListener

除此之外,还有第四种方式
,通过@EventListener注解实现监听器的功能。
通过@EventListener注解的condition属性来指定监听的事件类型。

package com.ruoyi.web.listener;

import com.ruoyi.common.core.domain.entity.SysUser;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

/**
 * @Description: 自定义监听器2
 * @Author: baiwen
 * @createTime: 2024年06月19日 14:07:57
 */
@Component
public class MyEventListener2 {

    @EventListener(MyEvent.class)
    public void listenerApplicationStarted(MyEvent event) {
        SysUser sysUser = event.getSysUser();
        System.out.println("注解方式监听到了事件,用户名:" + sysUser.getUserName());
    }
}

发布事件后,可以看到能正常监听到事件。

发布MyEvent事件。。。
注解方式监听到了事件,用户名:zhangsan

总结

以上,就是SpringBoot中实现监听器的四种方式。

至于监听器的实现原理,后续再补充。

在性能测试过程中总会收到一些需求如:单接口每秒并发20,这种并发持续60秒,通过负载测试查看系统稳定性,今天就让我们来浅谈一下这种场景如何去实现性能测试~

这种场景可以用两种方法去实现:

一、我们通过控制总线程数和Ramp-Up来控制并发,让它的线程慢慢增压,因为只有一个接口,所以执行时是偏快的

我们把线程数设置为20*60=1200,Ramp-Up设置为60,循环次数为1

这样设置我们来看下聚合报告,也是符合我们的需求,但没有那么准确的确保每秒都并发20,这种概率偏随机

可以发现它的Transactions per Second图中的事务数是这种忽高忽低,所以可以和第二种放发一起选择,选择适合的方法

二、第二种就要用到这篇文章的主角了Constant Throughput Timer(常数吞吐量定时器)

首先将线程组设置为20,循环次数60大道每秒并发20持续60秒的结果

其次就用到Constant Throughput Timer(常数吞吐量定时器)了,我们设置目标吞吐量(每分钟的样本量)为20*60=1200,注意这里的单位是分钟

那基于计算吞吐量怎么选择呢?在这种情况里我们选择了所有活动线程,但不同种情况可以选择不同的用法

只有此线程(This thread only)

  • 解释:当选择这个选项时,吞吐量目标仅针对当前设置了定时器的线程。这意味着设置的目标吞吐量(Target Throughput)将完全由这个线程来实现。

  • 用法:如果你想要单独测试某个线程的性能,并确保其达到特定的吞吐量目标,可以选择这个选项。例如,如果你有一个线程负责处理特定的业务逻辑,并希望它能够达到每秒10个请求(RPS)的吞吐量,你可以在该线程上添加Constant Throughput Timer并设置Target Throughput为600(因为一分钟有60秒,所以10 RPS = 600 RPS/分钟)。

所有活动线程(All active threads)

  • 解释:这个选项将吞吐量目标分配到当前所有活动的线程上。这意味着所有当前正在运行的线程都将共同分担实现总吞吐量目标的责任。

  • 用法:如果你有一个包含多个线程的测试计划,并且希望所有线程共同工作以达到某个总的吞吐量目标,可以选择这个选项。但是,需要注意的是,由于线程的执行可能不是完全同步的,因此每个线程实际达到的吞吐量可能会有所不同。

当前线程组中的所有活动线程(All active threads in current thread group)

  • 解释:这个选项与“所有活动线程”类似,但它仅针对当前线程组中的线程。如果你在一个测试计划中有多个线程组,这个选项将确保只有当前线程组中的线程参与实现吞吐量目标。

  • 用法:如果你想要隔离并测试某个线程组的性能,可以选择这个选项。这样,你可以确保只有该线程组中的线程被考虑在内,而不会受到其他线程组的影响。

所有活动线程(共享)(All active threads (shared))

  • 解释:这个选项类似于“所有活动线程”,但它具有一个额外的特性:每个活跃线程都会在所有活跃线程上一次运行结束后等待合理的时间后再次运行。这有助于确保所有线程都能够均匀地分担吞吐量目标的责任。

  • 用法:如果你想要确保所有线程都能够以相对均匀的方式分担吞吐量目标的责任,可以选择这个选项。这有助于避免某些线程过于繁忙而其他线程则相对空闲的情况。

归纳:

  • 单独测试:如果你想要单独测试某个线程或线程组的性能,可以选择“只有此线程”或“当前线程组中的所有活动线程”。
  • 协同测试:如果你想要多个线程或线程组共同工作以达到某个总的吞吐量目标,可以选择“所有活动线程”或“所有活动线程(共享)”。

最后我们看下执行的集合报告和TPS图,相对于第一种方法每秒的事务数更加稳定,可以精确的将每秒的并发控制在20来控制系统的稳定性了~

在Java中,将多个音频文件拼接成一个通常需要使用一些专门的音频处理库,因为Java标准库并不直接支持音频文件的合并。一个常用的库是
JAVE2
(Java Audio Video Encoder)或
JLayer
(用于MP3)结合
JavaFX
(如果用于简单的WAV文件)或其他类似的库。

不过,由于
JAVE2

JavaFX
可能不是最新的或者不是每个项目都适用的,我将给出一个基于
JLayer
(用于MP3)和
TarsosDSP
(一个音频处理库)的简化示例,但请注意,这个示例可能需要根据您的具体需求进行调整。

1. 引入依赖

首先,您需要在项目中引入相关的依赖。对于Maven项目,可以在
pom.xml
中添加如下依赖(注意:这些可能是旧版本,请检查是否有更新版本):

<dependencies>  
    <!-- MP3处理库 -->  
    <dependency>  
        <groupId>javazoom</groupId>  
        <artifactId>jlayer</artifactId>  
        <version>1.0.1</version>  
    </dependency>  
    <!-- 音频处理库 -->  
    <dependency>  
        <groupId>be.tarsos.dsp</groupId>  
        <artifactId>TarsosDSP</artifactId>  
        <version>YOUR_VERSION</version>  
    </dependency>  
    <!-- 其他可能需要的库,如文件操作等 -->  
</dependencies>

注意:TarsosDSP可能不包含直接的文件合并功能,但可以用于处理音频数据。对于文件合并,您可能需要自己实现或使用其他库。

2. 合并音频文件

由于
JLayer

TarsosDSP
主要关注音频数据的解码和处理,而不是直接的文件合并,因此实现文件合并可能需要一些额外的工作。但基本思路是:

(1)使用
JLayer
解码每个MP3文件到PCM数据。

(2)将这些PCM数据连接起来。

(3)使用音频编码库(如LAME MP3编码器或类似的Java库)将合并后的PCM数据编码回MP3文件。

由于编码回MP3文件的部分可能比较复杂且需要额外的库,这里只给出解码和合并PCM数据的伪代码示例:

import javazoom.jl.decoder.Bitstream;  
import javazoom.jl.decoder.Decoder;  
import javazoom.jl.decoder.Header;  
import javazoom.jl.decoder.SampleBuffer;  
  
// ... 其他必要的导入 ...  
  
public class AudioMerger {  
  
    public void mergeAudioFiles(List<File> inputFiles, File outputFile) throws IOException {  
        // 这里假设我们有一个方法来处理PCM数据的合并和编码回MP3  
        byte[] mergedPcmData = mergePcmData(inputFiles);  
          
        // 编码回MP3的代码(这里省略,因为需要额外的库)  
        // encodePcmToMp3(mergedPcmData, outputFile);  
    }  
  
    private byte[] mergePcmData(List<File> inputFiles) throws IOException {  
        // 初始化合并的PCM数据(这里只是伪代码)  
        ByteArrayOutputStream mergedData = new ByteArrayOutputStream();  
          
        for (File file : inputFiles) {  
            Bitstream bitstream = new Bitstream(new FileInputStream(file));  
            Decoder decoder = new Decoder();  
              
            Header frameHeader = null;  
            try {  
                while ((frameHeader = bitstream.readFrame()) != null) {  
                    SampleBuffer output = (SampleBuffer) decoder.decodeFrame(frameHeader, bitstream);  
                    // 将output中的数据追加到mergedData中(这里省略具体实现)  
                }  
            } finally {  
                if (bitstream != null) bitstream.close();  
            }  
        }  
          
        // 返回合并后的PCM数据(这里只是一个示例,实际上您可能需要处理采样率、声道数等)  
        return mergedData.toByteArray();  
    }  
  
    // ... 其他必要的代码 ...  
}

注意
:上面的代码只是一个框架和思路的示例,并不是完整且可运行的代码。特别是
mergePcmData
方法中的PCM数据合并部分和编码回MP3的部分需要您自己实现或找到合适的库来完成。另外,还需要处理不同的采样率、声道数等音频参数以确保合并后的音频质量。

3.完整的代码示例

由于直接提供一个完整且详细的Java代码示例来合并多个MP3文件可能相对复杂,并且需要依赖多个库来处理音频编解码和文件I/O,这里我将提供一个简化的概念性示例,并使用Java的
javax.sound.sampled
库来处理WAV文件(因为WAV格式相对简单,不需要额外的解码库)。但请注意,
javax.sound.sampled
库不直接支持MP3编解码。

对于MP3文件的合并,您可能需要使用如
LAME MP3 Encoder
的Java绑定或
JAVE2
等库,但由于这些库可能不是最新的,或者它们的使用可能超出了简单示例的范围,这里将不涵盖它们。

以下是使用
javax.sound.sampled
库合并多个WAV文件的Java代码示例:

import javax.sound.sampled.*;  
import java.io.*;  
  
public class WavMerger {  
  
    public static void main(String[] args) {  
        // 假设我们有两个WAV文件要合并  
        File wavFile1 = new File("input1.wav");  
        File wavFile2 = new File("input2.wav");  
        File outputFile = new File("merged.wav");  
  
        try {  
            mergeWavFiles(new File[]{wavFile1, wavFile2}, outputFile);  
            System.out.println("WAV files merged successfully!");  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
  
    public static void mergeWavFiles(File[] wavFiles, File outputFile) throws UnsupportedAudioFileException, IOException, LineUnavailableException {  
        AudioInputStream[] audioStreams = new AudioInputStream[wavFiles.length];  
  
        // 读取所有WAV文件到AudioInputStream  
        for (int i = 0; i < wavFiles.length; i++) {  
            audioStreams[i] = AudioSystem.getAudioInputStream(wavFiles[i]);  
        }  
  
        // 验证所有文件的音频格式是否相同  
        AudioFormat targetFormat = audioStreams[0].getFormat();  
        for (int i = 1; i < audioStreams.length; i++) {  
            if (!audioStreams[i].getFormat().equals(targetFormat)) {  
                throw new IllegalArgumentException("All input files must have the same format.");  
            }  
        }  
  
        // 创建一个SequenceInputStream来合并所有的AudioInputStream  
        SequenceInputStream mergedStream = new SequenceInputStream(new Enumeration<AudioInputStream>() {  
            int index = 0;  
  
            @Override  
            public boolean hasMoreElements() {  
                return index < audioStreams.length;  
            }  
  
            @Override  
            public AudioInputStream nextElement() {  
                if (index >= audioStreams.length) {  
                    throw new NoSuchElementException();  
                }  
                return audioStreams[index++];  
            }  
        });  
  
        // 写入合并后的音频到文件  
        try (AudioSystem.write(mergedStream, AudioFileFormat.Type.WAVE, outputFile)) {  
            // 写入操作在try-with-resources块中自动完成  
        }  
  
        // 关闭所有的AudioInputStream  
        for (AudioInputStream stream : audioStreams) {  
            stream.close();  
        }  
    }  
}

注意

(1)这个示例仅适用于WAV文件,并且假设所有WAV文件具有相同的音频格式(采样率、位深度、通道数等)。

(2)如果要合并MP3文件,您将需要使用额外的库来解码MP3到PCM,然后再使用类似的逻辑合并PCM数据,并使用MP3编码器将合并后的PCM数据编码回MP3格式。

(3)在实际项目中,请确保处理所有可能的异常,并优雅地关闭资源。

(4)由于音频处理可能涉及大量的数据,因此在处理大型文件或大量文件时,请考虑内存管理和性能优化。

最新版的 Visual Studio Code 对 Markdown 的支持已显著提升,其在预览方面的体验甚至可以与 Markdown Preview Enhanced 插件相比。本文将介绍一些优化方法,帮助用户提升在 VSCode 中编写 Markdown 文档的体验。

官方使用说明:
https://code.visualstudio.com/docs/languages/markdown

预览常规设置

打开 VSCode 的
markdown.preview
设置,可以看到一些设置选项,如字体、字号、行高等。

使用 CSS

VSCode中Markdown 预览实际上是一个网页,因此可以通过自定义 CSS 来优化其显示效果。

打开
markdown.styles
设置,可以添加 CSS 文件路径。支持两种文件路径:

  1. 相对路径。相对路径是相对于资源管理器中打开的文件夹进行解释的。如果没有打开的文件夹,则相对于 Markdown 文件的位置解释它们。
  2. CSS 文件的 HTTPS URL。

数学公式预览优化

打开 vscode 自带的开发者工具,可以看到所有数学公式都有
class="katex"

目前存在的问题是数学公式太小,于是可以在工作区本地创建 CSS 文件,内容为:

.katex {
    font-size: 1.25em !important;
}

然后将该文件路径添加到
markdown.styles
设置中,即可优化公式显示效果。

我也创建了该 CSS 文件的 HTTPS URL:
https://blog-static.cnblogs.com/files/blogs/825243/vscode-markdown-style.css

图片大小调整

一种方案是使用 HTML 标签:

<img src="assets/xxx.png" style="width:80%;" />

另一种方案是通过 CSS 设置:

img{
    width: 80%;
    padding-left: 10%;
}

这段代码将所有图片的宽度设置为段落宽度的 80%,并向右移动 10%,从而实现居中显示。基于此,可以进一步细化设置,如下所示:

img[src*="#w100"] {
width: 100%;
}

img[src*="#w80"] {
width: 80%;
}

img[src*="#w60"] {
width: 60%;
}

img[src*="#w50"] {
width: 50%;
}

img[src*="#w30"] {
width: 30%;
}

img[src*="#w20"] {
width: 20%;
}

img[src*="#w10"] {
width: 10%;
}

此时,可以通过在 Markdown 中使用特定格式来调整图片大小:

![img.png](assets/xxx.png#w60)

编辑器体验优化

为了优化在 VSCode 中编写 Markdown 的体验,在用户
settings.json
中使用如下配置:

"[markdown]": {
    "editor.minimap.enabled": false, // 关闭编辑器右侧的小地图以减少视觉干扰
    "editor.glyphMargin": false, // 关闭字形边距以获得更简洁的编辑界面
    "editor.renderWhitespace": "all" // 显示所有空白字符,方便查看和编辑空格、制表符等
}

图片粘贴

VSCode 的
Paste Image
插件和内置的 copyFiles 功能可以简化图片粘贴操作。

Paste Image
插件设置如下。此时使用
Ctrl+Shift+V
可以把剪切板上的图片保存到工作区的
assets
文件夹中,并在 md 文件中插入图片路径。

VSCode 的
markdown.copyFiles
设置如下。

此时使用
Ctrl+V
快捷键也可以保存剪切板上的图片到工作区的
assets
文件中,并在 md 文件中插入路径。区别在于:

  1. VSCode 内置的 copyFiles 功能还可以粘贴图片和音频文件。
  2. Paste Image
    插件保存的图片可以自动生成
    Y-MM-DD-HH-mm-ss
    的文件名,而 copyFiles 功能则不行。

因此建议这两种方法配合使用。

格式化

markdownlint
插件提供了格式化 Markdown 代码功能,并会对不规范的 markdown 代码进行警告。建议在用户
settings.json
中使用如下配置

"[markdown]": {
    "editor.defaultFormatter": "DavidAnson.vscode-markdownlint",
    "editor.formatOnSave": true,
},
"markdownlint.config": {
    "MD012": false,
    "MD018": false,
    "MD024": false,
    "MD025": false,
    "MD033": false,
    "MD036": false,
    "MD041": false,
    "MD045": false,
},

Pangu-Markdown
插件补充了格式化功能,如在中英字符之间插入空格。使用方法为右键点击 Pangu Format。

代码补全与快捷功能

Markdown All in One
插件可以补全 markdown 代码,包括 LaTeX 函数代码。同时也提供了一些编辑 Markdown 的快捷功能。

Better Markdown & Latex Shortcuts
插件提供了一些编辑 LaTeX 公式的快捷键。

功能拓展

Markdown Footnotes
插件让 VSCode 的 markdown 预览支持脚注功能,例如:

这是一个脚注 [^1]

[^1]: 脚注 1

Markdown Image Size
插件提供了调整图片大小的拓展语法。但是该语法应用并不普遍,因此不推荐使用。

导出 PDF 和 Word 文档

Markdown Preview Enhanced
插件

支持导出为 PDF 和 Word 文档。其利用 Chrome (Puppeteer) 导出 PDF 文件使用说明为
https://www.cnblogs.com/cjyyx/p/18272365

Markdown Preview Enhanced
插件导出的 PDF 显示效果并不好,因此我更推荐使用 Typora 软件导出。

博客写作

博客园 cnblogs 客户端

Zhihu On VSCode
插件都提供了比较好的博客写作体验。

Markdown Publisher For CSDN/JIANSHU/ZHIHU/JUEJIN/WECH
插件

支持 Markdown 文档多平台一键发布,但目前使用体验较差。