技术难点 - KumoKyaku/Megumin.AI.Samples GitHub Wiki

技术难点

这里是海面下的冰山!

这里记录一些在插件设计之前,没有意识到的问题。
在插件开发和使用到一定程度后,问题才随之出现,解决这些问题耗费了大量的时间。
如果一开始就意识到这些问题,可能设计之初就会有更好的架构,也可能直接放弃开发。

序列化反序列化

Q: 代码更改后,类名变更,命名空间变更,旧的序列化文件无法正确反序列的问题。
A: 建立全局查找表。

  • 增加别名特性,反序列开始前反射处理全部别名。
    当类型找不到时,从别名映射中查找。
  • 类名替换机制,当找不到类型时,尝试替换一部分类名,替换命名空间等。

参考:

Q:没有正确反序列化的节点,如果忽略,作为Selector的子节点应该返回失败, 作为Sequence的子节点应该返回成功。
A: 在节点调用过程中增加from参数,节点知道他的运行时是通过哪个节点进入的。 不能查找自己的父节点,可能存在菱形链接,含有多个父节点。只能运行时传入。
当节点Enabled == false,会调用此函数的GetIgnoreResult,动态返回结果。
注意:当反序列化失败时,节点会被missnode替代,调用missnode的GetIgnoreResult函数,而不是当前节点的函数。

/// <summary>
/// 根据调用节点返回不同的结果值,使调用节点忽略当前节点。
/// <para/> 父节点是Selctor 返回Failed,可以允许Selctor 跳过当前节点继续执行下个节点而是直接失败。
/// </summary>
/// <param name="from"></param>
/// <returns></returns>
protected virtual Status GetIgnoreResult(BTNode from)
{
    if (from is Selector)
    {
        return Status.Failed;
    }
    else
    {
        return Status.Succeeded;
    }
}

Undo/Redo

Q:撤销和重做是非常复杂的部分,添加节点,批量修改等。 A:使用SerializedObject,ScriptObject Wrap 一层。 抽象UndoBeginScope,解决一个操作包含很多更改操作的问题。

健壮性

为了保证稳定运行,需要写大量的条件判断,和意外情况处理逻辑。
这些代码枯燥又无聊,还需要特别小心,非常容易笔误。然后就是漫长的Debug环节。
常常为了一个小的问题,要写一大堆代码,还要与性能做取舍。
在这些代码上花费的时间远远多于架构设计和核心代码。

Debug/Log

Q:打印日志和优化性能非常冲突的两个部分,字符串内插会分配大量内存。日志方法调用又不应该太繁琐,用户使用时尽量少写代码。
直接使用Debug.Log打印日志,每次都会生成大量无用字符串。
A:最基本的解决思路是,在打印日志前做判断,如果不满足条件就不要生成日志字符串。
目前的设计是,在基类声明一个GetLogger方法,并使用空传播。
如果不满足条件,GetLogger返回null,后面的字符串生成不会被调用。

public virtual TraceListener GetLogger(string key = null)
{
    if (RunOption?.Log == true)
    {
        return TraceListener;
    }

    return null;
}

private void Enter(object options = null)
{
    GetLogger(LogConst.ChangeNode)?.WriteLine($"[{Time.time:0.00}] Enter Node {this}");
    OnEnter(options);
}  

这样设计的好处是,可以将Log开关放在GetLogger方法内检测,所有Log开关都可以放在一个地方调用。

后续设计还可以根据需求变形:

  • GetLogger的Key可以是泛型,也可以重载GetLogger方法。方便使用枚举作为LogKey。
  • GetLogger的返回值可以变为一个接口,根据key的不同或者运行环境的不同,返回不同的Logger,例如控制台Logger或者写入文件Logger,甚至可以返回一个StringBuilder,性能可以极大提高。

