国民游戏王者荣耀的地图轻量解决方案

架构师(JiaGouX)我们都是架构师!
架构未来,你来不来?


相信很多人都玩过王者荣耀,大家在欣赏其华丽的游戏界面以及炫酷的游戏技能时,是否好奇过王者荣耀的地图是怎样开发出来的?在开发的历程中,都有哪些问题?是怎样解决的?本文将从其地图设计到完成的整个流程讲解王者荣耀地图轻量解决方案,希望可以给你带来灵感。

👉看目点收藏,随时涨技术
1 项目背景2 技术方案演进   2.1 地图方案选型   2.2 技术方案选型3 项目架构设计   3.1 整体结构   3.2 UI框架   3.3 数据传输      3.4 小结4 项目中问题以及解决方案   4.1 三端坐标系统一   4.2 Anroid点击事件处理   4.3 Anroid沉浸式问题处理   4.4 Anroid点9图功能支持   4.5 联调流程优化5 总结




01



项目背景

地图展示作为游戏 LBS 社交的基础能力,是王者荣耀地图技术落地中需要突破和解决的事情。



地图能力是地图开放平台的核心能力,在经过第一次沟通后,明确了几个核心需求:王者地图UI的展示 、POI 点省市县排行、热门街区排行 、定位能力的输出。


并且也明确了由地图团队提供 Unity 上的地图展示方案,由王者团队、阿波罗团队以及地图团队共同开发该项目。


接下来就进入了技术方案的调研和设计阶段。




02



技术方案演进

   2.1 地图方案选型


地图展示作为游戏 LBS 社交的基础能力,是当前方案中最需要突破和解决的事情。按照《王者荣耀》的整体计划,留给调研设计、研发和联调也就只有 1 个月的时间,在技术选型上更多的是结合当前已有的地图能力对外输出。


从现状出发,地图开放平台对外输出移动端地图 sdk,使用平台分为  Android 端和 ios 端,在效果上可以分为两类,2D 版本和 3D 版本。区别如下:



2D 版本的地图提供了基础的地图展示能力,3D 版本的地图可以支持更酷炫的建筑物拔起效果以及无极缩放等,在体验上更酷炫,但所占用的包大小更大。


android:


包大小 包增量
栅格1.2.8 221K 115K
矢量4.0.1 jar包2.3M(包括资源文件1.1M),so库1.3M(armV7a) 2.2M(armv7a)

ios:


代码段
栅格1.2.7 321K(arm64)
矢量4.0.0 1490K (arm64)

从王者系统的第一期需求效果图来看,2D 版本的地图是完全可以满足的。而王者对于包大小也有严格的要求。


基于此,我们把地图支持的项目目标定义为:王者荣耀提供基于 2D 效果的轻量级游戏解决方案。


   2.2 技术方案选型


   2.2.1 第一阶段  原生View挂载可行性分析


明确了使用 2D 地图 sdk 对外输出后,需要解决的是如何将两个平台 ( Android 和ios )的原生 View 和 Unity 的 View 结合在一起。


Unity 与原生的 andorid 和 ios 相互调用,在技术上是可行的。之前王者内部是有一些页面由各个团队提供的原生 view 支持主要是一些独立的 webview 页面,如英雄故事,王者规则等


   2.2.1.1 Android可行性分析 


Android一般情况有三种方式实现地图:

1)启动新的 Activty,展示一个全新的页面;

2)使用 WindowManager,在游戏 Activity 之上显示一个新页面;

3)加载原生 View,需要将原始View挂载到游戏 Activity 之上。


第一种方案一开始就被pass了。由于已明确了 Unity 业务逻辑,上层负责 UI 展示,而展示地图时,Unity 侧还需要进行一些逻辑处理。新起一个 Activity, 在体验上和逻辑上都行不通。


第二个方案和第三个方案原则上都行得通,两种方案也都做了验证。本文介绍的是第三种方案。


原理如下:

public class UnityPlayerNativeActivity extends NativeActivity  
{
    protected UnityPlayer mUnityPlayer; // don't change the name of this variable; referenced from native code
    // Setup activity layout
    @Override protected void onCreate (Bundle savedInstanceState)  
    
{
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        super.onCreate(savedInstanceState);
        getWindow().takeSurface(null);
        setTheme(android.R.style.Theme_NoTitleBar_Fullscreen);
        getWindow().setFormat(PixelFormat.RGB_565);
        mUnityPlayer = new UnityPlayer(this);
        if (mUnityPlayer.getSettings ().getBoolean ("hide_status_bar", true))
            getWindow ().setFlags (WindowManager.LayoutParams.FLAG_FULLSCREEN,
                                   WindowManager.LayoutParams.FLAG_FULLSCREEN);
  
        setContentView(mUnityPlayer);
        mUnityPlayer.requestFocus();
    }
    ..............................
}


这个是 Android 中 Unity 中 Activity 的基类,而 mUnityPlayer 也是通过 setContentView 加载的,也就是加载到 DecorView 上。所以只需要再将 Native 的View 加载上去就可以了:


ViewGroup rootView = (ViewGroup)activity.getWindow().getDecorView();
ViewGroup.LayoutParams param = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT);
rootView.addView(mView, param);

   2.2.1.2  ios可行性分析

ios 侧可以通过将原生View挂载在地图的 Window 上。

