文章有点长,但是文末有赠书福利!

0 前言

前几天,深度强化学习圈最火的事件莫过于腾讯 AI Lab 与王者荣耀联合研发的策略协作型 AI “绝悟” 完全体相关论文被AI顶级会议 NeurIPS 2020 收录。

绝悟人机经过一年辛苦练习的复仇,已经解锁了全英雄池。第20关的绝悟人机已经达到了巅峰赛2000分的水准,操作飘逸,秀得飞起,就算是职业选手也可能被人机吊锤,简直恐怖如斯。

让我们先欣赏一下绝悟的操作:

▲AI策略:红方AI铠大局观出色,成功绕后蹲草丛,精准击杀对方C位,扭转战局。

▲AI微操:蓝方AI中路3人小规模交锋,精细操作,丝血化解红方橘右京攻势。‍‍‍

▲AI协作:蓝方AI团战4人完美配合,中路打团以少胜多,扩大优势。‍‍

▲AI微操:AI公孙离完美连招,隔墙一秀三反杀红方猪八戒,成功逃生。

那么这么??的AI,到底是怎么实现的呢?

今天就来给大家介绍一下游戏AI智能体的前世:状态机和行为树

1. 状态机的弊端

有限状态机包含了有限个的状态和状态间的转换条件。最直白的说法就是几个if...else...语句。用状态机来实现AI更符合我们思维的朴素表达,任何一个有经验的coder都能直观得去写一个自己的AI状态机。

它用于一些简单的ai其实是没有大问题的,(搜索敌人,靠近,攻击,死亡)用状态机其实更加便捷。但是面对一些复杂的ai逻辑实现就会显得比较繁杂。

同时,当需要对现有行为逻辑进行扩展的时候,会随着状态和转换条件的增多而急速地变得错综复杂,要维护的状态量会成倍增加,因此在代码的开发与维护上就会很吃力(想象一下,多加一个状态,则需要增加几条转换线,越多状态,需要增加的转换线越多)。

2. 为什么选择行为树

行为树,实现AI的过程更加得有技巧,框架设计者较为全面考虑了我们可能会遇到的种种情况,把每种情况都抽象成了一个类型的节点,而我们要做的就是按照规范去写节点,然后把节点连接成一颗行为树。更加得具有面向对象的味道,行为模块间的藕合度相对较低。

举个粗糙的例子来比较一下两者的不同:

AI行为:吃饭 睡觉 打豆豆(很消耗体力和脑力的)

1.打豆豆 HP - 5 / 秒 MP - 3 / 秒 2.吃饭 HP + 10/秒 MP - 1 / 秒 3.睡觉 MP + 15/秒 HP - 2/秒 4.吃饭和睡觉是不可打断的动作(pending),必须执行到吃饱(HP = 100) or 睡饱(MP = 100) 5.打豆豆是瞬发动作,每帧都可以执行一次

状态机的实现逻辑图:

行为树的实现逻辑图:

其实不管你知不知道什么是selector,condition都不要紧,至少从上图,应该可以看出来,行为树节点间的联系并不像状态机那样得“紧密”。

选择两种不同的ai实现方法,也决定了具体行为的实现逻辑。比如对于sleep动作的实现,如果是状态机:

function sleep() =    if Y == 100 then        AwakeEvent()        return    end    HP -= X    MP += Yend

然后每一帧执行sleep()

如果是选择行为树:

function sleep()    local sleepTime = (100/15)    self:runAction(cc.Sequence:create(cc.DelayTime:create(sleepTime),cc.CallFunc:create(cancelPending)))    local cancelPending = function()    pending = falseend

3 行为树的概念

3.1 行为树的核心概念

行为树是一个包含逻辑节点和行为节点的树结构,每次需要找出一个行为的时候,会从树的根节点出发,遍历各个节点,找出第一个和当前数据相符合的行为。

  • 对于有限状态机而言,必须明确状态的转换方式
  • 对于行为树而言,必须明确状态前提:前提条件

每一个行为必须有“前提条件” ,这决定了该行为是否被选择。

3.2 行为树的组成

