【转载】TabLayout 源码解析
1. 功能介绍
1.1 TabLayout
Tabs跟随Actionbar在Android 3.0进入大家的视线,是一个很经典的设计。它也是Material Design 规范中提及的Component
之一。Tabs or Bottom navigation?相信不少Android开发者与产品都撕过,就连微信在其中也有过抉择。Google在Google+以及Google Photo中相继采用Bottom navigation的设计把剧情推到向高潮,一度轰动整个社区。Google继而在Material Design 规范加入了Bottom navigation,表明了态度,也给这起争论画上了圆满的句号。
在 support desgin lib 发布前,大家基本都采用PagerSlidingTabStrip来实现tab效果。其实TabLayout
在实现上和PagerSlidingTabStrip
十分相似,今天我们来分析TabLayout
。
1.2 TabLayout使用
TabLayout
使用比较简单。既可以单独使用,也可以与ViewPager
配合使用。
1.2.1 TabLayout单独使用
在java代码中添加Tabs
TabLayout tabLayout = (TabLayout) findViewById(R.id.tabLayout);
tabLayout.addTab(tabLayout.newTab().setText("Tab 1"));
tabLayout.addTab(tabLayout.newTab().setText("Tab 2"));
tabLayout.addTab(tabLayout.newTab().setText("Tab 3"));
也可以在xml中添加Tabs
<android.support.design.widget.TabLayout
android:layout_height="wrap_content"
android:layout_width="match_parent"> <android.support.design.widget.TabItem
android:text="@string/tab_text"/> <android.support.design.widget.TabItem
android:icon="@drawable/ic_android"/> </android.support.design.widget.TabLayout>
1.2.2 与ViewPager搭配使用
// find view
TabLayout tabLayout = ...;
ViewPager viewPager = ...; PagerAdapter adapter = new PagerAdapter(){
// ...Override some methods
// TabLayout调用这个方法获取Tab的title
@Override
public CharSequence getPageTitle(int position) {
return "Tab 1";
}
}
viewPager.setAdapter(adapter);
tabLayout.setupWithViewPager(viewPager);
2. 总体设计
TabLayout
继承HorizontalScrollView
天生就是一个可以横向滚动的ViewGroup
. 我们知道,HorizontalScrollView
与ScrollView
一样, 最多只能包含一个子View.SlidingTabStrip
继承于LinearLayout
,是TabLayout
的内部类。它是TabLayout
唯一的子View. 所有的TabView
都是它的子View.TabView
继承于LinearLayout
,以Tab
为数据源,来展示Tab的样式。最终用for循环被add进SlidingTabStrip
.Tab
是一个简单的View Model实体类,控制TabView
的title, icon, custom layout id等属性。TabItem
继承于View. 用于在layout xml中来描述Tab. 需要注意的是,它不会add到SlidingTabStrip
中去。它的作用是从xml中获取到text,icon,custom layout id等属性。TabLayout inflate到TabItem
并获取属性到装配到Tab
中,最终add到SlidingTabStrip
中的还是TabView
.OnTabSelectedListener
是TabLayout中的内部接口,用于监听SlidingTabStrip
中子TabView
选中状态的改变。Mode
是TabLayout滚动模式的描述,一共有两种状态。MODE_FIXED
不可滚动模式,以及MODE_SCROLLABLE
可以滚动模式。Gravity
是TabView
在SlidingTabStrip
中layout方式的描述。分为:GRAVITY_FILL,GRAVITY_CENTER.
3. 详细设计
3.1 类关系图
3.2 分析
3.2.1 TabLayout子View唯一性保证
前面介绍TabLayout
继承于HorizontalScrollView
最多只能有1个子View. 但TabLayout
可以在layout中添加多个子View节点. 这是怎么回事呢?
<android.support.design.widget.TabLayout
android:layout_height="wrap_content"
android:layout_width="match_parent"> <android.support.design.widget.TabItem
android:text="@string/tab_text"/> <android.support.design.widget.TabItem
android:icon="@drawable/ic_android"/> </android.support.design.widget.TabLayout>
看过LayoutInflater
源码的同学可能会知道这个过程:先inflate到生成View对象,再调用ViewGroup#addView(...)
系列方法把view添加到ViewGroup中。我们发现TabLayout的addView(...)
系列方法,都删去super调用,且调用了共同的一个方法,addViewInternal(View view)
。
private void addViewInternal(final View child) {
if (child instanceof TabItem) {
addTabFromItemView((TabItem) child);
} else {
throw new IllegalArgumentException("Only TabItem instances can be added to TabLayout");
}
}
可见,若child非TabItem
对象会抛出异常。所以xml中给TabLayout添加tab时,只能添加TabItem
对象。若想添加其它View类型怎么办?TabItem有android:customView
这个属性。我们继续来看。
private void addTabFromItemView(@NonNull TabItem item) {
final Tab tab = newTab();
if (item.mText != null) {
tab.setText(item.mText);
}
if (item.mIcon != null) {
tab.setIcon(item.mIcon);
}
if (item.mCustomLayout != 0) {
tab.setCustomView(item.mCustomLayout);
}
addTab(tab);
} public Tab newTab() {
Tab tab = sTabPool.acquire();
if (tab == null) {
tab = new Tab();
}
tab.mParent = this;
tab.mView = createTabView(tab);
return tab;
} private TabView createTabView(@NonNull final Tab tab) {
TabView tabView = mTabViewPool != null ? mTabViewPool.acquire() : null;
if (tabView == null) {
tabView = new TabView(getContext());
}
tabView.setTab(tab);
tabView.setFocusable(true);
tabView.setMinimumWidth(getTabMinWidth());
return tabView;
}
这里调newTab()
方法创建了一个tab对象,并且用对象池把创建的tab对象缓存起来。然后将TabItem
对象的属性都赋值给tab对象。在createTabView(Tab tab)
这个方法中,首先从TabView
池中获取TabView
对象,如果不存在,则实例化一个对象,并调用tabView.setTab(tab)
方法来进行了数据绑定。 addTab(...)
有三个重载方法,最终都会调用如下方法:
public void addTab(@NonNull Tab tab, boolean setSelected) {
if (tab.mParent != this) {
throw new IllegalArgumentException("Tab belongs to a different TabLayout.");
} addTabView(tab, setSelected);
configureTab(tab, mTabs.size());
if (setSelected) {
tab.select();
}
} private void addTabView(Tab tab, int position, boolean setSelected) {
final TabView tabView = tab.mView;
mTabStrip.addView(tabView, position, createLayoutParamsForTabs());
if (setSelected) {
tabView.setSelected(true);
}
} private void configureTab(Tab tab, int position) {
tab.setPosition(position);
mTabs.add(position, tab); final int count = mTabs.size();
for (int i = position + 1; i < count; i++) {
mTabs.get(i).setPosition(i);
}
}
在addView(Tab, int, boolean)
方法中,把TabView
对象add进了SlidingTabStrip
这个ViewGroup
中。实际上SlidingTabStrip
的对象mTabStrip
才是TabLayout
的唯一子View.在TabLayout
的构造方法中:
public TabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 禁用横向滑动条
setHorizontalScrollBarEnabled(false); // new 一个'SlidingTabStrip'的实例,并作为唯一的子View add进'TabLayout'.
mTabStrip = new SlidingTabStrip(context);
super.addView(mTabStrip, 0, new HorizontalScrollView.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); // 省略下面的无关代码...
}
至此,我们就明白了TabLayout
中子View的一致性是如何保证的。也明白了TabView
其实才是亲生的,TabItem
其实是后娘养的! 这些代码都很简单,不过我们可以从中学习到很多有用的思想。
至此,一个清晰的View
层级图应该就出现在了各位同学的眼前。
3.2.2 与ViewPager搭配使用
有了上面的的基础,我们再来看看TabLayout
是如何和它的好基友ViewPager
搭配使用的。
public void setupWithViewPager(@Nullable final ViewPager viewPager) {
//...
//为理解简单起见,删掉边角性干扰代码,主要来看核心逻辑 mViewPager = viewPager; // Add our custom OnPageChangeListener to the ViewPager
if (mPageChangeListener == null) {
mPageChangeListener = new TabLayoutOnPageChangeListener(this);
}
mPageChangeListener.reset();
viewPager.addOnPageChangeListener(mPageChangeListener); // Now we'll add a tab selected listener to set ViewPager's current item
setOnTabSelectedListener(new ViewPagerOnTabSelectedListener(viewPager)); // Now we'll populate ourselves from the pager adapter
setPagerAdapter(adapter, true);
} public void setOnTabSelectedListener(OnTabSelectedListener onTabSelectedListener) {
mOnTabSelectedListener = onTabSelectedListener;
} private void setPagerAdapter(@Nullable final PagerAdapter adapter, final boolean addObserver) {
if (mPagerAdapter != null && mPagerAdapterObserver != null) {
// If we already have a PagerAdapter, unregister our observer
mPagerAdapter.unregisterDataSetObserver(mPagerAdapterObserver);
} mPagerAdapter = adapter; if (addObserver && adapter != null) {
// Register our observer on the new adapter
if (mPagerAdapterObserver == null) {
mPagerAdapterObserver = new PagerAdapterObserver();
}
adapter.registerDataSetObserver(mPagerAdapterObserver);
} // Finally make sure we reflect the new adapter
populateFromPagerAdapter();
}
这里的TabLayoutOnPageChangeListener
实现了ViewPager.OnPageChangeListener
. 首先调用ViewPager
对象addOnPageChangeListener(OnPageChangeListener)
来监听ViewPager
的滑动以及当前也的选中。然后设置ViewPagerOnTabSelectedListener
对象,保证ViewPager的页面和TabLayout的item的选中状态保持一致,以及滚动的协同性。这里的监听在3.2.3中详细讲解。
我们一般调用viewPager.getAdapter().notifyDataSetChanged()
来进行ViewPager的刷新. 现在我们在ViewPager的adapter中注册一个监听器,监听ViewPager
的刷新行为。目的是为了刷新ViewPager
的同时也可以刷新TabLayout. 我们来看看PagerAdapterObserver
这个监听器是如何刷新TabLayout的。
private class PagerAdapterObserver extends DataSetObserver {
@Override
public void onChanged() {
populateFromPagerAdapter();
} @Override
public void onInvalidated() {
populateFromPagerAdapter();
}
} private void populateFromPagerAdapter() {
removeAllTabs(); if (mPagerAdapter != null) {
final int adapterCount = mPagerAdapter.getCount();
for (int i = 0; i < adapterCount; i++) {
addTab(newTab().setText(mPagerAdapter.getPageTitle(i)), false);
} // Make sure we reflect the currently set ViewPager item
if (mViewPager != null && adapterCount > 0) {
final int curItem = mViewPager.getCurrentItem();
if (curItem != getSelectedTabPosition() && curItem < getTabCount()) {
selectTab(getTabAt(curItem));
}
}
} else {
removeAllTabs();
}
} public void removeAllTabs() {
// Remove all the views
for (int i = mTabStrip.getChildCount() - 1; i >= 0; i--) {
removeTabViewAt(i);
} for (final Iterator<Tab> i = mTabs.iterator(); i.hasNext();) {
final Tab tab = i.next();
i.remove();
tab.reset();
sTabPool.release(tab);
} mSelectedTab = null;
}
刷新方式很简单粗暴,从SlidingTabStrip
对象中移除所有的TabView
,继而从View ModelmTabs
中移除所有Tab
对象。然后从adapter中获取tab信息,循环调用addTab(Tab, boolean)
方法重新添加TabView
。最后调用ViewPager
对象的getCurrentItem()
方法,获取当前位置,然后调用selectTab(int position)恢复TabView
的选中状态(针对TabView的选中,3.2.4中有详细介绍)。
3.2.3 ViewPager与TabLayout的Tab及indicaotr协同滚动
public static class TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener {
private final WeakReference<TabLayout> mTabLayoutRef;
private int mPreviousScrollState;
private int mScrollState; public TabLayoutOnPageChangeListener(TabLayout tabLayout) {
mTabLayoutRef = new WeakReference<>(tabLayout);
} @Override
public void onPageScrollStateChanged(int state) {
mPreviousScrollState = mScrollState;
mScrollState = state;
} @Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
final TabLayout tabLayout = mTabLayoutRef.get();
if (tabLayout != null) {
// Only update the text selection if we're not settling, or we are settling after
// being dragged
final boolean updateText = mScrollState != SCROLL_STATE_SETTLING ||
mPreviousScrollState == SCROLL_STATE_DRAGGING;
// Update the indicator if we're not settling after being idle. This is caused
// from a setCurrentItem() call and will be handled by an animation from
// onPageSelected() instead.
final boolean updateIndicator = !(mScrollState == SCROLL_STATE_SETTLING
&& mPreviousScrollState == SCROLL_STATE_IDLE);
tabLayout.setScrollPosition(position, positionOffset, updateText, updateIndicator);
}
} @Override
public void onPageSelected(int position) {
final TabLayout tabLayout = mTabLayoutRef.get();
if (tabLayout != null && tabLayout.getSelectedTabPosition() != position) {
// Select the tab, only updating the indicator if we're not being dragged/settled
// (since onPageScrolled will handle that).
final boolean updateIndicator = mScrollState == SCROLL_STATE_IDLE
|| (mScrollState == SCROLL_STATE_SETTLING
&& mPreviousScrollState == SCROLL_STATE_IDLE);
tabLayout.selectTab(tabLayout.getTabAt(position), updateIndicator);
}
} private void reset() {
mPreviousScrollState = mScrollState = SCROLL_STATE_IDLE;
}
}
用过ViewPager
的同学对OnPageChangeListener
不会陌生,不多赘述。TabLayoutOnPageChangeListener
实现了OnPageChangeListener
, 在onPageScrolled(...)
方法中做协同滚动处理。滚动的条件是:
final boolean updateIndicator = !(mScrollState == SCROLL_STATE_SETTLING && mPreviousScrollState == SCROLL_STATE_IDLE);
调用TabLayout的setScrollPosition(...)
方法来控制TabLayout
中TabView
和indocator的协同滚动。
private void setScrollPosition(int position, float positionOffset, boolean updateSelectedText, boolean updateIndicatorPosition) {
final int roundedPosition = Math.round(position + positionOffset);
if (roundedPosition < 0 || roundedPosition >= mTabStrip.getChildCount()) {
return;
} // Set the indicator position, if enabled
if (updateIndicatorPosition) {
mTabStrip.setIndicatorPositionFromTabPosition(position, positionOffset);
} // Now update the scroll position, canceling any running animation
if (mScrollAnimator != null && mScrollAnimator.isRunning()) {
mScrollAnimator.cancel();
}
scrollTo(calculateScrollXForTab(position, positionOffset), 0); // Update the 'selected state' view as we scroll, if enabled
if (updateSelectedText) {
setSelectedTabView(roundedPosition);
}
}
3.2.3.1 TabLayout的Indicator协同滚动
indicator的滚动由SlidingTabStrip来处理: ``
// Set the indicator position, if enabled
if (updateIndicatorPosition) {
mTabStrip.setIndicatorPositionFromTabPosition(position, positionOffset);
}
这里的position
是当前选中的位置。positionOffset
是: 距当前Tab滑动的距离
/从当前tab滑动到下一个tab的总距离
这样一个范围在[0,1]间的小数。
SlidingTabStrip#setIndicatorPositionFromTabPosition(int, float)
void setIndicatorPositionFromTabPosition(int position, float positionOffset) {
if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) {
mIndicatorAnimator.cancel();
} mSelectedPosition = position;
mSelectionOffset = positionOffset;
updateIndicatorPosition();
}
SlidingTabStrip#updateIndicatorPosition()
private void updateIndicatorPosition() {
final View selectedTitle = getChildAt(mSelectedPosition);
int left, right; if (selectedTitle != null && selectedTitle.getWidth() > 0) {
left = selectedTitle.getLeft();
right = selectedTitle.getRight(); if (mSelectionOffset > 0f && mSelectedPosition < getChildCount() - 1) {
// Draw the selection partway between the tabs
View nextTitle = getChildAt(mSelectedPosition + 1);
left = (int) (mSelectionOffset * nextTitle.getLeft() +
(1.0f - mSelectionOffset) * left);
right = (int) (mSelectionOffset * nextTitle.getRight() +
(1.0f - mSelectionOffset) * right);
}
} else {
left = right = -1;
} setIndicatorPosition(left, right);
}
通过getChildAt(mSelectedPosition)
, 获取到到mSelectedPosition
处的TabView。若滑动的mSelectionOffset>0f
且当前选中的位置mSelectedPosition
不是最后一个TabView. 获取到下一个TabView,并计算出indicator的left和right。
SlidingTabStrip#setIndicatorPosition(int, int)
private void setIndicatorPosition(int left, int right) {
if (left != mIndicatorLeft || right != mIndicatorRight) {
// If the indicator's left/right has changed, invalidate
mIndicatorLeft = left;
mIndicatorRight = right;
ViewCompat.postInvalidateOnAnimation(this);
}
}
非常简单的代码,在调用ViewCompat.postInvalidateOnAnimation(this)
重绘View之前,去掉一些重复绘制的帧。
@Override
public void draw(Canvas canvas) {
super.draw(canvas); // Thick colored underline below the current selection
if (mIndicatorLeft >= 0 && mIndicatorRight > mIndicatorLeft) {
canvas.drawRect(mIndicatorLeft, getHeight() - mSelectedIndicatorHeight,
mIndicatorRight, getHeight(), mSelectedIndicatorPaint);
}
}
绘制逻辑很简单。调用canvas.drawRect(float left, float top, float right, float bottom, Paint paint)
来绘制indicator.这里:
left = mIndicatorLeft;
top = getHeight() - mSelectedIndicatorHeight;
right = mIndicatorRight;
bottom = getHeight();
3.2.3.2 TabLayout的TabView协同滚动
我们回头来看 3.2.3中setScrollPosition(...)
方法
private void setScrollPosition(int position, float positionOffset, boolean updateSelectedText, boolean updateIndicatorPosition) {
final int roundedPosition = Math.round(position + positionOffset);
if (roundedPosition < 0 || roundedPosition >= mTabStrip.getChildCount()) {
return;
} // Set the indicator position, if enabled
if (updateIndicatorPosition) {
mTabStrip.setIndicatorPositionFromTabPosition(position, positionOffset);
} // Now update the scroll position, canceling any running animation
if (mScrollAnimator != null && mScrollAnimator.isRunning()) {
mScrollAnimator.cancel();
}
scrollTo(calculateScrollXForTab(position, positionOffset), 0); // Update the 'selected state' view as we scroll, if enabled
if (updateSelectedText) {
setSelectedTabView(roundedPosition);
}
}
在3.2.3.1中我们知道indicator的滚动是通过mTabStrip.setIndicatorPositionFromTabPosition(position, positionOffset)
实现的。那TabView的滚动呢?我们知道TabLayout
是继承HorizonScrollView
天生就是一个可以横行滚动的View
,所以,我们只需要调用scrollTo(int x, int y)
方法就可以实现横向滚动。
scrollTo(calculateScrollXForTab(position, positionOffset), 0);
这里x方向的偏移量调用calculateScrollXForTab(position, positionOffset)
实时计算得出,y方向的偏移量为0。
private int calculateScrollXForTab(int position, float positionOffset) {
if (mMode == MODE_SCROLLABLE) {
final View selectedChild = mTabStrip.getChildAt(position);
final View nextChild = position + 1 < mTabStrip.getChildCount()
? mTabStrip.getChildAt(position + 1)
: null;
final int selectedWidth = selectedChild != null ? selectedChild.getWidth() : 0;
final int nextWidth = nextChild != null ? nextChild.getWidth() : 0; return selectedChild.getLeft()
+ ((int) ((selectedWidth + nextWidth) * positionOffset * 0.5f))
+ (selectedChild.getWidth() / 2)
- (getWidth() / 2);
}
return 0;
}
至此,我们就明白了TabLayout
是如何随ViewPager
的滚动而滚动的。
3.2.4 Tab选中状态
private void setSelectedTabView(int position) {
final int tabCount = mTabStrip.getChildCount();
if (position < tabCount && !mTabStrip.getChildAt(position).isSelected()) {
for (int i = 0; i < tabCount; i++) {
final View child = mTabStrip.getChildAt(i);
child.setSelected(i == position);
}
}
}
调用View的setSelected(boolean)
方法。
【转载】TabLayout 源码解析的更多相关文章
- jQuery整体架构源码解析(转载)
jQuery整体架构源码解析 最近一直在研读 jQuery 源码,初看源码一头雾水毫无头绪,真正静下心来细看写的真是精妙,让你感叹代码之美. 其结构明晰,高内聚.低耦合,兼具优秀的性能与便利的扩展性, ...
- 转载:Bootstrap 源码解析
Bootstrap 源码解析 前言 Bootstrap 是个CSS库,简单,高效.很多都可以忘记了再去网站查.但是有一些核心的东西需要弄懂.个人认为弄懂了这些应该就算是会了.源码看一波. 栅格系统 所 ...
- 【转载】Xutils3源码解析
Github源码地址:https://github.com/wyouflf/xUtils3 原文地址 :http://www.codekk.com/blogs/detail/54cfab086c476 ...
- 【转载】okhttp源码解析
转自:http://www.open-open.com/lib/view/open1472216742720.html https://blog.piasy.com/2016/07/11/Unders ...
- 【转载】FloatingActionButton源码解析
原文地址:https://github.com/Rowandjj/my_awesome_blog/blob/master/fab_anlysis/README.md loatingActionButt ...
- 【转载】Scroller源码解析
原文地址:https://github.com/Skykai521/AndroidSdkSourceAnalysis/blob/master/article/Scroller%E6%BA%90%E7% ...
- 【原】Android热更新开源项目Tinker源码解析系列之三:so热更新
本系列将从以下三个方面对Tinker进行源码解析: Android热更新开源项目Tinker源码解析系列之一:Dex热更新 Android热更新开源项目Tinker源码解析系列之二:资源文件热更新 A ...
- 【原】Android热更新开源项目Tinker源码解析系列之一:Dex热更新
[原]Android热更新开源项目Tinker源码解析系列之一:Dex热更新 Tinker是微信的第一个开源项目,主要用于安卓应用bug的热修复和功能的迭代. Tinker github地址:http ...
- 【原】Android热更新开源项目Tinker源码解析系列之二:资源文件热更新
上一篇文章介绍了Dex文件的热更新流程,本文将会分析Tinker中对资源文件的热更新流程. 同Dex,资源文件的热更新同样包括三个部分:资源补丁生成,资源补丁合成及资源补丁加载. 本系列将从以下三个方 ...
随机推荐
- Elasticsearch6.0 IKAnalysis分词使用
Elasticsearch 内置的分词器对中文不友好,会把中文分成单个字来进行全文检索,不能达到想要的结果,在全文检索及新词发展如此快的互联网时代,IK可以进行友好的分词及自定义分词. IK Anal ...
- You can't specify target table 'table' for update in FROM clause
delete from table1 where ID not in(select max(ID) ID from table1 group by row1) and row1 ) # 出现错误 # ...
- LightOJ 1013 - Love Calculator LCS
题意:找一个串使给出的两个串都是它的子串,要求最短,求出最短长度,以及种类数. 思路:可以想到,当两个子串a,b拥有最长的公共子串为LCS时,那么可以求出的最短的串为lena+lenb-LCS. 那么 ...
- c# 设置和取消文件夹共享及执行Dos命令
/// <summary> /// 设置文件夹共享 /// </summary> /// <param name="FolderPath">文件 ...
- centos6.8使用脚本一键搭建apache+svn服务
服务器环境: 脚本如下: #!/bin/bash yum install wget -y mv /etc/yum.repos.d/*.repo /tmp wget -O /etc/yum.repos. ...
- MyISAM和InnoDB的行格式ROW_FORMAT
MyISAM行存储 MyISAM有3种行存储格式:fixed / dynamic / compressed: 格式 说明 备注 fixed 只有当表不包含变长字段(varchar/varbina ...
- shell 给未定义的变量设定默认值 ${parameter:-word}
参考: [ Unix & Linux ] Shell Demo $echo ${JENKINS_VERSION:-2.7.4} 2.7.4 $JENKINS_VERSION=2.99 $ech ...
- 12.13记录//QQDemo示例程序源代码
笔记的完整版pdf文档下载地址: https://www.evernote.com/shard/s227/sh/ac692160-68c7-4149-83ea-0db5385e28b0 ...
- [Unity]扩展Hierachry的右键菜单
游戏制作到一定阶段后,一定会出现一些GameObject的"模板".比如一个敌人一定会有一个"Enemy Behaviour"."Box Collid ...
- 数据分析之CE找数据大法
一.基本介绍 CE的全称为Cheat Engine,是一款内存修改编辑工具,其官网是http://www.cheatengine.org,可以在其官网下载到最新的CE工具,目前最新版本是Cheat E ...