/**
 * 获取场景挂载点 (keyWindow)
 * @return 挂载点
 */

+ (UIView *)getMountPoint{
    UIWindow *window = [UIApplication sharedApplication].keyWindow;
    NSAssert(window != nil, @"window must not be nil");
    return window;
}
/**
 * 挂载到 keywindow.
 */

- (void)mount{
    UIView *mountPoint = [[self class] getMountPoint];
    NSAssert([self underlyingView].superview == nil , @"scene super view must be nil");
    [mountPoint addSubview:[self underlyingView]];
}

以上方案均在 Unity 侧验证通过。


   2.2.2  第二阶段  View层级关系


从整体需求来看,上层不仅仅是一个单独的地图,还要有很多的 UI 元素:



那么上面的按钮、其它元素如何去做呢?


理想的方案由地图单纯的提供地图以及地图上的标注元素,上面的元素仍然由Unity 侧进行绘制。这样只需要将地图的显示插入到 Unity 的层级中。可以看一下Unity 的原理。


Android 侧由于 Activity 加载的是 UnityPlayer,这里可以看一下 UnityPlayer 的代码:


private SurfaceView n; 
public class UnityPlayer extends FrameLayout implements
    com.unity3d.player.a.aaa 
{
  public UnityPlayer(final ContextWrapper m) {
  super((Context) m);
        this.n = new SurfaceView((Context) m);
  this.n.getHolder().addCallback((SurfaceHolder.Callback) new SurfaceHolder.Callback() {
          public final void surfaceCreated{
          //缺省**************************
          }
          public final void surfaceChanged{
            //缺省**************************
          }
          public final void surfaceDestroyed(final SurfaceHolder surfaceHolder) {
//缺省**************************
                                        }
        });
    this.n.setFocusable(true);
    this.n.setFocusableInTouchMode(true);
                //缺省**************************
          }
}

其实内部的 Unity 在渲染原理上是一个 SurfaceView 。比较容易理解,由于地图渲染使用的是普通的 sdk,和view层级不在一个级别,而且要将原生的 View 放到 SurfaceView 下面进行展示,也是做不到的,起初有一个很好的比喻可以解释,SurfaceView 会将屏幕扣出一个洞,然后进行绘制,因此只要这块区域通过SurfaceView 进行了绘制,普通View就没办法进行渲染了。


如果是 SurfaceView,基于 OPenGl 渲染的 3D 地图 sdk 就成了可选方案,但需要解决如何将 Unity 和 Native 两层渲染打通,这里会涉及到大量的改动以及接口封装,考虑到方案调研和研发的时间成本以及包大小的控制,前期不必在这个方案上做深究。可以得出如下结论:


如果上次使用原生的 View 进行地图渲染,那么在此地图上的所有 UI 元素,都必须使用原生 View 进行绘制


   2.2.3  第三阶段  技术设计原则


确认了上层都使用了端上的原生 View 进行绘制,那么这次的需求就不再只是地图的能力支持了。而需要考虑到业务逻辑的变化性,将王者层和地图平台层进行明确划分:地图平台团队负责 UI 渲染部分,王者团队负责具体的产品交互和业务逻辑,阿波罗团队负责 Native 和 Unity 之间的桥接中转。



这样,就存在了 Unity 调用原生 Android/ios 以及 Native 调用 Unity 的一系列调用。阿波罗团队将会承担中间的通道中大量的中转任务。中转过程中,涉及大量的数据结构。一旦结构发生变化,就需要 Unity 以及原生的 Android 和 ios 平台进行数据格式的调整。


为了降低维护三个平台数据结构的复杂度,同事们提出引入 JCE 作为 Unity 和Android/ios 的数据结构头文件。结合公司内部的JCE语法和编译平台,就可以做到维护一份标记语言。


加入 JCE 后,就可以彻底把阿波罗团队解放出来,使其更专注于数据通道的实现,改变后的三层结构是这样的:



   2.2.4  第四阶段  技术方案确认



讨论完可行性和数据交互协议以后,团队一开始就是准备上层按照王者的具体需求去实现 UI 展示效果,然后数据由王者来填充。


这个阶段时间不长,王者团队又提出:能否定制一些按钮的显示位置,文字大小等。毕竟很多时候需求会有变化,这就涉及到一个思考:


为什么要定制 UI?为什么不做一套通用的UI框架来实现王者的需求?


开始这样思考的时候,已经按照之前的计划排期了。整体的研发为三周时间,第一周完成首页面的开发,后面两周都做联调。做整体架构和具体实现也就只有一周时间。


我仍记得这个场景:当时我们团队几个人到了会议室,已是某个周五下午的5点多,整个会议室充斥着沿这个思路去策划方案的兴奋!没有理由不去做。


我和同事放下「狠话」说:“做不出来,晚上不回去!”结果,我们奋战到第二天日出


为了纪念那个周五晚上,我们把这个方案名叫做 Friday。




03



项目架构设计

由于目标从实现具体的页面,转向实现一套跨平台的 UI 框架,那么就需要考虑这套 UI 框架如何去定义、去创建。


   3.1 整体架构


整体架构和最初的思路没有太大出入,地图团队提供一套完整的 native UI 框架以及实际的渲染方案,王者团队负责业务逻辑以及绘制逻辑,而作为通道的阿波罗团队负责数据中转。



   3.2 UI框架