Q:有时节点参数不合法,需要在编辑器界面体现出来,而且用户自定义的节点同样需要此功能。例如权重随机节点,权重参数个数与子节点数不一致;跟随节点没有跟随对象等。
A:通过IDataValidable接口实现。编辑器会判断节点是否实现了这个接口,并在合适时机调用。根据返回值在编辑器中提示,或者打印警告。

/// <summary>
/// 可验证节点数据的
/// </summary>
public interface IDataValidable
{
    /// <summary>
    /// 用于验证节点数据的合法性,当参数不合法时,编辑器UI上增加提示。
    /// <para/> 通常0表示数据合法
    /// </summary>
    /// <returns></returns>
    (int Result, string ToolTip) Valid();
}

调用点设计 Awake,Start 等

Q:设计过多的调用点,用户使用起来更加灵活,但是也引入了复杂性。
大多数调用点可能永远不会调用,但是每次执行都要执行大量的空函数。
A:目前设计是调用点接口化,并缓存到一个集合中。这个设计不算好,但可以缓解一些问题。

调用点函数签名设计

public interface IAwakeable
{
    /// <summary>
    /// 绑定之后,解析之后,第一次Tick开始时调用
    /// </summary>
    /// <param name="options"></param>
    void Awake(object options = null);
}

调用点通常含有一个object默认为空的可选参数,这是一个预留参数。
目前可以想到的用途是,以后非常有可能将计时器,从整个行为树系统中抽象分离出去。参数调用过程中可能需要从外部传递计时器进来。
以后也非常可能为每个调用点设计一个Context类型,并且子类根据Context实现的接口做特殊处理。

/// <summary>
/// 轮询
/// </summary>
/// <param name="from">当前调用的父节点</param>
/// <param name="options">预留设计,用于传递可能的Context上下文等参数</param>
/// <returns></returns>
/// <remarks>
/// 使用参数获得父节点,而不是从节点连接关系取得父节点。
/// 如果行为树文件拓扑结构允许菱形或者环形,可能有多个父节点。
/// 但是运行时同一时刻只可能有一个调用父节点。
/// </remarks>
public Status Tick(BTNode from, object options = null)
{

}

轮询调用点方法,暂时没有接口化,因为还没有考虑的太全面,留到使用一段时间后,看看有没有合适的重构时机。

轮询方法签名,与其他行为树设计最大的区别就是,将调用这个节点的节点传递进来。当前节点可以根据上个节点执行不同逻辑,返回不同的返回值,GetIgnoreResult就依赖于此实现。
有利有弊,这样设计又与状态机有些相似,牺牲了行为树的纯粹性,可能会造成误用。

跨平台问题

不同平台对API支持不一样,遇到了WebGL不持支Task问题。
目前WebGL强制使用同步模式。等到Unity2023的Awaitable在测试重构。

优化

理论设计和优化设计完全是两回事。

最开始的实现代码都是理想状态,等投入工程实践中,有各种各样的性能问题。
内存开销过大,同时运行实例的最大数量,尖峰帧卡顿等。

需要漫长的性能分析,调整架构,实现方式。在各种地方创建缓存。
这又会引入新的BUG,循环往复,最后变成屎山。
还要保证注释清晰,因为优化后的代码可能已经不容易理解。

迭代重构

不同于仅自己使用的代码或者内部项目代码,拥有公开发布流程的代码,迭代重构特别困难。
很多功能甚至要兼容多个版本,复杂低效又臃肿。
只能建议用户升级到最新版本,但是永远无法强迫用户使用某个版本。
在技术支持周期内,很多本可以优化的地方就不能随意改动,甚至重命名都是一个艰巨的任务。
许多API原本可以设计为Public,为了保守安全起见渐渐倾向于设计为private。

技术支持

既然上架商店,就必然要提供技术支持,回应用户反馈。
英语不好沟通上有很多不便。用户描述问题不清楚也很难提供好的帮助。
文档写的也不是足够详细。
这些都是脏活累活,有不得不做。

⚠️ **GitHub.com Fallback** ⚠️