简体   繁体   English

如何使用 JavaFx AnimationTimer 制作基于时间的平滑 animation?

[英]How to make a smooth time-based animation with JavaFx AnimationTimer?

I'm making a very simple animation using JavaFX.我正在使用 JavaFX 制作一个非常简单的 animation。 My goal here is just to have a rectangle move smoothly across the window.我的目标是让一个矩形平滑地穿过 window。

I'm trying to achieve this using AnimationTimer , which seems fit for the task.我正在尝试使用AnimationTimer来实现这一点,这似乎适合该任务。 I've tried different ways of rendering, such as a Rectangle in an AnchorPane , or simply drawing onto a Canvas , but in the end it always boils down to the same thing, with the same results.我尝试了不同的渲染方式,例如AnchorPane中的Rectangle ,或者简单地绘制到Canvas上,但最终它总是归结为相同的东西,结果相同。

I basically store the position of my rectangle, and apply a moving rate to it at each frame.我基本上存储了我的矩形的 position,并在每一帧对其应用移动速率。

In fact, when I use a constant moving rate in the handle method of my AnimationTimer , animation is perfectly smooth.事实上,当我在AnimationTimerhandle方法中使用恒定移动速率时,animation 非常平滑。 However, there are 2 problems with this technique:但是,这种技术存在两个问题:

  1. Frame rate seems to be platform-dependent, with no easy way to control it.帧速率似乎与平台有关,没有简单的方法来控制它。 So the animation would render differently on different machines.所以 animation 在不同的机器上会有不同的渲染。
  2. Frame rate sometimes varies, for instance when resizing the window, it can sometimes drop by half, or sometimes even double up, which changes the animation speed accordingly.帧速率有时会发生变化,例如在调整 window 的大小时,它有时会下降一半,有时甚至会加倍,这会相应地改变 animation 的速度。

So I tried to make the animation time-based, by using the argument of AnimationTimer.handle(long now) .因此,我尝试使用AnimationTimer.handle(long now)的参数使 animation 基于时间。 It solved the inconsistency issues, but the animation is now jittery, A few times per second.它解决了不一致的问题,但 animation 现在很紧张,每秒几次。 the rectangle seems to "jump" a few pixels ahead and then stall for a frame or two to recover it's expected position.矩形似乎向前“跳跃”了几个像素,然后停顿一两帧以恢复预期的 position。 It becomes more and more obvious as I increase the speed.随着速度的提高,它变得越来越明显。

Here's the relevant piece of code (simplified):这是相关的代码(简化):

AnimationTimer anim = new AnimationTimer() {
  private long lastRun = 0;
  
  @Override
  public void handle(long now) {
    //Ignore first frame as I'm not sure of the timing here
    if (lastRun == 0) {
      lastRun = now;
      return;
    }
    //Now we've got a reference, so let's animate
    double elapsed = (now - lastRun) / 1e9; //Convert to seconds
    //Update position according to speed
    position = position.add(speed.multiply(elapsed)); //Apply speed in pixels/second
    lastRun = now; //Store current time for next loop
    draw();
  }
};

I've tried to log time differences, frame rate and position.我尝试记录时差、帧速率和 position。 Tried a few different fixes, making my code always more complex but with no result whatsoever.尝试了一些不同的修复,使我的代码总是更复杂但没有任何结果。

Edit 2022-03-15 following your comments (thanks)根据您的评论编辑 2022-03-15(谢谢)

I've tried this on my usual computer (Win 10, Xeon processor, 2 Geforce 1050Ti GPUs), and also on a Microsoft Surface Go 3 tablet under Windows 11. I've tried it using Java 17.0.1 (Temurin) and JavaFX 17.0.1, as well as JDK 8u211 with the same results. I've tried this on my usual computer (Win 10, Xeon processor, 2 Geforce 1050Ti GPUs), and also on a Microsoft Surface Go 3 tablet under Windows 11. I've tried it using Java 17.0.1 (Temurin) and JavaFX 17.0.1,以及具有相同结果的 JDK 8u211。