关于如何设计 UI 框架、Android、ios、react native、小程序等等,市面上很多事物都有一套设计规则。本人的理念是源于一本书(记得好像是一本杂志)



当你打开这本书,你就进入了这个为你贴身打造的场景(Scene),每一页(Page)都是为你定制的内容,有文字(Label)、图片(ImageView)、各种图文混排的组合......


因此,我们将整体的 UI 分为三层:Scene、Page、View 控件



Scene 场景:Friday Engine 提供一个场景,所有的UI展示都在该场景中。Page 页面:Scene中可以添加多个 Page。可以是全屏的,也可以自定义大小。View 控件:在每个 Page 中可以添加多个View控件,来实现实际展示效果。目前包括:Label、Button、ImageView、MapView、Tableiew、LoadingView、TextBox 等。

坐标系:

有了这三层框架,下一步就是如何将View控件放到指定的位置,这就需要有标准的坐标系。整体的坐标系定位是基于父元素左上角为(0,0)的点。有了坐标系,想把控件放到位置,还需要知道这个控件的大小,因此,需要有控件的宽高:


Z轴:

有了坐标系和 view 宽高,控件就可以绘制到指定区域了,但出现的层级关系如何处理,谁在上谁在下呢?这就需要纵向层级属性:ZIndex。如图,地图在下方,其他元素在上方。



控件以 ZIndex 为 order 确认纵向层级,同一层级的控件按照显示范围依次绘制。存在遮挡区域的不同控件,通过设定不同的 Z 轴 index 进行层级划分,默认为 0,越往上数值越高。
View 控件集合:
控件 含义
UKLabel 文本控件
UKButton 按钮控件
UKImageView 图片控件
UKMapView 地图控件
UKTableView 列表控件
UKCheckbox 单选框控件
UKLoadingView loading控件
UKViewGroup View组合控件

并且定义了 View 的通用属性:
属性 含义
id 文本控件
rect 显示区域
backgroundcolor 背景色值
UKMapView 地图控件
zIndex 纵向index
invisible 是否隐藏

这里有一个有意思的点。通用属性里有一行:invisible、bool 值。含义是:是否隐藏。


这里没有用 visible:是否显示。简单解释一下,中间通过 JCE 数据格式进行数据传输,默认不填数据,bool 值默认是 false。那么假设这里设置的是 visible,而用户没有设置该属性的话,值就默认是  false,不显示了。这可不是用户想要的,用户还是希望默认是显示的。于是便有了这样的定义。


下面是一个文本控件的JCE格式示例:


struct UKLabel {
    // View通用属性
    0 require UKInt id; //唯一标示
    1 optional UKInt zIndex;//z轴索引
    2 require UKRect rect;//显示区域,坐标,宽高
    3 optional UKBool invisible;//是否隐藏
    4 optional UKColor backgroundColor;//背景色
    
    // 文本属性.
    5 optional UKString text; //文本
    6 optional UKColor textColor; //文本颜色
    7 optional UKColor highlightedTextColor;//高亮时的颜色
    8 optional UKFont font;//字体
    9 optional UKTextAlignment textAlignment;//文本位置,居中,居左等
    10 optional UKEllipsis ellipsis;//文本省略方式
  };


   3.3 数据传输


UI框架大致如上,而如何将这一整套框架运转起来呢——数据驱


设想一下荣耀页面整体的运转流程:

王者用户点击荣耀战区,会进入荣耀地图页面。那么这时候,需要进入该场景,也就需要创建一个 Scene。然后需要加载一个页面,就是一个Page。之后在 Page 上添加地图 View、添加按钮、添加图片、添加文字等元素。经过这些元素的添加,整个页面就显示出来了。

然后,接受用户的事件,譬如说一个按钮的点击,点击事件获取到以后,就需要进行下一步的处理,譬如修改某个文本,设置某个图片的元素等等,也就是会继续向该框架发送下一个指令。


总结来说,要做两件事:Unity Friday 发送指令,Friday 将用户事件回调给 Unity。这两件事情可以归纳为:方法调用和事件回调


这里要解决两个问题:

1)如何通过数据完成方法调用和事件回调?

2)如何找到对应的调用对象?


   3.3.1 方法调用


举一个例子,设置文本控件的文字,正常的方法调用是这样的:


class UKLabel{
   /**
     设置文本
   */

   public void setText(UKString text){
     if(text != null){
          setText(text.getval())
     }
   }
}

UKLabel label = new UKLabel();
label.setText("hello world");


那么如何去解决呢,方法如下:



如上图所示,方法名对应数据的变量名,参数对应数据的参数值,参数类型就对应的是数据的参数类型,是否被调用就对应变量值是否为空,这样就完成了一个普通方法的调用。


下一个问题:多个参数如何处理?既然参数类型对应的是变量类型,那么多个参数只要设计一个结构体进行存储即可。


按照这套规则,我们可以看到,同时可以有多个方法被调用。这大大增加了使用的灵活性,减少冗余数据的出现。而顺序则是按照既定或协商好的顺序执行。


方法可以调用了,接下来就是修改文本,但修改哪一个文本控件的文字呢?


这就需要找到指定的文本控件。如前面的 Label 的 JCE 数据所示,所有的 View 控件都是有一个 id 的,而且所有的 View id 要求必须唯一,而且 id 的规则是由外部调用者(王者)决定的。这就解决了方法调用对象的问题,通过 id 索引,找到对应的View控件,从而调用到该控件支持的方法,完成完整的方法调用。