行为树有三种节点类型,它们分别是:

  • 组合节点-Composite: 组合节点通常可以拥有一个或更多的子节点,这些子节点会按照一定的次序或是随机地执行,并会根据执行的结果向父节点返回“Success”、“Failure”,或是在未执行完毕时“Running”这样的结果值。

  • 修饰节点-Decorator: 修饰节点也可以拥有子节点,但是不同于组合节点,它只能拥有一个子节点。取决于修饰节点的类型,它的功能可以是修改子节点返回的结果、终止子节点,或是重复执行子节点等等

  • 叶节点-Leaf: 是真正让你的树做具体事情的基础行为元素,比如:攻击、移动等等。叶节点可以有一些参数,比如Walk可以包含一个具体将要移动到的位置的参数。这些参数可以从其他变量里获得,比如角色将要前往的一个地点可以被 GetSafeLocation 这个节点所决定,存入一个变量里,然后 Walk 节点可以使用这个变量来定义它的目的地。行为树的运行中,这些不同的节点通过数据上下文来共同储存或使用一些持久数据(persistent data),使得行为树的功能变得强大。另一种叶节点的类型是调用其他的行为树并把当前行为树的数据传给对方。这些功能将允许你高度模块化你的树并把很多节点用在更多的地方。比如 Break into Building(闯入房间)行为需要先有一个 targetBuilding(目标房间)的变量来进行操作,那么父树可以先定义 targetBuilding 这个变量,在子树需要闯入房间时通过一个叶节点来把这个变量传入。

这些节点都拥有一个共同的核心功能,即它们会返回三种状态中的一个作为结果。这三种状态分别是:

  • 成功-Success;
  • 失败-Failure;
  • 运行中-Running;

每个节点需要向它们的父节点通知运行的成功或失败的结果,如果状态是“运行中”,则表示结果还未确定,需要在下一个step的时候再去检查这个节点的运行结果。

这个功能非常重要,它可以让一个节点持续运行一段时间来维持某些行为。比如一个“walk(行走)”的节点会在计算寻路和让角色保持行走的过程中持续返回“Running”来让角色保持这一状态。如果寻路因为某些原因失败,或是除了某些状况让行走的行为不得不中止,那么这个节点会返回“Failure”来告诉它的父节点;如果这个角色走到了指定的目的地,那么节点返回“Success”来表示这个行走的指令已经成功完成。

这些状态可以用来决定行为树的走向,确保 AI 可以按照我们预期的方式来以某些顺序去执行行为树里的行为。

3.3 行为树的调用方式

行为树的运算也是通过帧(step)循环的update来驱动,不一定是每帧都update,但是要周期性update。

每一次run从根节点(root)开始,每一运行都会选择一个可行的子节点运行,这种选择可以是随机方式,也可以是预设好优先条件。

当一个叶子节点被选择后,就会激活其对应的基本的行为。最基本的行为可能执行成功也可能失败。

高等级的行为(中间节点)是否执行成功依赖于他们的孩子节点是否执行成功。

一个子节点失败可能导致父母节点选择另外一个孩子。

除了选择(selector)一个单独的子节点行为,一个节点还可能顺序(sequence)or并行(concurrent)得运行他的所有子节点。

一个行为除了有前提条件,可能还有上下文条件(父节点or孩子节点可能存储一定的状态变量)。高优先级的行为可能抢占低优先级的行为。

3.4 行为树优缺点

从代码实现的角度来谈下优缺点

优点:

  1. 行为逻辑和状态数据分离,任何节点写好以后可以反复利用
  2. 重用性高,可用通过重组不同的节点来实现不同的行为树
  3. 呈线性的方式扩展,易于扩展
  4. 可配置,把工作交给designer

缺点:

  1. 每一帧都从root开始,有可能会访问到所以的节点,相对State Machine消耗更多的cpu
  2. 任何一个简单的操作都必须要使用节点

4 各节点详解及案例分析

4.1 组合节点

4.1.1 次序节点-Sequences

Sequences节点会从左到右依次访问子节点。每个子节点成功之后便轮到下一个,直到最后。

Sequences节点的所有子节点是‘and’关系,如果所有子节点都 Success,则向次序节点返回 Success;其间任何一个子节点返回 Failure,就会立即向次序节点返回 Failure 的结果。

次序节点有很多的用处,其中最显而易见的用法就是执行一连串有前后依存关系的行为其中一个的失败必然导致后续的动作没有进行的意义,比如这个“进门”行为的例子:

这个次序节点下的子节点共同让角色实现了从走向门到进门关门的连串动作。过程如下:

次序节点 -> Walk to Door (Success) -> 次序节点(Running) -> Open Door (Success)  -> 次序节点(运行中) -> Walk through Door (Success) -> 次序节点(Running) -> Close Door (Success) -> 次序节点(Running) -> 向次序节点的父节点返回 Success。