Using JVM argument -Djavafx.animation.pulse=10 has no effect whatsoever other than showing "Setting PULSE_DURATION to 10 hz" in stderr.使用 JVM 参数-Djavafx.animation.pulse=10除了在标准错误中显示“将 PULSE_DURATION 设置为 10 hz”之外没有任何效果。 -Djavafx.animation.framerate=10 doesn't do a thing. -Djavafx.animation.framerate=10没有任何作用。

End of edit编辑结束

I can't figure out what I'm doing wrong here.我无法弄清楚我在这里做错了什么。 Can you please help me out?你能帮帮我吗?

Here's my entire code: (Edited on 2022-03-15 to include FPS-meter)这是我的完整代码:(于 2022-03-15 编辑以包括 FPS-meter)

import java.math.BigDecimal;
import java.math.RoundingMode;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.geometry.VPos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.BorderPane;
import javafx.scene.paint.Paint;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import javafx.stage.Stage;

public class TestFxCanvas2 extends Application {

  // Set your panel size here
  private static final int FRAME_WIDTH = 800;
  private static final int FRAME_HEIGHT = 800;

  public static void main(String[] args) {
    launch(args);
  }

  @Override
  public void start(Stage stage) throws Exception {
    BorderPane root = new BorderPane();
    MyAnimation2 myAnimation = new MyAnimation2();
    myAnimation.widthProperty().bind(root.widthProperty());
    myAnimation.heightProperty().bind(root.heightProperty());
    root.setCenter(myAnimation);
    Scene scene = new Scene(root);
    stage.setScene(scene);
    stage.setWidth(FRAME_WIDTH);
    stage.setHeight(FRAME_HEIGHT);

    stage.show();
  }
}

class MyAnimation2 extends Canvas {

  private static final double SPEED = 500; // Speed value to be applied in either direction in px/s
  private static final Point2D RECT_DIMS = new Point2D(50, 50); // rectangle size
  // Canvas painting colors
  private static final Paint BLACK = Paint.valueOf("black");
  private static final Paint RED = Paint.valueOf("red");
  private static final Paint GREEN = Paint.valueOf("ForestGreen");

  // Defines rectangle start position
  private Point2D recPos = new Point2D(0, 300);
  // Stores previous position
  private Point2D oldPos = new Point2D(0, 0);

  // Current speed
  private Point2D speed = new Point2D(SPEED, 0);

  public MyAnimation2() {

    AnimationTimer anim = new AnimationTimer() {
      private long lastRun = 0;

      long[] frameTimes = new long[10];
      long frameCount = 0;

      @Override
      public void handle(long now) {
        // Measure FPS
        BigDecimal fps = null;
        int frameIndex = (int) (frameCount % frameTimes.length);
        frameTimes[frameIndex] = now;
        if (frameCount > frameTimes.length) {
          int prev = (int) ((frameCount + 1) % frameTimes.length);
          long delta = now - frameTimes[prev];
          double fr = 1e9 / (delta / frameTimes.length);
          fps = new BigDecimal(fr).setScale(2, RoundingMode.HALF_UP);
        }
        frameCount++;
        // Skip first frame but record its timing
        if (lastRun == 0) {
          lastRun = now;
          return;
        }
        // Animate
        double elapsed = (now - lastRun) / 1e9;
        // Reverse when hitting borders
        if (hitsBorders())
          speed = speed.multiply(-1.);
        // Update position according to speed
        oldPos = recPos;
        recPos = recPos.add(speed.multiply(elapsed));
        lastRun = now;
        draw(oldPos, recPos, fps);
      }
    };

    // Start
    anim.start();
  }