因此,一个方法调用包括两部分:方法目标Target)、方法体(Method)


Target 中包含该对象的 id,方法体包含具体的方法数据。而这里还需要解决一个问题,因为拿到的数据虽然有对象,通过对象也能知道该对象的类型,并且拿到该对象类型支持的方法类型,也能把方法体解析出来。但为了方便,还是直接将方法类型封装在 target 里,便于快速解析,如:



由于所有数据都进行了 JCE 格式的压缩,数据以二进制的形式通过阿波罗团队在Unity 和 Friday 之间传递,对外暴露的接口在 android 侧是下面这个样子:


/**
     * 对外调用接口
     * @param target
     * 消息目标,jce格式化后的数据
     * @param method
     * 数据参数,jce格式化后的数据
     * */

    public void call(byte[] target, byte[] method);

所有的方法调用都是通过该通道传输。


   3.3.2 事件回调


方法调用完成后,另一块就是看各种事件如何传递给 Unity 侧。如一个点击事件:一个TableView 的某一项被点击、CheckBox 某一项被选中、某个地图上的标注被点击等等。


如何构造回调事件,需要解决两个问题:

1)是谁发生了点击或状态变化2)发生的变化是什么

关于1):因为每个对象都有了唯一的标识,所以向外输出时,可以将该id对外发布。而为了外部解析的便捷,也将回调的对象类型和数据类型一起回调给 Unity。

示例如下:


struct UKCallbackTarget {
  
  0 require UKInt targetID;//回调时间的id
  
  1 require UKTargetType targetType;//回调的对象类型 如:Button,TableView
  
  2 require UKCallbackType callbackType;//回调数据类型,如点击或者状态变化
};

关于2):对应不同的点击事件,定义了不同的回调类型,并且将所需的数据封装起来一起回传。如 TableView 的点击回调数据类型,需要回调 Unity 哪一条被点击:


struct UKTableViewCallbackData_Clicked {
  0 require UKInt idx; //被点击的item的index
};

其他的回调也都是类似,同方法调用,回调对外提供的接口为:


/**
     * 回调,目前支持点击回调,或者事件回调
     * @param target
     * 回调事件对象
     * @param @data
     * 回调事件数据
     * */

    public void callback(byte[] target, byte[] data);

而阿波罗团队只需要将方法调用和事件回调中的两份数据传递给王者团队,即可完成通道作用。


   3.4 小结


通过UI的框架和方法的调用以及回调系统的设计和研发,整体的设计架构也就基本搭建完成了,剩下的就是不同UI控件的具体实现和接口输出了。这一部分是在第一周研发的前期完成,包括文本、图片、TableView、按钮等控件等,通过这些已经可以基本模拟出王者第一个页面的显示。第一周的研发工作也基本告一段落,下一步就是”开赴成都,与王者团队会师“!




04


遇到的问题和解决方案

第一周时,团队准备了详细的设计方案和使用文档,以为可以轻轻松松去联调了。结果还是遇到了很多问题。


   4.1 三端坐标系统一

 

Untiy 有自己的一套坐标系,拿到的坐标系在 Android 侧既不是 dp 也不是像素,在 ios也是一样。当时自己和同事的第一反应是找一下 Unity 的坐标系原理,确认其和端上的转换关系,只有这样才能把控件绘制到王者游戏中想要的位置。


我们在不同的设备上测试了一下,没有找到什么规律,也查找了 Unity 坐标相关的文档,短时间内没有找到解决问题的思路。Andorid 和 ios 建立的都是以像素为单位的坐标系,如果寄希望于上层 Unity 以终端的设备为单位的坐标系去设置所有控件的宽高、位置等属性,对于 Unity 是很大的负担。


但无论坐标系是怎么样的,都是一个基于平面的坐标系,而屏幕宽高比是一致的。如王者在 Vivo XPlay5 获取的屏幕宽高(横屏)是:

size: {
  width: {
    val: 1280
  }
  height: {
    val: 720
  }
}

而终端通过以下代码获取屏幕宽高:

WindowManager wm = this.getWindowManager();
ScreenUtils.width = wm.getDefaultDisplay().getWidth();
ScreenUtils.height = wm.getDefaultDisplay().getHeight();

结果:width:2560;height:1440;手机屏幕密度是 3.5


由于王者所有的UI元素都是基于范围为1280*720的坐标系建立的,而手机端的显示都是基于2560*1440的坐标系建立的,但比例是一样的,只需要将所有的坐标做一个比例映射就可以解决。


   4.2 Android 点击事件处理


   4.2.1 原生 View 无法获取焦点


在加载 Android 原生 View 后会出现一个问题,从UI层级上看,原生页面在上,Unity 页面在下,但上层却没有收到点击事件。经过和阿波罗团队的沟通,得出了解决问题的思路和方案:


我们知道,Android 程序都是运行在 dalvik/art 虚拟机上的,而 Unity 程序是运行在(mono/il2cpp)上。当一个Unity应用想要用到 Andorid 的方法时,毫无疑问,这个应用就需要两套虚拟机同时运行,即两个虚拟机运行在同一个进程中。 那么,Unity 与 Android 之间的交互,其实就是两个 VM 之间的相互调用,如下图:如上图所示,Unity 通过 UnityEngine 提供的 API 调用 Android 的方法;Android 借助 com.unity.player 包提供的 API 调用 Unity 的方法。