如果角色因为某些原因未能成功走到门前,比如路被挡住了之类的,那么试图开门这些动作都没有意义了。当走向门这个动作失败的时候,次序节点就会返回 Failure,其父节点就可以根据这个结果来进行后面的事情了。

次序节点除了非常自然地用于进行一系列前后依存的动作之外,还可以用来做一些其他的事情,比如:

在上面这个例子中,次序节点的子节点不是一系列动作而是一系列的检查。这些子节点会检查角色是不是饿了,有没有食物,是不是在安全的地点,只有在它们都返回 Success 时,角色才会吃东西。这样使用次序节点可以实现类似于代码中 if 判断和“与门(AND gate)”的效果。这些用于判断的子节点可以是其他的组合节点或是修饰节点等等来实现更丰富的效果。比如下面这个使用了逆变节点的例子:

尽管功能和前面的例子完全一样,但是通过逆变节点我们在这里创建了一个非门(NOT gate),只有在“Enemies Around(敌人在周围)”这个条件返回 Failure 时,这一步才会返回 Success,从而让角色继续进行吃东西的动作。这意味着这些节点的组合可以减少很多不必要的开发量。

4.1.2 选择节点-Selector

Selector节点就是次序节点的反面。选择节点则会在任何一个子节点返回 Success 时就返回 Success 并且不再继续运行后续的子节点。相应的,当所有子节点都 Failure 时,选择节点才会返回 Failure。选择节点其实可以被理解为一个“或门”(OR gate)。

它可以用来表示一个行为的多种方式,从最高优先级到最低,任何一个方式的成功都会让这个动作 Success

在之前那个进门的例子,看看选择节点如何给这个行为增加复杂性:

我们只用了几个节点就制造了一套应对锁上的门的逻辑。我们来看看这个选择节点被执行时发生了什么吧。

首先,它执行了开门节点。它最希望得到的结果是直接打开门,如果这一步成功了,就没有后面的事儿了,没有必要走到其他选择节点的子节点里去检查该做什么。但是如果门没法成功打开,那么这个开门节点会 Failure,向它的父节点返回 Failure。在这个情况下,选择节点会继续尝试它的第二个子节点,或是优先级稍低于前一个的节点,来试图打开门锁。

我们在这里加入了一个执行打开门锁动作的次序节点,因为是次序节点,所以它的子节点的行为是有前后依存关系的,必须开锁成功才会执行后面的开门动作。两个子节点都 Success 后,这个次序节点的父节点——也就是那个选择节点也会返回 Success,那么角色就可以执行后面的穿过门的动作了。如果前面的尝试都 Failure(比如角色没有钥匙,或是没有开锁的技能),它们的失败会让选择节点尝试第三个开门的方式——把门打烂!

假如角色连这一点也做不到的话,它也令整个选择节点 Failure,从而导致整个试图走进门的行为的 Failure。

我们也可以添加一个新的行为来作为走进门这个行为 Failure 后的备选方案:

我们在树的顶部增加了一个选择节点。当角色试图进入房间时,他会先试着从门进去,当这样做行不通时,他会尝试从窗户进入。这个简化的示例很好地解释了这个逻辑,但实际的项目里的行为树可要比这个复杂多了。比如说,当这个房间没有窗户的时候,整个“进入房间”的节点会失败来告诉这个角色前往下一个房间。

行为树能够简化 AI 开发的关键因素在于一项任务的失败不再意味着当前所做事情的完全终止(比如,“寻路失败。那我该干什么?”这样的情况),而是符合 AI 系统范式的,行为决策中很自然的一个可预期的结果。

这些例子所表明的行为树的可扩展性,使得 AI 的开发可以从最简单的“把事情办了”开始逐渐迭代,用新的选择节点添加分支来扩展不同情况下 AI 的行为。丰富的后续方案可以降低一个行为彻底失败情况的出现,从而展现更加合理的 AI 行为。前面提到,NPC 找不到物品时会试图打造物品,实际上这个功能也是后面才加入的。即便没有这个行为逻辑,NPC 也会尝试寻找物品,但这一行为大大提高了 NPC 达成自己目标的能力。

再加上合理地为各种后续方案赋予优先级和条件,哪怕都是编程好的行为,它们也能让 AI 在行为决策时表现得更加自然和聪明。

