前言

前段时间曾经写过一个视频播放器:https://www.cnblogs.com/judgeou/p/14746051.html

然而这个播放器却无法显示出外挂或者内封的字幕,这里要稍微解释一下,字幕存在的三种形式:

  • 内嵌:字幕是画面的一部分
  • 内封:把字幕文件,例如ass文件放入了视频文件。
  • 外挂:字幕单独是一个文件。

在内嵌的情况下不需要特殊处理,而内封和外挂的时候,就需要进行额外的工作才能看到字幕。

在 DX 11.1 之前,往 D3D 表面渲染文字的主要方式,是通过把字体文件变成图片的形式载入到纹理,然后再利用文字编码映射到纹理UV的方式来渲染文字,当我们使用 Imgui 的时候他就是这么处理文字的。

优点:可以高速切换文字,渲染效率高。缺点:如果遇上中文字体等字符数量庞大的字体,那么开头载入字体到纹理这个阶段需要耗费大量CPU时间,之后也需要占据大量显存。如果需要显示比较大而且又清晰的字体,那么这种负面情况会加剧,如果大到一定程度,一张纹理都塞不下,情况会变得相当复杂。(除非你不介意糊的话可以用小纹理渲染大字体)。以及,一些字体排版、特效等难以实现。

DX 11.1 之后,Direct2D 可以和 Direct3D 互操作了,d2d 的文字渲染功能全都可以利用起来,渲染高质量字体不再是问题。