点击事件是先由 Unity 侧先收到,如果需要传递到 Android 侧,可以设置:统一转发机制允许将事件传播到 DalvikVM。需在AndroidManifest.xml 文件中的 activity 子节点下增加如下两行代码。


<meta-data android:name="android.app.lib_name" android:value="unity" /> 
<meta-data android:name="unityplayer.ForwardNativeEventsToDalvik" android:value="true" />

通过此方式,将点击事件传递到 Android 侧。点击传递如下:



这样 Android 侧的 View 就可以接收到事件了。


   4.2.2 Unity侧点击事件处理


通过以上方法解决了 Andorid 侧无法获取点击事件的问题,但如上图所示,Unity 侧还是会收到事件,这样会触发一些 Unity 的点击逻辑。这是所有人都不希望的,最后在王者团队和阿波罗团队讨论后,采用 Unity 官方论坛的一条解答方案对此问题进行了解决:


在展示 android  页面时,在 Unity 侧添加一个蒙版,Untiy 此时不处理该事件,而是直接转移到 Android 侧。来源:


http://answers.unity3d.com/questions/829687/android-plugin-touch-issues-with-forwardnativeeven.htmlThe answer goes the same as in this question:"You have two possible solutions here:create a separate window for your view, for example a Dialog;create your own activity which shows your view.The reason is that UnityPlayerNativeActivity consumes all these events, because the input loop is associated with Unity window."

   4.3 Android沉浸式问题处理


王者在 Andorid 侧采用了沉浸式模式,沉浸式在显示界面上,默认情况下是全屏的,状态栏和导航栏都不会显示。而当需要用到状态栏或导航栏时,只需要在屏幕顶部向下拉,或者在屏幕右侧向左拉,状态栏和导航栏才会显示出来,此时界面上任何元素的显示或大小都不会受影响。过一段时间后如果没有任何操作,状态栏和导航栏又会自动隐藏起来,重新回到全屏状态。


举例来说非沉浸式,部分沉浸式(状态栏隐藏),完全沉浸式:


很多 Android 手机是有虚拟按键的,但效果上打开王者荣耀的效果,边缘的虚拟按键以及顶部的状态栏都是不显示的。这里有两个小细节,如下:


  • 屏幕宽高

获取屏幕宽高,一开始是通过上面提到的方法获得:


WindowManager wm = this.getWindowManager();
ScreenUtils.width = wm.getDefaultDisplay().getWidth();
ScreenUtils.height = wm.getDefaultDisplay().getHeight();

在王者没有设置沉浸式模式的时候,是没有问题的。但该宽高是不包括虚拟按键的宽高的,这就导致王者在设置沉浸式以后,出现显示不全屏的问题,边上少了一块。


那我们看一下如何设置沉浸模式:


public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    public void onWindowFocusChanged(boolean hasFocus) {
        super.onWindowFocusChanged(hasFocus);
        if (hasFocus && Build.VERSION.SDK_INT >= 19) {
            View decorView = getWindow().getDecorView();
            decorView.setSystemUiVisibility(
                View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                | View.SYSTEM_UI_FLAG_FULLSCREEN
                | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
        }
    }

}

其实也是通过 Activity 的 DecorView 进行设置的沉浸模式,那 DecorView 的宽高肯定在该处也会变成全屏大小了,通过测试确实如此,由此也解决了显示少一部分区域的问题。


  • WindowManager

现在王者荣耀里有很多其他的原生页面Android/ios,使用的是 webview 进行显示独立的信息。譬如说英雄传说,世界起源等页面,在目前的展示上似乎没有达到沉浸式的效果,这里方法上根据一些相关团队的研发介绍,应该是通过WindowManager 的方式添加的,做了一些测试,但没有达到需要的效果。


以下是通过添加 WindowManager 的方法:


WindowManager windowManager = activity.getWindowManager();
            if (mScene.getParent() != null) {
                windowManager.removeView(mScene);
            }
            try {
                windowManager.addView(mScene, params);
            } catch (WindowManager.BadTokenException e) {
                e.printStackTrace();
            } catch (WindowManager.InvalidDisplayException e) {
                e.printStackTrace();
            }

public WindowManager.LayoutParams createLayoutParams(int left, int top, int width, int height) {
        WindowManager.LayoutParams windowParams = new WindowManager.LayoutParams();
        windowParams.gravity = Gravity.LEFT | Gravity.TOP;
        windowParams.flags = windowParams.flags
                | WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES
                | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
                | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
                | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
                | WindowManager.LayoutParams.FLAG_FULLSCREEN
                | WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS
                | WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION;
        windowParams.width = width;
        windowParams.height = height;
        windowParams.x = left;
        windowParams.y = top;
        windowParams.format = PixelFormat.TRANSLUCENT;
        windowParams.softInputMode |= WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION;
        if (mActivityReference.get() != null) {
            windowParams.type = WindowManager.LayoutParams.TYPE_APPLICATION;
        } else {
            windowParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
        }
        windowParams.systemUiVisibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;

        return windowParams;
    }