4.2 修饰节点-Decorator Nodes

4.2.1 逆变节点-Inverter

前面提到过这个节点,他会反置或否定子节点的结果。

4.2.2 成功节点-Succeeder

成功节点不管它的子节点向其返回的结果为何,它总是返回 Success 的结果。这个往往用在当你知道一个子节点一定会返回 Failure 的结果,而它的父节点是次序节点,会因此而终止,那么你可以强行让这个子节点返回 Success,来避免这一情况的发生。我们并不需要一个专门的失败节点,因为一个逆变节点加上成功节点就可以达到这一效果。

4.2.3 重复节点-Repeater

重复节点会在它的子节点返回结果后反复继续执行它。重复节点常常被用在一棵树的最顶部来确保树的持续运行。另外重复节点也可以被设定重复执行的次数。

4.2.4 重复直至失败节点-Repeat Until Fail

类似重复节点重复执行子节点,但这一节点会在子节点 Failure 的时候返回 Failure。

4.3 数据上下文-Data Context

当一个行为树被调用时,一个数据上下文也被创建出来。它的用途是存储被节点所解释和改变的变量这些变量随后可以被节点结合着数据的上下文加以读写,从而使整个行为树保持为一个统一的整体

节点可以读取或写入变量,为以后处理的节点提供上下文数据,并允许行为树充当一个内聚单元。

4.4 定义叶节点-Defining Leaf Nodes

叶节点的内容是关于行为树的具体实现的,所以不同的方法和系统会有所区别。但为了通过叶节点来将游戏的具体功能加入到行为树中,大部分的系统都会有下面这两个功能:

  • Init(初始) -- 在叶节点第一次被其父节点访问时被调用。比如一个次序节点会在轮到它执行的时候调用 Init。在所有的子节点都完成了执行并返回了结果给父节点以前,即走完一次流程之前,Init 不会再次被调用。这个功能用来初始化这个节点并开始执行它的动作。以 Walk 这个行为为例,它会获取一些参数来初始化这个寻路的任务。

  • Process(执行)-- 在节点运行时的每一 step 都会被调用。如果得到了 Running 的结果则会一直运行下去;而一旦这个功能得到了成功或失败的结果,运行就会终止,结果也被返回给父节点。在 Walk 这个例子里,它会返回 Running,直到寻路成功或是失败。

节点可以有属性,既可以是被明确指定传入的参数,也可以是根据数据上下文从控制这个 AI 的实体里的变量引用而来。它会因使用的编程语言和行为树系统而有所不同,但是行为树的参数和数据存储的概念是通用的。

比如,我们这样定义一个 Walk 节点:

Walk(character,destination):    - Success:到达目的地    - Failure: 未能达到目的地    - Running:行进中

在这个例子中 Walk 节点有两个参数,分别是 character(角色)和 destination(目的地)。尽管很自然地会认为运行这个 AI 行为的对象就是这个节点的所有者,所以不需要专门把这个信息作为参数传入,但是最好还是不要这样默认。尤其是在作为条件判断的节点上,经常需要为了测试其他角色的行为或与它们交互而重新写代码。所以最好还是多走一步,哪怕你认为只有这个 AI 会使用这个行为,也还是把角色的信息也作为参数传入。

至于 destination,可以手动地填入 XYZ 的坐标。但是更有可能的情况,是这些坐标信息作为上下文变量被引用,如从其他的对象中获取的位置,或是根据 NPC 所在位置计算出的一个最近的安全躲避点等等。

4.5 栈-Stacks

当第一次使用行为树时,很自然地会把节点的适用范围与角色的行为、判断条件和情境联系起来。带着这些限制,行为树的能力得不到最大发挥。

用节点实现栈操作会有很显著的作用,为游戏加入了下面这些节点:

  • PushToStack(item, stackVar)
  • PopFromStack(stack, itemVar)
  • IsEmpty(stack)

它们只需要 init/ process 功能的支持就可以实现一个标准库的创建和修改的栈操作,只用了几行代码,它们就打开了一系列的可能性。

比如 PushToStack 创建一个新的栈,并且将传入的变量名存入,压入栈。类似的,PopFromStack 将一个前面压入的变量弹出栈并储存为 itemVar 变量。当栈已经为空时返回失败。IsEmpty 就是用来检查这个栈是否是空的,如果是则返回成功,否则失败。

使用这些节点,我们就可以用这样的树来遍历整个栈:

PopFromStack,加上一个在栈为空之前都会重复让其执行的 Until fail repeater 父节点就可以实现我们需要的结果。

接下来是一些其他的我常用的功能性节点:

  • SetVariable(varName, object)
  • IsNull(object)

现在假设我们添加一个叫做 GetDoorStackFromBuilding 的节点,你可以传入一个房间物体,然后它会将这个房间的门物体全部取出并建立一个栈来储存它们并以其作为我们的目标对象。那么接下来我们可以用它来做什么事情呢?

看起来变得有些复杂了。不过最终,跟任何语言一样,当你理解了其中原理你就可以轻松读懂它,而且在失去了可读性的时候,我们还获得了灵活性。

它做了什么呢?简而言之,这个行为会提取一个房间的所有门并尝试进入,如果角色成功地进入了任何一个则返回 Success,否则会 Failure。

首先它会获取这个包含了所有门的栈,通过调用 Until Fail repeater 节点,它会反复执行它的子节点,直到返回 Failure 为止。它的子节点是一个次序节点,会用 PopFromStack 从前面提到的栈里弹出一个门,并将其保存在 door 这个变量里,后面会用到 door 这个变量来告诉角色该去试图进入哪个门。

如果因为栈空了而弹出失败,则这个节点会返回 Failure 并用这个结果结束了前面的 Until Fail repeater 节点,继续最顶上的这个次序节点的执行,来到了这个结果被逆变的 IsNull 节点。它会检查 usedDoor 这个变量的 IsNull(是不是空的),显然它一定是空的,即返回 Success,因为我们还从来没有机会去设置它,所以这个成功被逆变节点返回为 Failure,于是整个行为 Failure。

如果栈确实弹出了门,那么它会调用另一个次序节点(也是带有逆变节点的),它会尝试走向那扇门,打开它然后走进去。

如果因为种种原因 NPC 没能成功穿过这扇门,那么这个次序节点会 Failure,而逆变节点会将这个 Failure 转化为 Success 向上返回,导致它的父节点仍然走不出这个 Until Fail repeater,故而会继续重复 PopFromStack,改变 door 变量的值,让 NPC 去尝试进入另一个门。

如果 NPC 成功穿过了一扇门,那么它会将这扇门赋值给 usedDoor 变量,并返回 Success。你会注意到我用到了一个 Succeeder 来修饰关门这个动作,这是因为要是前面 NPC 使用了破门的方式来开门的话,这里关门的动作应该就不会成功了,所以我需要这个修饰节点来确保它的成功结果。最后,整个次序节点的成功会被逆变节点转化为失败并让父节点离开 Until Fail repeater。在这种情况下,行为树继续往下走,我们会在 usedDoor 的 IsNull 中 Failure,因为刚刚 usedDoor 被赋了值,通过被逆变后,结果为 Success,现在它的父节点也会返回 Success,得知 NPC 成功地找到了一扇门并走了进去。

如果还是失败的话,我们可以用 GetWindowStackFromBuilding 来使用一样的步骤再对所有的窗户走一遍流程。

参考:

  1. @尘世喧嚣 https://blog.csdn.net/u011484013/article/details/52369313
  2. @Chris Simpson https://www.gamasutra.com/blogs/ChrisSimpson/20140717/221339/Behavior_trees_for_AI_How_they_work.php

—— 福利来啦 ——?就是本?

扫码加群,即可参与抽奖!!包邮到家哦