不过今天要说的 libass 这个库(https://github.com/libass/libass)使得我们不需要关心上面说的问题,基本帮我们把所有字体特效全部搞定了。

编译 libass

当一个库宣称自己跨平台的时候,就说明在 Windows 上可能要折腾一番了。这个库使用了典型的 GNU Build System,在 MinGW64 上可以非常流畅的编译成功,如果你不介意使用DLL文件进行动态链接,可以尝试自己在 MinGW64 上编译源代码试试。但是如果你想静态链接到自己的项目,那么 MinGW64 上编译出来的库文件在 Visual Studio 中多半会不能用。。。

通过一番折腾,我已经把 libass 代码以及其依赖项整理好了,弄成了 vs 的项目:https://gitee.com/judgeou/libass-msvc,拿来直接编译就行。

初始化 libass

libass 的函数全都是以 ass_ 开头,很好辨认。

初始化操作没有太多要注意的:

  1. ASS_Library* libass = ass_library_init();
  2. ASS_Renderer* ass_renderer = ass_renderer_init(libass);
  3. ASS_Track* ass_track = ass_read_file(libass, (char*)"subtitle.ass", (char*)"UTF-8");
  4. ass_set_fonts(ass_renderer, NULL, "Arial", ASS_FONTPROVIDER_AUTODETECT, NULL, 0);
  5. ass_set_frame_size(ass_renderer, bgWidth, bgHeight);

subtitle.ass 就是你字幕文件的路径,ass_set_fonts 第二、三个参数可以选择你想要的默认字体(当找不到对应字体的时候会使用)。

ass_set_frame_size 设置视频画面的分辨率,注意,这里是视频渲染时的分辨率,而不是原画分辨率,这样 libass 才会返回正确大小的位图。

渲染字幕

libass 渲染字幕的函数只有一个:ass_render_frame:

  1. long long currentms = 6000; // 生成哪一时刻的字幕,单位是毫秒
  2. int isChange = 0; // 与上一次生成比较,0:没有变化,1:位置变了,2: 内容变了
  3. ASS_Image* assimg = ass_render_frame(ass_renderer, ass_track, currentms, &isChange);

ASS_Image 结构体是我们重点关注的东西:

  1. /*
  2. * A linked list of images produced by an ass renderer.
  3. *
  4. * These images have to be rendered in-order for the correct screen
  5. * composition. The libass renderer clips these bitmaps to the frame size.
  6. * w/h can be zero, in this case the bitmap should not be rendered at all.
  7. * The last bitmap row is not guaranteed to be padded up to stride size,
  8. * e.g. in the worst case a bitmap has the size stride * (h - 1) + w.
  9. */
  10. typedef struct ass_image {
  11. int w, h; // Bitmap width/height
  12. int stride; // Bitmap stride 位图每一行有多少字节
  13. unsigned char *bitmap; // 1bpp stride*h alpha buffer 仅含 alpha 通道的位图,大小是 stride * h
  14. // Note: the last row may not be padded to
  15. // bitmap stride! 注意,最后一行可能不会填充满,意思是读取最后一行的时候,读够 w 字节就行了
  16. uint32_t color; // Bitmap color and alpha, RGBA 位图使用的 RGBA 颜色
  17. int dst_x, dst_y; // Bitmap placement inside the video frame 该位图应该显示在视频画面中的哪个位置
  18. struct ass_image *next; // Next image, or NULL 下一个 image
  19. enum {
  20. IMAGE_TYPE_CHARACTER,
  21. IMAGE_TYPE_OUTLINE,
  22. IMAGE_TYPE_SHADOW
  23. } type;
  24. } ASS_Image;

很明显这是一个链表,libass 实际会生成多层的图像,我们需要一层一层的逐一渲染才能看到正确的字幕。

按照一般思维,我们猜测 libass 应该要返回一个 RGBA 位图,这样我们只要使用常规的手段可以简单的把位图显示在画面的某处,但 libass 返回的位图竟然只有 alpha 通道,外加一个单一的颜色值,这就有点棘手了。

首先我们单独创建一个和画面大小相同的 RGBA 格式纹理 subTexture,把字幕写入到这个纹理,然后先渲染视频画面,再渲染字幕纹理,这样字幕就居于视频之上了。这里必须要注意渲染字幕的纹理前一定要调用 OMSetBlendState 告诉 DIrect3D 接下来要进行 alpha 混合,否则透明的像素会渲染为黑色而不是它后面的视频纹理像素。

实现关键代码:

  1. int isChange = 0;
  2. auto assimg = ass_render_frame(ass_renderer, ass_track, currentms, &isChange);
  3. if (isChange != 0) {
  4. int count = 0;
  5. D3D11_MAPPED_SUBRESOURCE mapped;
  6. d3dctx->Map(subTexture.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
  7. memset(mapped.pData, 0, mapped.DepthPitch);
  8. if (assimg) {
  9. while (assimg) {
  10. auto src = assimg->bitmap;
  11. auto dst = (UCHAR*)mapped.pData;
  12. // 正确计算字幕的起始位置
  13. dst = dst + assimg->dst_y * mapped.RowPitch + assimg->dst_x * 4;
  14. for (int y = 0; y < assimg->h; ++y) {
  15. for (int x = 0; x < assimg->w; ++x) {
  16. auto i = assimg;
  17. auto pixel = dst + x * 4;
  18. auto srcA = (src[x] * (0xff - (assimg->color & 0x000000ff))) >> 8;
  19. auto compA = 0xff - srcA;
  20. double alpha = (255 - src[x]) / 255.0;
  21. UCHAR rb = (assimg->color & 0xff000000) >> 24;
  22. UCHAR gb = (assimg->color & 0x00ff0000) >> 16;
  23. UCHAR bb = (assimg->color & 0x0000ff00) >> 8;
  24. UCHAR ra = pixel[0];
  25. UCHAR ga = pixel[1];
  26. UCHAR ba = pixel[2];
  27. UCHAR aa = pixel[3];
  28. pixel[0] = (1 - alpha) * rb + alpha * ra;
  29. pixel[1] = (1 - alpha) * gb + alpha * ga;
  30. pixel[2] = (1 - alpha) * bb + alpha * ba;
  31. pixel[3] = (1 - alpha) * src[x] + alpha * aa;
  32. }
  33. // 指针移动到下一行
  34. src += assimg->stride;
  35. dst += mapped.RowPitch;
  36. }
  37. assimg = assimg->next;
  38. }
  39. d3dctx->Unmap(subTexture.Get(), 0);
  40. }
  41. }
  42. ctx->DrawIndexed(indicesSize, 0, 0);

主要就是循环每个像素写入正确的颜色值,渲染每一层的时候,注意要和上一层的像素手动进行alpha混合。所有层写入完毕后再 调用 DrawIndexed 渲染到 D3D 表面。

这种方法的一个问题在于,效率太低了,中间的混合过程运算并不轻松,而且迭代次数过多,经常动不动就一个图层接近一万次的迭代,如果用来渲染数量庞大的弹幕比PPT还卡。

想要提升效率,一是要减少迭代,二是要尽可能把运算交给 GPU 处理,为此,需要做不少工作:

  1. 不使用之前的全屏覆盖的纹理,改为每个图层独立创建小纹理
  2. 直接把 assimg->bitmap 原封不动复制到字幕纹理中
  3. 把 assimg->color 作为常量缓冲,和纹理归为一组资源,放入 pipeline
  4. 通过 assimg->dst_x 和 assimg->dst_y 计算顶点坐标,和纹理归为一组资源,放入pipeline,让字幕渲染到正确的位置
  5. 通过 着色器 来对像素颜色进行处理,充分利用GPU。
  6. 把这些资源放到一个数组中保存起来,当字幕没有变化时(isChange == 0),直接开始 D3D 的渲染流程,跳过写入数据到纹理等资源的过程
  7. 因为我们没法预测字幕位图的大小,以及 D3D 纹理大小是固定的,所以每次字幕变化时,都需要重新创建纹理,之前的纹理无法重复使用,必须要销毁。
  1. // 创建字幕图层的只读的纹理
  2. void CreateOneTimeTexture(ID3D11Device* d3ddevice, int width, int height, ID3D11Texture2D** subTexture, ID3D11ShaderResourceView** srv, const UCHAR* data, int pitch) {
  3. D3D11_TEXTURE2D_DESC subDesc = {};
  4. subDesc.Format = DXGI_FORMAT_R8_UNORM;
  5. subDesc.ArraySize = 1;
  6. subDesc.MipLevels = 1;
  7. subDesc.SampleDesc = { 1, 0 };
  8. subDesc.Width = width;
  9. subDesc.Height = height;
  10. subDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
  11. subDesc.Usage = D3D11_USAGE_IMMUTABLE;
  12. D3D11_SUBRESOURCE_DATA sd = {};
  13. sd.pSysMem = &data[0];
  14. sd.SysMemPitch = pitch;
  15. ComPtr<ID3D11Texture2D> tempTexture;
  16. if (subTexture == NULL) {
  17. subTexture = &tempTexture;
  18. }
  19. d3ddevice->CreateTexture2D(&subDesc, &sd, subTexture);
  20. if (srv) {
  21. // 创建着色器资源
  22. D3D11_SHADER_RESOURCE_VIEW_DESC const srvDesc = CD3D11_SHADER_RESOURCE_VIEW_DESC(
  23. *subTexture,
  24. D3D11_SRV_DIMENSION_TEXTURE2D,
  25. subDesc.Format
  26. );
  27. d3ddevice->CreateShaderResourceView(
  28. *subTexture,
  29. &srvDesc,
  30. srv
  31. );
  32. }
  33. }
  34. // 每一个字幕图层需要的 D3D 资源
  35. struct SubtitleD3DResource {
  36. ComPtr<ID3D11Texture2D> tex;
  37. ComPtr<ID3D11ShaderResourceView> srv;
  38. ComPtr<ID3D11Buffer> cb_color;
  39. ComPtr<ID3D11Buffer> vertex;
  40. SubtitleD3DResource(ID3D11Device* device, int w, int h, const UCHAR* texdata, int pitch, uint32_t color, const vector<Vertex>& vertices) {
  41. // ... 在这里创建好这个结构体的 D3D 资源。
  42. CreateOneTimeTexture(device, w, h, &tex, &srv, texdata, pitch);
  43. D3D11_BUFFER_DESC bd = {};
  44. bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
  45. bd.Usage = D3D11_USAGE_IMMUTABLE;
  46. bd.ByteWidth = vertices.size() * sizeof(Vertex);
  47. bd.StructureByteStride = sizeof(Vertex);
  48. D3D11_SUBRESOURCE_DATA sd = {};
  49. sd.pSysMem = &vertices[0];
  50. device->CreateBuffer(&bd, &sd, &vertex);
  51. D3D11_BUFFER_DESC cbd = {};
  52. cbd.Usage = D3D11_USAGE_IMMUTABLE;
  53. cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
  54. cbd.ByteWidth = 16;
  55. cbd.StructureByteStride = sizeof(uint32_t);
  56. D3D11_SUBRESOURCE_DATA csd = {};
  57. csd.pSysMem = &color;
  58. device->CreateBuffer(&cbd, &csd, &cb_color);
  59. }
  60. };
  61. void Draw () {
  62. // ...
  63. int isChange = 0;
  64. auto assimg = ass_render_frame(ass_renderer, ass_track, currentms + 6300, &isChange);
  65. if (isChange) {
  66. subsD3DResource.clear(); // SubtitleD3DResource的数组,重新写入字幕位图时需要清空,回收资源
  67. if (assimg) {
  68. while (assimg) {
  69. // 计算UV,这里要进行归一化,转换为 [0.0, 1.0]
  70. float u1 = (float)assimg->dst_x / sub_frame_width;
  71. float v1 = (float)assimg->dst_y / sub_frame_height;
  72. float u2 = ((float)assimg->dst_x + assimg->w) / sub_frame_width;
  73. float v2 = ((float)assimg->dst_y + assimg->h) / sub_frame_height;
  74. // 计算顶点坐标,这里把上面的结果变为 [-1.0, +1.0]
  75. float x1 = u1 * 2 - 1;
  76. float y1 = 1 - v1 * 2;
  77. float x2 = u2 * 2 - 1;
  78. float y2 = 1 - v2 * 2;
  79. vector<Vertex> vertices = {
  80. {x1, y1, 0, 0, 0},
  81. {x2, y1, 0, 1, 0},
  82. {x2, y2, 0, 1, 1},
  83. {x1, y2, 0, 0, 1},
  84. };
  85. // 直接把 bitmap 复制到纹理,没有任何多余的循环
  86. SubtitleD3DResource subRes(d3ddevice.Get(), assimg->w, assimg->h, assimg->bitmap, assimg->stride, assimg->color, vertices);
  87. subsD3DResource.push_back(subRes);
  88. assimg = assimg->next;
  89. }
  90. }
  91. }
  92. // 按顺序渲染每一个图层
  93. for (auto& subRes : subsD3DResource) {
  94. ID3D11Buffer* vertexBuffers2[] = { subRes.vertex.Get() };
  95. ctx->IASetVertexBuffers(0, 1, vertexBuffers2, &stride, &offset);
  96. ID3D11Buffer* cbs2[] = { subRes.cb_color.Get() };
  97. ctx->PSSetConstantBuffers(0, 1, cbs2);
  98. ID3D11ShaderResourceView* srvs2[] = { subRes.srv.Get() };
  99. ctx->PSSetShaderResources(0, 1, srvs2);
  100. ctx->DrawIndexed(indicesSize, 0, 0);
  101. }
  102. // ...
  103. }

渲染字幕纹理时,使用下面这个着色器:

  1. Texture2D<float> tex : register(t0);
  2. SamplerState splr;
  3. cbuffer CBuf
  4. {
  5. uint color;
  6. };
  7. float4 main_PS_ass(float2 tc : TEXCOORD) : SV_TARGET
  8. {
  9. float alpha = tex.Sample(splr, tc); // 从纹理中取得 alpha 值
  10. // 从常量缓冲取得 rgb 值
  11. float r = ((color & 0xff000000) >> 24) / 255.0;
  12. float g = ((color & 0x00ff0000) >> 16) / 255.0;
  13. float b = ((color & 0x0000ff00) >> 8) / 255.0;
  14. return float4(r, g, b, alpha);
  15. }

结果截图:

即使是数量较多的弹幕,CPU占用也不算太高了。但是如果再多一些还是会卡顿,如果还要优化,就需要抛弃 libass 的渲染代码,自己用 Direct2D 进行文字渲染,避免我这样每次都创建新的纹理,事实上大多数CPU都花费在了创建新纹理上。又或者想出一种办法可以用一张纹理通过着色器程序一次搞定,反正我是想不出来了。如果大家有什么好办法,请务必在评论区告诉我。

内封字幕

对于内封字幕,其实处理方法大同小异。ASS_Track 的获得方式不再是 ass_read_file,而是使用 ass_new_track 创建一个空的 track,在打开视频字幕流的时候(AVCodec.type == AVMEDIA_TYPE_SUBTITLE),从 AVCodecContext.extradata 可以取得 ASS 的文件头,类似这样的内容(注意它是UTF-8编码的):

  1. [Script Info]
  2. Title: 侦探已死:1_番剧_bilibili_哔哩哔哩
  3. Original Script: Generated by tiansh/ass-danmaku (embedded in liqi0816/bilitwin) based on https://www.bilibili.com/bangumi/play/ep409795?spm_id_from=333.851.b_62696c695f7265706f72745f616e696d65.53
  4. ScriptType: v4.00+
  5. Collisions: Normal
  6. PlayResX: 560
  7. PlayResY: 420
  8. Timer: 100.0000
  9. [V4+ Styles]
  10. Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
  11. Style: Fix,SimHei,25,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,20,20,2,0
  12. Style: Rtl,SimHei,25,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,20,20,2,0

随后调用 ass_process_codec_private

  1. ass_process_codec_private(ass_track, (char*)subcodecCtx->extradata, subcodecCtx->extradata_size);

在循环解码的阶段,判断 AVPacket 的解码器类型是 AVMEDIA_TYPE_SUBTITLE 时,则可从 packet 中的 pts 和 duration 得到该字幕应当出现的时刻与时长(注意要根据 timebase 转换成毫秒)。调用 avcodec_decode_subtitle2 可取得 AVSubtitle 对象,从 AVSubtitle.rects 取得 AVSubtitleRect**,因为同一时刻可能存在多个字幕。AVSubtitleRect.ass 就是我们要的东西,它通常是一行 ass event,总之调用 ass_process_chunk 把这行字符串交给 libass 即可。

  1. AVPacket* packet;
  2. // ... 读取 packet
  3. // 这里得到的是秒
  4. double duration = packet->duration * subtitleTimeBase;
  5. double pts = packet->pts * subtitleTimeBase;
  6. AVSubtitle sub = {};
  7. int got_sub_ptr = 0;
  8. avcodec_decode_subtitle2(codecCtx, &sub, &got_sub_ptr, packet);
  9. if (got_sub_ptr) {
  10. int num = sub.num_rects;
  11. for (int i = 0; i < num; i++) {
  12. auto rect = sub.rects[i];
  13. ass_process_chunk(ass_track, rect->ass, strlen(rect->ass), pts * 1000, duration * 1000); // 乘以 1000 转换成毫秒
  14. }
  15. }

之后的处理就和上面一样了,依然是调用 ass_render_frame 来获得图层。

结尾

libass 的 bitmap 通常 stride 会大于 w,多出来的部分是用来填充的空白数据,为什么要多此一举呢,简单的来说就是要对齐字节,加速 CPU 处理。比如现在 64 位 CPU 少说一次读取也能读取 64bit,也就是一个 long long 或者 两个 int,通过恰当的安排可以减少 CPU 读取次数。

为了兼容各平台,libass 没有使用和平台密切相关的技术,例如 Direct2D(最多也是用来获取字体),字体的绘制都是使用例如 freetype 这样跨平台的库来实现,这就导致其几乎没有硬件加速能力,如果要完全硬件加速,恐怕得自己写渲染代码了。

【C++】使用 libass,完成 Direct3D 11 下的字幕渲染的更多相关文章

  1. 【译】Import Changes from Direct3D 11 to Direct3D 12

    译者:林公子 出处:木木的二进制人生 转载请注明作者和出处,谢谢! 这是微软公布的Direct3D 12文档的其中一篇,此翻译留作学习记录备忘,水平有限,错漏难免,还望海涵. 原文链接是https:/ ...

  2. Direct3D 11 Tutorial 4: 3D Spaces_Direct3D 11 教程4:3D空间

    概述 在上一个教程中,我们在应用程序窗口的中心成功渲染了一个三角形. 我们没有太注意我们在顶点缓冲区中拾取的顶点位置. 在本教程中,我们将深入研究3D位置和转换的细节. 本教程的结果将是渲染到屏幕的3 ...

  3. Direct3D 11 Tutorial 3: Shaders and Effect System_Direct3D 11 教程3:着色器和效果系统

    概述 在上一个教程中,我们设置了一个顶点缓冲区并将一个三角形传递给GPU. 现在,我们将逐步完成图形管道并查看每个阶段的工作原理. 将解释着色器和效果系统的概念. 请注意,本教程与前一个源代码共享相同 ...

  4. Direct3D 11 Tutorial 2: Rendering a Triangle_Direct3D 11 教程2:渲染一个三角形

    概要 在之前的教程中,我们建立了一个最小的Direct3D 11的应用程序,它用来在窗口上输出一个单一颜色.在本次教程中,我们将扩展这个应用程序,在屏幕上渲染出一个单一颜色的三角形.我们将通过设置数据 ...

  5. Direct3D 11 Tutorial 1: Basics_Direct3D 11 教程1:基础

    Github-LearnDirectX-DX3D11 tutorial01 概述 在这第一篇教程中,我们将通过介绍创建最小Direct3D应用程序所必需的元素.每一个Direct3D应用程序必需拥有这 ...

  6. [转]Direct3D 11 Tessellation Tutorial

    The new hardware tessellation feature available on Direct3D 11 video cards has great potential, but ...

  7. asp.net 项目在 IE 11 下出现 “__doPostBack”未定义 的解决办法

    最近项目在 IE 11 下<asp:LinkButton> 点击出现 “__doPostBack”未定义”,经过一番google,终于知道了原因:ASP.NET 可能无法辨识出一些浏览器的 ...

  8. Linux 0.11下信号量的实现和应用

    Linux 011下信号量的实现和应用 生产者-消费者问题 实现信号量 信号量的代码实现 关于sem_wait和sem_post sem_wait和sem_post函数的代码实现 信号量的完整代码 实 ...

  9. Cmake新手使用日记(1)【C++11下的初体验】

    第一次使用Cmake,搜索了很多使用教程,包括<Cmake实践>.<Cmake手册>等,但是在针对最新的C++11条件下编程还是会存在一点点问题,需要实验很多次错误并搜索大量文 ...

随机推荐

  1. 面向.NET开发人员的Dapr- actors 构建块

    原文地址:https://docs.microsoft.com/en-us/dotnet/architecture/dapr-for-net-developers/actors The actor m ...

  2. mysql主节点down机后如何恢复操作

    1 停机维护 (1) 先停止上层应用 (2) 检查backup和slave的中继日志是否已经完成了回放及gtid_executed保持一致 mysql> show slave status\G; ...

  3. ps 快速替换背景颜色

    1.打开图片: 点击工具栏上的"选择"--色彩范围--按[delete]

  4. 触宝科技基于Apache Hudi的流批一体架构实践

    1. 前言 当前公司的大数据实时链路如下图,数据源是MySQL数据库,然后通过Binlog Query的方式消费或者直接客户端采集到Kafka,最终通过基于Spark/Flink实现的批流一体计算引擎 ...

  5. ubuntu docker开启2375端口,支持远程访问

    1.编辑docker文件:/usr/lib/systemd/system/docker.service vi /usr/lib/systemd/system/docker.service 2.Exec ...

  6. CG-CTF WxyVM2

    一.原本以为要动调,因为出现了这个,函数太长,无法反编译 后面才知道这玩意可以在ida的配置文件里面去改,直接改成1024. 里面的MAXFUNSIZE改成1024,就可以反编译了,这个长度是超过这个 ...

  7. mongodb常用查询语句(转)

    1.查询所有记录 db.userInfo.find();相当于:select* from userInfo; 2.查询去掉后的当前聚集集合中的某列的重复数据db.userInfo.distinct(& ...

  8. python 10篇 操作mysql

    一.操作数据库 使用pip install pymysql,安装pymysql模块,使用此模块连接MySQL数据库并操作数据库. import pymysql host = 'ip地址' # 链接的主 ...

  9. keeplived+mycat+mysql高可用读写分离水平分表(谁看谁都会)

    一:环境准备: 应用 主机 mysql-master 192.168.205.184 mysql-slave 192.168.205.185 mycat-01,keeplived,jdk 192.16 ...

  10. window对象之计时器--v客学院技术分享

    setTimeout()和setInterval()可以用来注册在指定的时间之后单次或者重复调用的函数.因为它们都是客户端JavaScript中重要的全局函数,所以定义为window对象的方法,但是作 ...