添加的方式我们请教了相关开发人员。后面添加了些代码,想以此去解决虚拟按键显示的问题,如上图所示,进行了一些尝试:


WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION;

windowParams.systemUiVisibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;

这起到了一定的效果,但在有虚拟按键的手机上,进入页面后会先闪一下虚拟键盘然后消失,体验上不够好。我们通过 DecorView 方式进行添加,则不存在该问题,因此,也就没有更换方案。


这里还是蛮有意思的,感兴趣的开发者可以想一下解决方案。WindowManager 的方案是不需要考虑点击事件传递的,这一点对于方案来说应该是更方便,方案迁移上也更好。


   4.4 Android 点9图功能支持


这个课题很有意思,如何将一张普通图片以点 9 的形式提供拉伸、缩放的能力?


Unity 里提供了大量的类似使用方式,只提供普通图和拉伸点,来实现拉伸效果。这种方式也很快在 ios 里得到了验证和实现。而在 android 里,如何做到这种效果呢?


一张普通的图如何实现点9的效果,网上的解答基本都是从 NinePatch 的原理讲起,反向推导输出方案。


这一块其实可以看一下点9图的编译过程,也是很有意思。最后编译后的图并不是点 9,而是一张 png 图片,并且携带了 ninepatchConfig 的信息。那么此时的思路其实就是伪造一份 NinePatchConfig,就可以实现普通图的效果了。 


再看 NinePatchDrawable 的构造方法:


/**
     * Create drawable from raw nine-patch data, setting initial target density
     * based on the display metrics of the resources.
     */

    public NinePatchDrawable(Resources res, Bitmap bitmap, byte[] chunk,
            Rect padding, String srcName
)
{
        this(new NinePatchState(new NinePatch(bitmap, chunk, srcName), padding), res);
    }

其实,支持这一思路的可行性,只需要构造 chunk 的二进制流,就可以伪装成点 9 图的效果。


拿到一张点 9 图,android 是通过 NinePatch 进行处理,点 9 图无非是在普通图上打几个点,作为拉伸的依据,即 NinePatchConfig,然后交由 Native 层进行处理,NInePatch 的代码不多:


// NinePatch chunk.
class NinePatchChunk {

    public static final int NO_COLOR = 0x00000001;
    public static final int TRANSPARENT_COLOR = 0x00000000;

    public Rect mPaddings = new Rect();

    public int mDivX[];
    public int mDivY[];
    public int mColor[];

    private static void readIntArray(int[] data, ByteBuffer buffer) {
        for (int i = 0, n = data.length; i < n; ++i) {
            data[i] = buffer.getInt();
        }
    }

    private static void checkDivCount(int length) {
        if (length == 0 || (length & 0x01) != 0) {
            throw new RuntimeException("invalid nine-patch: " + length);
        }
    }

    public static NinePatchChunk deserialize(byte[] data) {
        ByteBuffer byteBuffer =
                ByteBuffer.wrap(data).order(ByteOrder.nativeOrder());

        byte wasSerialized = byteBuffer.get();
        if (wasSerialized == 0) return null;

        NinePatchChunk chunk = new NinePatchChunk();
        chunk.mDivX = new int[byteBuffer.get()];
        chunk.mDivY = new int[byteBuffer.get()];
        chunk.mColor = new int[byteBuffer.get()];

        checkDivCount(chunk.mDivX.length);
        checkDivCount(chunk.mDivY.length);

        // skip 8 bytes
        byteBuffer.getInt();
        byteBuffer.getInt();

        chunk.mPaddings.left = byteBuffer.getInt();
        chunk.mPaddings.right = byteBuffer.getInt();
        chunk.mPaddings.top = byteBuffer.getInt();
        chunk.mPaddings.bottom = byteBuffer.getInt();

        // skip 4 bytes
        byteBuffer.getInt();

        readIntArray(chunk.mDivX, byteBuffer);
        readIntArray(chunk.mDivY, byteBuffer);
        readIntArray(chunk.mColor, byteBuffer);

        return chunk;
    }
}

由此反向寻求解决方案,将打的上下左右的点去反推二进制数据的构造方法。但实际使用时,没有达到理想的效果。上面两个开源项目是 StackOverflow 里提的比较多的,第二个开源项目中的核心代码:


public class NinePatchBitmapFactory {

    // The 9 patch segment is not a solid color.
    private static final int NO_COLOR = 0x00000001;

    // The 9 patch segment is completely transparent.
    private static final int TRANSPARENT_COLOR = 0x00000000;

    public static NinePatchDrawable createNinePathWithCapInsets(Resources res, Bitmap bitmap, int top, int left, int bottom, int right, String srcName) {
        ByteBuffer buffer = getByteBuffer(top, left, bottom, right);
        NinePatchDrawable drawable = new NinePatchDrawable(res, bitmap, buffer.array(), new Rect(), srcName);
        return drawable;
    }

    public static NinePatch createNinePatch(Resources res, Bitmap bitmap, int top, int left, int bottom, int right, String srcName) {
        ByteBuffer buffer = getByteBuffer(top, left, bottom, right);
        NinePatch patch = new NinePatch(bitmap, buffer.array(), srcName);
        return patch;
    }

    private static ByteBuffer getByteBuffer(int top, int left, int bottom, int right) {
        //Docs check the NinePatchChunkFile
        ByteBuffer buffer = ByteBuffer.allocate(56).order(ByteOrder.nativeOrder());
        //was translated
        buffer.put((byte)0x01);
        //divx size
        buffer.put((byte)0x02);
        //divy size
        buffer.put((byte)0x02);
        //color size
        buffer.put(( byte)0x02);

        //skip
        buffer.putInt(0);
        buffer.putInt(0);

        //padding
        buffer.putInt(0);
        buffer.putInt(0);
        buffer.putInt(0);
        buffer.putInt(0);

        //skip 4 bytes
        buffer.putInt(0);

        buffer.putInt(left);
        buffer.putInt(right);
        buffer.putInt(top);
        buffer.putInt(bottom);
        buffer.putInt(NO_COLOR);
        buffer.putInt(NO_COLOR);

        return buffer;
    }

}

我们看一个简单的示例:



原图是一个 144*72 的 png 图片,我们希望达到的点 9 效果:



希望作为按钮去实现该效果,可以先实现横向的拉伸效果,按照中间显示的区域做拉伸。通过以上代码达到的效果如下:



如图所示,点9图是我们的目标效果,直接拉伸会造成图片虚缈,不符合要求。而通过以上开源代码得到的效果周边似乎少了一圈,虽然看上去没有任何拉伸变虚的问题,但也不符合要求。


如何解决这个问题?仿佛是很棘手的事情。这是在王者荣耀开发第一周时遇到的。当时本着先实现效果的目标,再另找方法。


思路:点 9 无非是根据拉伸点(本文涉及的是两个拉伸点),将一张图分成九块,每块做不同的处理。



边缘四个角不做变化,中上,中下,左中,右中,以及中部做不同的处理,以达到拉伸效果。这部分研发复杂度偏高,没有达到完美的效果。


还是要重新跟进源码。继续看 NinePatchDrawable 的源码:


/**
     * Set the density scale at which this drawable will be rendered. This
     * method assumes the drawable will be rendered at the same density as the
     * specified canvas.
     *
     * @param canvas The Canvas from which the density scale must be obtained.
     *
     * @see android.graphics.Bitmap#setDensity(int)
     * @see android.graphics.Bitmap#getDensity()
     */

    public void setTargetDensity(@NonNull Canvas canvas) {
        setTargetDensity(canvas.getDensity());
    }

    /**
     * Set the density scale at which this drawable will be rendered.
     *
     * @param metrics The DisplayMetrics indicating the density scale for this drawable.
     *
     * @see android.graphics.Bitmap#setDensity(int)
     * @see android.graphics.Bitmap#getDensity()
     */

    public void setTargetDensity(@NonNull DisplayMetrics metrics) {
        setTargetDensity(metrics.densityDpi);
    }

    /**
     * Set the density at which this drawable will be rendered.
     *
     * @param density The density scale for this drawable.
     *
     * @see android.graphics.Bitmap#setDensity(int)
     * @see android.graphics.Bitmap#getDensity()
     */

    public void setTargetDensity(int density) {
        if (density == 0) {
            density = DisplayMetrics.DENSITY_DEFAULT;
        }

        if (mTargetDensity != density) {
            mTargetDensity = density;

            computeBitmapSize();
            invalidateSelf();
        }
    }

而在绘制的时候:


@Override
    public void draw(Canvas canvas) {
        final NinePatchState state = mNinePatchState;

        Rect bounds = getBounds();
        int restoreToCount = -1;

        final boolean clearColorFilter;
        if (mTintFilter != null && getPaint().getColorFilter() == null) {
            mPaint.setColorFilter(mTintFilter);
            clearColorFilter = true;
        } else {
            clearColorFilter = false;
        }

        final int restoreAlpha;
        if (state.mBaseAlpha != 1.0f) {
            restoreAlpha = getPaint().getAlpha();
            mPaint.setAlpha((int) (restoreAlpha * state.mBaseAlpha + 0.5f));
        } else {
            restoreAlpha = -1;
        }

        final boolean needsDensityScaling = canvas.getDensity() == 0;
        if (needsDensityScaling) {
            restoreToCount = restoreToCount >= 0 ? restoreToCount : canvas.save();

            // Apply density scaling.
            final float scale = mTargetDensity / (float) state.mNinePatch.getDensity();
            final float px = bounds.left;
            final float py = bounds.top;
            canvas.scale(scale, scale, px, py);

            if (mTempRect == null) {
                mTempRect = new Rect();
            }

            // Scale the bounds to match.
            final Rect scaledBounds = mTempRect;
            scaledBounds.left = bounds.left;
            scaledBounds.top = bounds.top;
            scaledBounds.right = bounds.left + Math.round(bounds.width() / scale);
            scaledBounds.bottom = bounds.top + Math.round(bounds.height() / scale);
            bounds = scaledBounds;
        }

        final boolean needsMirroring = needsMirroring();
        if (needsMirroring) {
            restoreToCount = restoreToCount >= 0 ? restoreToCount : canvas.save();

            // Mirror the 9patch.
            final float cx = (bounds.left + bounds.right) / 2.0f;
            final float cy = (bounds.top + bounds.bottom) / 2.0f;
            canvas.scale(-1.0f, 1.0f, cx, cy);
        }

        state.mNinePatch.draw(canvas, bounds, mPaint);

        if (restoreToCount >= 0) {
            canvas.restoreToCount(restoreToCount);
        }

        if (clearColorFilter) {
            mPaint.setColorFilter(null);
        }

        if (restoreAlpha >= 0) {
            mPaint.setAlpha(restoreAlpha);
        }
    }

明显使用了 Density 的属性进行了绘制,于是开发人员对原有的代码进行了修改,加入了屏幕密度的修改:


float density = (context.getResources().getDisplayMetrics().density);
       
 Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, (int)(bitmap.getWidth() * density), (int)(bitmap.getHeight() * density), true);
        
 ByteBuffer buffer = getByteBufferFixed((int)(top * density), (int)(left * density), (int)(bottom * density), (int)(right * density));
 NinePatchDrawable drawable = new NinePatchDrawable(context.getResources(), scaledBitmap, buffer.array(), new Rect(), null);

改进后,得到的效果如下:



通过加入 density 属性,完美解决了边缘处效果的问题,结论就是还是要检查源码。


这里我提一个问题:以上的方案解决了图片拉伸的问题,那如果该图片需要做压缩,该如何处理呢?有兴趣的开发者可以思考一下~


   4.5 联调流程优化


由于只提供了 Android 和 ios 的库,所以就出现一个问题,Unity 的研发无法在Windows 上进行调试。而出现问题也不能很方便的走查。编译一次端上的包,需要一到两个小时,一个小问题也很难快速解决。每次尝试修改,都需要重新打包。


跟进问题方法打印 log,起初就是通过打印一些必要的 log 跟进问题,然后遍包查 log。


第一次改进,通过调试走查问题当时通过编译的包,在 ios 上对 C#编译后的C代码进行 Debug 调试,以此来跟进问题的具体原因,减少了编码次数。


第二次改进,数据还原这个问题还是得想办法解决,思路源自数据协议。


这一套基于数据的渲染引擎,只是让王者生成了数据,而数据只是通过阿波罗团队转接一次。那到 Android 和 ios 侧就可以还原出来,那完全不需要编包才能做。


在这里进行一个尝试,写一个 Demo,在 Windows 上编译生成数据以及资源文件,交给 Android 侧,通过,就直接将二进制的文件进行解析并将页面还原出来,这样就规避掉了编译的过程、快速的走查调用时可能产生的问题。


而在跟进问题时,也可以通过记录文件、还原文件进行 Debug。这样 Debug 就变成了一个很简单的 Android Demo 的项目了,更加快速便捷。


Demo 示意图(点击显示view后显示王者界面):



通过一系列的改进,从一开始查问题需要 1-2 个小时甚至更长,到最后大约 10 分钟左右就可以搞定,而且一次还可以查多个问题。





05


总结

王者地图支持项目是一个充满故事的项目。经过和其他王者开发人员的并肩作战,也确实领会到了一个产品是如何走到今天的。能够感受那种氛围,也是一个不错的体验。


整个项目从可行性分析,到第一周研发准备,再到去成都进行联调和最核心部分的开发,这段时间总共只有四周的时间。而涉及的人员也相当的多,期间得到了很多王者开发人员的支持和帮助,非常感谢一路走来的战友们!


如喜欢本文,请点击右上角,把文章分享到朋友圈
如有想了解学习的技术点,请留言给若飞安排分享

因公众号更改推送规则,请点“在看”并加“星标”第一时间获取精彩技术分享

·END·

相关阅读:

作者:尚建

来源:腾讯云开发者

版权申明:内容来源网络,仅供学习研究,版权归原创者所有。如有侵权烦请告知,我们会立即删除并表示歉意。谢谢!

架构师

我们都是架构师!



关注架构师(JiaGouX),添加“星标”

获取每天技术干货,一起成为牛逼架构师

技术群请加若飞:1321113940 进架构师群

投稿、合作、版权等邮箱:admin@137x.com

相关推荐

  • [开源]基于SaaS模式的进销存+简单财务的系统,进销存多商户系统
  • 开源一个在线课程资料的管理系统开源项目,很不错
  • 这样的面试,不去也罢!
  • 什么时候都用微服务,只会害了你
  • 寻宝 AI 时代,OSC 邀你来苏州轰趴!
  • 优先展示冒牌货且定向至恶意软件,网友:是时候摆脱Google了
  • JSDoc 真能取代 TypeScript?
  • 陶哲轩疯狂安利Copilot:它帮我完成了一页纸证明,甚至能猜出我后面的过程
  • “我有一个大胆的想法”?Meta AI 新技术让你的思维图像一览无余!
  • Stable Diffusion新玩法火了!给几个词就能生成动图,连动图人物的表情和动作都能随意控制
  • 你从来没见过的20种口味可口可乐,看看你爱上了哪一款
  • SpringBoot 接口签名校验实践
  • 快速掌握 9 种 UML 图,5分钟上手,附10张实操案例!
  • 成都周报 | 苹果CEO库克到访,高新区将设置200亿数字经济基金
  • 动图图解马尔科夫链、PCA、贝叶斯!
  • 倒计时 1 天!1024 程序员节全日程公开(附参会指南)
  • NVIDIA Jetson助力AI教育教学与视觉感知应用创新
  • B站数据质量保障体系建设与实践
  • DeepMind:大模型又曝重大缺陷,无法自我纠正推理,除非提前得知正确答案
  • H800/A800受限牵涉「云上算力」!美正酝酿新规管制云服务