wpf指定的元素已经是另一个元素的逻辑子元素。请先将其断开连接。_大魔王王者荣耀绝悟,游戏AI逻辑的底层是啥?...相关推荐

  1. wpf指定的元素已经是另一个元素的逻辑子元素。请先将其断开连接。_在60分钟内建立一个无代码应用程序...

    让我们使用Airtable和Appgyver制作一个基本的Web应用程序. 无需编码技能. 我们将使用以下内容建立具有类别的产品目录: · Airtable:用于构建数据的混合数据库服务(第1部分) ...

  2. 机器人坐标系建立_如何在30分钟内建立一个简单的搜索机器人

    机器人坐标系建立 by Quinn Langille 奎因·兰吉尔(Quinn Langille) 如何在30分钟内建立一个简单的搜索机器人 (How to Build A Simple Search ...

  3. 如何在 5 分钟内建立一个数据驱动的新型冠状病毒肺炎应用程序

    在开始教程前,我们先来了解一个由加拿大 IVADO(Institute for Data Valorization)资助的项目: COVID-19 Data Hub (新型冠状病毒肺炎数据中心),它是 ...

  4. vr设备应用程序_在15分钟内构建一个VR Web应用程序

    vr设备应用程序 在15分钟内,您可以开发一个虚拟现实应用程序,并在Web浏览器,VR头盔或Google Daydream上运行它. 关键是A-Frame ,这是Mozilla VR Team构建的开 ...

  5. 愉快的舞会c++_如何在5分钟内建立一个令人愉快的加载屏幕

    愉快的舞会c++ First, here is what we will build. Set your timer! 首先,这是我们将要建立的. 设置您的计时器! Does this look fa ...

  6. 语音库构建_在10分钟内构建一个多功能语音助手

    语音库构建 Nowadays people don't have time to manually search the internet for information or the answers ...

  7. 用python做预测模型的好处_如何用Python在10分钟内建立一个预测模型

    匿名用户 1级 2017-01-01 回答 预测模型的分解过程 我总是集中于投入有质量的时间在建模的初始阶段,比如,假设生成.头脑风暴.讨论或理解可能的结果范围.所有这些活动都有助于我解决问题,并最终 ...

  8. 父元素a标签的href默认行为以及子元素绑定的click事件的响应之间存在影响

    原文地址 背景 开发过程中遇到问题,简单写个demo 运行环境为Chrome 68 描述一下这个问题,当a标签内部存在嵌套时, 父元素a标签的href默认行为以及子元素绑定的click事件的响应之间存 ...

  9. bootstrap设计登录页面_前端小白如何在10分钟内打造一个爆款Web响应式登录界面?...

    对于前端小白(例如:专注后端代码N年的攻城狮),自己编写一个漂亮的Web登录页面似乎在设计上有些捉襟见肘,不懂UI设计,颜色搭配极度的混乱(主色,辅助色,配色,色彩渐变,动画效果等等,看起来一堆乱七八 ...

  10. 开发平台之美:10分钟内实现一个销售订单功能的增删改查

    IT技术发展了这么多年,早就应该抛弃那些copy&paste的工作了,毫无成就,毫无趣味,毫无好感.这直接催生了一大批快速开发平台的崛起,下面的视频讲述的就是通过一个开发平台如何在10分钟内实 ...

最新文章

  1. 为什么局域网的IP普遍是192.168开头?
  2. keepalived实现nginx的高可用
  3. 中国电信:张志勇辞任公司执行副总裁
  4. SpringBoot异步任务, 以及带返回值的异步任务(@Async 不起作用的原因)
  5. 互联网时代的了解和未来的预期_互联网时代品牌农业该如何营销 后园带你了解这五点...
  6. 在线中文繁简体转换工具
  7. 百度云安装WordPress,提示数据库连接错误!
  8. jmeter压力测试指标解释
  9. 全球半导体产业60年兴衰启示录!
  10. 迭代期望和方差(iterated expectation,variance)
  11. 阿里云 OSS + CDN 部署静态网站配置
  12. 微信公众号新变动!你都发现了吗?
  13. Bellman-Ford(最短路)
  14. 【SEO优化】SEO应该是我们现在理解的这样吗?
  15. 如何借助大数据技术找到精准客户?
  16. Arduino LCD1602电子时钟
  17. 无星的RN学习之旅(六)-第三方App跳转,苹果商店跳转,loading框
  18. 解决:dockerd: failed to start daemon: Devices cgroup isn‘t mounted
  19. 用Javascript开发《三国志曹操传》-开源讲座(五)-可移动地图的实现
  20. 光明区关于促进科技创新的若干措施(征求意见稿)

热门文章

  1. Java|Kotlin, SpringBoot从配置文件中读取@KafkaListener参数配置
  2. Android之API详解
  3. vue-13 类与样式的绑定-自学视频
  4. IDEA编译报错“程序包XXX不存在”
  5. Python 随笔:用 PIL 库读取图像文件像素长宽大小
  6. 封装一个带查询功能的树形省市区(el-tree)
  7. 串口服务器 无线 802.1n,宇泰 UT-9061 10/100M TCP/IP转RS-232/485/422 WIFI串口服务器
  8. 程序设计——图书管理系统(附源代码)
  9. 对南摩工程师有需求的大老板们 老板姓名
  10. SpringMVC源码剖析(五)-消息转换器HttpMessageConverter