  private void draw(Point2D oldPos, Point2D recPos, BigDecimal fps) {
    GraphicsContext gfx = this.getGraphicsContext2D();
    // Clear and draw border
    gfx.setStroke(BLACK);
    gfx.setLineWidth(1);
    gfx.clearRect(0, 0, getWidth(), getHeight());
    gfx.strokeRect(0, 0, getWidth(), getHeight());
    // Draw moving shape
    gfx.setFill(RED);
    gfx.fillRect(recPos.getX(), recPos.getY(), RECT_DIMS.getX(), RECT_DIMS.getY());
    // Draw FPS meter
    String fpsText = fps == null ? "FPS" : fps.toString();
    gfx.setTextAlign(TextAlignment.RIGHT);
    gfx.setFill(GREEN);
    gfx.setFont(Font.font(24));
    gfx.setTextBaseline(VPos.TOP);
    gfx.fillText(fpsText, getWidth() - 5, 5);
  }

  private boolean hitsBorders() {
    Rectangle2D frame = new Rectangle2D(0, 0, getWidth(), getHeight());
    Rectangle2D rect = new Rectangle2D(recPos.getX(), recPos.getY(), RECT_DIMS.getX(), RECT_DIMS.getY());
    if (speed.getX() < 0 && rect.getMinX() < frame.getMinX())
      return true;
    else if (speed.getX() > 0 && rect.getMaxX() > frame.getMaxX())
      return true;
    else if (speed.getY() < 0 && rect.getMinY() < frame.getMinY())
      return true;
    else if (speed.getY() > 0 && rect.getMaxY() > frame.getMaxY())
      return true;
    return false;
  }
}

Addition after testing same program in JavaScript在 JavaScript 中测试相同程序后添加

This JavaScript version runs smoothly on my devices这个 JavaScript 版本在我的设备上运行流畅

See a video comparing both versions (JavaScript at the top, JavaFX at the bottom) here: https://www.ahpc-services.com/fileshare/JFX_test_pulses/20220315_150431_edit1.mp4在此处查看比较两个版本的视频(顶部的 JavaScript,底部的 JavaFX): https://www.ahpc-services.com/fileshare/JFX_test_pulses/20220315_150431_edit1.mp4

 const RED = 'red'; const GREEN = 'ForestGreen'; const BLACK = 'black'; window.addEventListener('DOMContentLoaded', e => { const animArea = document.querySelector('#anim-area'); const ctx = animArea.getContext('2d'); const cWidth = animArea.clientWidth; const cHeight = animArea.clientHeight; adjustCanvasSize(); window.addEventListener('resize', adjustCanvasSize); const rect = { x: 0, y: 50, width: 50, height: 50 } const speed = { x: 500, y: 0 } const frameTiming = { frameCount: 0, frameTimes: Array(10), lastRun: 0, } requestAnimationFrame(animate); function animate() { const now = Date.now(); requestAnimationFrame(animate); //Count FPS let fps; const frameIndex = frameTiming.frameCount % frameTiming.frameTimes.length; frameTiming.frameTimes[frameIndex] = now; if (frameTiming.frameCount > frameTiming.frameTimes.length) { const prev = (frameTiming.frameCount + 1) % frameTiming.frameTimes.length; const delta = now - frameTiming.frameTimes[prev]; fps = Math.round(100 * 1000 * frameTiming.frameTimes.length / delta) / 100; } frameTiming.frameCount++; //Ignore first frame if (frameTiming.lastRun == 0) { frameTiming.lastRun = now; return; } //Animate const elapsed = (now - frameTiming.lastRun) / 1e3; // Reverse when hitting borders if (hitsBorders()) { speed.x *= -1; speed.y *= -1; } // Update position according to speed const oldRect = Object.assign({}, rect); rect.x += speed.x * elapsed; rect.y += speed.y * elapsed; frameTiming.lastRun = now; draw(); function draw() { // Clear and draw border ctx.clearRect(0, 0, animArea.width, animArea.height); ctx.strokeStyle = BLACK; ctx.strokeRect(0, 0, animArea.width, animArea.height); // Draw moving shape ctx.fillStyle = RED; ctx.fillRect(rect.x, rect.y, rect.width, rect.height); // Draw FPS meter const fpsText = fps == undefined? "FPS": `${fps}`; ctx.textAlign = 'right'; ctx.fillStyle = GREEN; ctx.font = "24px sans-serif"; ctx.textBaseline = 'top'; ctx.fillText(fpsText, animArea.width - 5, 5); } function hitsBorders() { if (speed.x < 0 && rect.x < 0) return true; else if (speed.x > 0 && rect.x + rect.width > animArea.width) return true; else if (speed.y < 0 && rect.y < 0) return true; else if (speed.y > 0 && rect.y + rect.height > animArea.height) return true; return false; } } function adjustCanvasSize() { if (window.innerWidth < cWidth + 30) animArea.style.width = (window.innerWidth - 30) + "px"; else animArea.style.width = ""; if (window.innerHeight < cHeight + 30) animArea.style.height = (window.innerHeight - 30) + "px"; else animArea.style.height = ""; animArea.width = animArea.clientWidth; animArea.height = animArea.clientHeight; } });
 html, body { margin: 0; padding: 0; } #anim-area { width: 800px; height: 800px; }
 <,DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width. initial-scale=1.0"> <title>Moving square</title> </head> <body> <div class="content-wrapper"> <canvas id="anim-area"></canvas> </div> </body> </html>

I figured this out myself.我自己想通了。 As it turns out, JavaFX takes no account of the actual refresh rate of the display.事实证明,JavaFX 没有考虑显示器的实际刷新率。 It calls AnimationTimer.handle with an average rate of about 67Hz (though varying quite widely), while the typical monitor refreshes at around 60Hz.它以大约 67Hz 的平均速率调用AnimationTimer.handle (尽管变化很大),而典型的监视器刷新频率约为 60Hz。

This causes some frames to be rendered with a delay (the call being quite offset from the screen display frame), and some frames to be reported with a wide variety of lengths whereas the screen will actually display them at a contant rate, thus the inconsistent movement I observed.这会导致一些帧被延迟渲染(调用与屏幕显示帧有很大的偏移),并且一些帧以各种长度报告,而屏幕实际上会以恒定速率显示它们,因此不一致我观察到的运动。

I can compensate for that by measuring the screen's refresh rate, and calculating my rectangle's moving rate based on the next frame to be displayed (I won't know the exact timing, but a constant offset will be OK).我可以通过测量屏幕的刷新率并根据要显示的下一帧计算我的矩形的移动率来补偿这一点(我不知道确切的时间,但恒定的偏移量就可以了)。

So here are the code parts:所以这里是代码部分:

1. Get screen refresh rate 1.获取屏幕刷新率

stage.setOnShown(e -> {
  Screen screen = Screen.getScreensForRectangle(stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight())
      .get(0);
  GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
  GraphicsDevice d = ge.getScreenDevices()[Screen.getScreens().indexOf(screen)];

  int r = d.getDisplayMode().getRefreshRate();
  System.out.println("Screen refresh rate : " + r);
  //Calculate frame duration in nanoseconds
  this.frameNs = 1_000_000_000L / refreshRate; //Store it as it better suits you
});

Note that this method gives an int for refresh rate, whereas screen refresh rates are often not exactly integers (mine is currently 60.008 Hz).请注意,此方法给出了一个int表示刷新率,而屏幕刷新率通常不是整数(我的当前是 60.008 Hz)。 But this seems like a good enough approximation, judging by the results但这似乎是一个足够好的近似值,从结果来看

Also it's relying on awt where I'd rather have a pure JavaFX solution, and I'm assuming that screens are reported in the same order by both systems which is far from guaranteed: So use this with caution in production!它还依赖于我宁愿拥有纯 JavaFX 解决方案的awt ,并且我假设两个系统以相同的顺序报告屏幕,这远不能保证:所以在生产中谨慎使用它!

2. Alter the animation loop to account for this refresh rate 2. 更改 animation 循环以考虑此刷新率

AnimationTimer anim = new AnimationTimer() {
  private long lastRun = 0;

  @Override
  public void handle(long now) {
    // Skip first frame but record its timing
    if (lastRun == 0) {
      lastRun = now;
      return;
    }
    // If we had 2 JFX frames for 1 screen frame, save a cycle
    if (now <= lastRun)
      return;
    // Calculate remaining time until next screen frame (next multiple of frameNs)
    long rest = now % frameNs;
    long nextFrame = now;
    if (rest != 0) //Fix timing to next screen frame
      nextFrame += frameNs - rest;
    // Animate
    double elapsed = (nextFrame - lastRun) / 1e9;
    // Reverse when hitting borders
    if (hitsBorders())
      speed = speed.multiply(-1.);
    // Update position according to speed
    oldPos = recPos;
    recPos = recPos.add(speed.multiply(elapsed));
    log.println(String.format("%d\t: %d", frameCount, (now - lastRun) / 1_000_000));
    lastRun = nextFrame;
    draw();
  }
};

And with these alterations, the animation runs smoothly通过这些改动,animation 运行顺畅

Full code (improved a bit)完整代码(改进了一点)

import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.geometry.VPos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.BorderPane;
import javafx.scene.paint.Paint;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;

public class TestFxAnimationCanvas extends Application {

  // Set your panel size here
  private static final int FRAME_WIDTH = 1024;
  private static final int FRAME_HEIGHT = 480;

  public static void main(String[] args) {
    launch(args);
  }

  @Override
  public void start(Stage stage) throws Exception {
    BorderPane root = new BorderPane();
    SmootherAnimation myAnimation = new SmootherAnimation();
    myAnimation.widthProperty().bind(root.widthProperty());
    myAnimation.heightProperty().bind(root.heightProperty());
    root.setCenter(myAnimation);
    Scene scene = new Scene(root);
    stage.setScene(scene);
    stage.setWidth(FRAME_WIDTH);
    stage.setHeight(FRAME_HEIGHT);

    // Get screen refresh rate and apply it to animation
    stage.addEventHandler(WindowEvent.WINDOW_SHOWN, e -> {
      Screen screen = Screen.getScreensForRectangle(
          stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight()
          ).get(0);
      if (screen == null)
        return;
      GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
      // /!\ Does ge.getScreenDevices really return same order as Screen.getScreens?
      GraphicsDevice d = ge.getScreenDevices()[Screen.getScreens().indexOf(screen)];
      int r = d.getDisplayMode().getRefreshRate(); // /!\ r is an int whereas screen refresh rate is often not an integer
      myAnimation.setRefreshRate(r);
      //TODO: re-assess when window is moved to other screen
    });

    stage.show();
  }
}

class SmootherAnimation extends Canvas {

  private static final double SPEED = 500; // Speed value to be applied in either direction in px/s
  private static final Point2D RECT_DIMS = new Point2D(50, 50); // rectangle size
  // Canvas painting colors
  private static final Paint BLACK = Paint.valueOf("black");
  private static final Paint RED = Paint.valueOf("red");
  private static final Paint GREEN = Paint.valueOf("ForestGreen");
  private static final Paint BLUE = Paint.valueOf("SteelBlue");

  // Defines rectangle start position, stores current position
  private Point2D recPos = new Point2D(0, 50);
  // Defines initial speed, stores current speed
  private Point2D speed = new Point2D(SPEED, 0);

  //Frame rate measurement
  private long frameCount = 0;
  private BigDecimal fps = null;
  long[] frameTimes = new long[120]; //length defines number of rendered frames to average over
  //Frame duration in nanoseconds according to screen refresh rate
  private long frameNs = 1_000_000_000L / 60; //Default to 60Hz

  public SmootherAnimation() throws IOException {

    AnimationTimer anim = new AnimationTimer() {
      private long previousFrame = 0;

      @Override
      public void handle(long now) {
        // Skip first frame but record its timing
        if (previousFrame == 0) {
          previousFrame = now;
          frameTimes[0] = now;
          frameCount++;
          return;
        }
        
        // If we had 2 JFX frames for 1 screen frame, save a cycle by skipping render
        if (now <= previousFrame)
          return;
        
        // Measure FPS
        int frameIndex = (int) (frameCount % frameTimes.length);
        frameTimes[frameIndex] = now;
        if (frameCount > frameTimes.length) {
          int prev = (int) ((frameCount + 1) % frameTimes.length);
          long delta = now - frameTimes[prev];
          double fr = 1e9 / (delta / frameTimes.length);
          fps = new BigDecimal(fr).setScale(2, RoundingMode.HALF_UP);
        }
        frameCount++;
        
        // Calculate remaining time until next screen frame (next multiple of frameNs)
        long rest = now % frameNs;
        long nextFrame = now;
        if (rest != 0) //Fix timing to next screen frame
          nextFrame += frameNs - rest;
        
        // Animate
        updateWorld(previousFrame, nextFrame);
        previousFrame = nextFrame; //Saving last execution
        draw();
      }
    };

    // Start
    anim.start();
  }

  /**
   * Save frame interval in nanoseconds given passed refresh rate
   * @param refreshRate in Hz
   */
  public void setRefreshRate(int refreshRate) {
    this.frameNs = 1_000_000_000L / refreshRate;
  }
  
  /**
   * Perform animation (calculate object positions)
   * @param previousFrame previous animation frame execution time in ns
   * @param nextFrame next animation frame execution time in ns
   */
  private void updateWorld(long previousFrame, long nextFrame) {
    double elapsed = (nextFrame - previousFrame) / 1e9; //Interval in seconds
    // Reverse when hitting borders
    if ( rectHitsBorders(   recPos.getX(), recPos.getY(),
                            RECT_DIMS.getX(), RECT_DIMS.getY(),
                            speed.getX(), speed.getY()) ) {
      speed = speed.multiply(-1.);
    }
    // Update position according to speed
    recPos = recPos.add(speed.multiply(elapsed));
  }

  /**
   * Draw world onto canvas. Also display calculated frame rate and frame count
   */
  private void draw() {
    GraphicsContext gfx = this.getGraphicsContext2D();
    // Clear and draw border
    gfx.setStroke(BLACK);
    gfx.setLineWidth(1);
    gfx.clearRect(0, 0, getWidth(), getHeight());
    gfx.strokeRect(0, 0, getWidth(), getHeight());
    // Draw moving shape
    gfx.setFill(RED);
    gfx.fillRect(recPos.getX(), recPos.getY(), RECT_DIMS.getX(), RECT_DIMS.getY());
    // Draw FPS meter
    String fpsText = fps == null ? "FPS" : fps.toString();
    gfx.setTextAlign(TextAlignment.RIGHT);
    gfx.setTextBaseline(VPos.TOP);
    gfx.setFill(GREEN);
    gfx.setFont(Font.font(24));
    gfx.fillText(fpsText, getWidth() - 5, 5);
    // Draw frame counter
    gfx.setTextAlign(TextAlignment.LEFT);
    gfx.setFill(BLUE);
    gfx.fillText("" + frameCount, 5, 5);
  }

  /**
   * Tells whether moving rectangle is hitting canvas borders
   * @param x considered rectangle horizontal coordinate (top-left from left)
   * @param y considered rectangle vertical coordinate (top-left from top)
   * @param width considered rectangle width
   * @param height considered rectangle height
   * @param speedX speed component in x direction
   * @param speedY speed component in y direction
   * @return true if a canvas border is crossed in the direction of movement
   */
  private boolean rectHitsBorders(double x, double y, double width, double height, double speedX, double speedY) {
    Rectangle2D frame = new Rectangle2D(0, 0, getWidth(), getHeight());
    Rectangle2D rect = new Rectangle2D(x, y, width, height);
    if (speedX < 0 && rect.getMinX() < frame.getMinX())
      return true;
    else if (speedX > 0 && rect.getMaxX() > frame.getMaxX())
      return true;
    else if (speedY < 0 && rect.getMinY() < frame.getMinY())
      return true;
    else if (speedY > 0 && rect.getMaxY() > frame.getMaxY())
      return true;
    return false;
  }

}

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM