**(一)首页的整体结构 **
整体采用ViewPager + Fragment的结构,共有“首页”,“比赛大厅”,"资讯",“24小时”,“分类”,“原创”,“会员” 七个fragment。
MainActivity开发中注意事项总结如下:
设置ViewPager的facusable属性为false,因为如果ViewPager先获取焦点的话,那么Fragment中的view将有可能不能获取焦点了。
ViewPager预加载的前后4个Fragment,目的是加载更多的页面,用户如果在一级导航来回快速切换的话,由于预加载的Fragment比较多,那么ViewPager将不会那么频繁地回收Fragment,自然将不会给用户产生卡顿的感觉
this.mViewPager.setFocusable(false);
this.mViewPager.setOffscreenPageLimit(PRELOAD_PAGE);
- 由于24小时(原轮播台)带有一个播放器,如果快速切换一级导航的话,将会导致播放器不停的播放和停止播放。有时还会导致ANR的情况发生,所以我们在ViewPager的onPageSelected方法中,做了一个延时切换到特定Fragment的操作,这样可以有效避免卡顿和ANR的发生。
/**
* 处理头部导航Fragment
*
* @param tabIndex
*/
private void switchHeadChannel(int tabIndex) {
mJumpType = tabIndex;
mTabs[tabIndex].requestFocus();
mNavigation.setViewFocusable(mJumpType == (PAGE_COUNT - 1));
for (int i = PAGE_COUNT - 1; i >= 0; i--) {
mTabs[i].setSelected(i == mJumpType);
}
if (mHandler.hasMessages(MSG_UPDATE_ITEM)) {
mHandler.removeMessages(MSG_UPDATE_ITEM);
}
mHandler.sendEmptyMessageDelayed(MSG_UPDATE_ITEM, 100);
}
- 一级导航来回切换的时候
(1)Fragment被回收时的生命周期方法执行顺序是
onPause -> onStop -> onDestroyView
注意:并没有执行onDestroy方法,所以内存回收,请求取消的一些操作要放在 onDestroyView中进行。
(2)Fragment被回收后,又被恢复时,它的生命周期方法执行顺序是
onCreateView ->onActivityCreated -> onStart ->onResume
**注意:Fragment恢复时,并没有回调onCreate方法,Fragment恢复时 的一些操作可以放到onCreateView中进行**
下面用CompetitionHallFragment的onDestroyView方法做个示范:
@Override
public void onDestroyView() {
cancelHallRequest();
destroyMenuParams();
if (mGridView != null) {
mGridView.removeCallbacks(mPostRunnable);
}
super.onDestroyView();
mLogger.d(TAG, "==onDestroyView==");
}
- MainActivity的启动模式是singleTask,当Activity重复开启时,会回调onNewIntent方法。在做打洞的时候要格外注意这个细节。
/**
* activity已经存在的时候调用startActivity()方法时触发
*
* @param intent
*/
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
mLogger.d("=== onNewIntent===");
if (null != intent) {
int lastJumpType = mJumpType;
mJumpType = intent.getIntExtra("JumpType", GlobalConstant.INDEX_HOME);
isFromOutApp = intent.getBooleanExtra("isFromOutApp", false);
mLogger.i("lastJumpType == " + lastJumpType);
mLogger.i("mJumpType == " + mJumpType);
mLogger.i("isFromOutApp value = " + String.valueOf(isFromOutApp));
/**
如果Activity存在,而又要打开的时候,如果当前tab不是新闻,
要显示一级导航,并让新闻切换成小屏幕
*/
if (mJumpType != GlobalConstant.INDEX_CAROUSEL
&& lastJumpType == GlobalConstant.INDEX_CAROUSEL && mAdapter != null
&& mAdapter.getItem(GlobalConstant.INDEX_CAROUSEL) != null
&& mAdapter.getItem(GlobalConstant.INDEX_CAROUSEL).isAdded()) {
CarouselFragment carouselFragment = ((CarouselFragment) mAdapter.getItem(GlobalConstant.INDEX_CAROUSEL));
if (carouselFragment.isFullScreen()) {
carouselFragment.switchFullScreen(false);
}
}
if (Math.abs(lastJumpType - mJumpType) > PRELOAD_PAGE) {
mLogger.i("current tab is destroy");
if (getIntent() != null) {
getIntent().getIntExtra("selectedPosition", intent.getIntExtra("selectedPosition", 1));
}
setGotoHallTab(true);
focusCurrentSelectTab();
} else {
mLogger.i("current tab is not destroy");
focusTabByIndex(mJumpType, SearchParams.STATUS_TYPE_ALL);
}
}
}
**(二)比赛大厅(大陆) **###
-
**菜单列表的开发注意事项 **
(1)请求菜单列表前,我会默认添加“全部”、“更多”这两个按钮
接口请求到菜单数据后,我会把接口请求的菜单项添加进来。
如果接口请求失败,或者没有数据为空,那么页面只会显示“全部”和更多两个菜单选项。
这样可以做的优点是:请求比赛接口和请求菜单接口单独同时请求,而不用等菜单接口请求完了后,再去请求比赛接口。
/**
* 添加搜索"全部"的频道,默认值为0
*/
public void addDefaultChannel(ArrayList<ChannelModel> allChannelList) {
if (allChannelList != null && allChannelList.size() > 0) {
/**(1)过滤出推荐的数据 **/
mRecommendChannelList = new ArrayList<ChannelModel>();
for (ChannelModel model : allChannelList) {
if (model.isRecommend()) {
mRecommendChannelList.add(model);
}
}
/**(2)添加"全部项目"菜单**/
ChannelModel allGameChannel = new ChannelModel();
allGameChannel.setResourceId(SearchParams.ALL_DAY_TYPE);//默认值为0
allGameChannel.setName(getResources().getString(R.string.all_game));
mRecommendChannelList.add(0, allGameChannel);
/**(3) 添加"推荐"菜单**/
/* ChannelModel recommendChanel = new ChannelModel();
recommendChanel.setResourceId(RECOMMEND_ID);//默认值为1
recommendChanel.setName(getResources().getString(R.string.hall_game_recommend));
mRecommendChannelList.add(0, recommendChanel);*/
/**(4) 添加"更多项目"按钮,跳转到菜单页**/
ChannelModel lastChannel = new ChannelModel();
lastChannel.setResourceId(MORE_CHANNEL_ID);
lastChannel.setName(getResources().getString(R.string.more_game));
mRecommendChannelList.add(lastChannel);
} else {
mRecommendChannelList = new ArrayList<ChannelModel>();
/**(2)添加"全部项目"菜单**/
ChannelModel allGameChannel = new ChannelModel();
allGameChannel.setResourceId(SearchParams.ALL_DAY_TYPE);//默认值为0
allGameChannel.setName(getResources().getString(R.string.all_game));
mRecommendChannelList.add(0, allGameChannel);
/**(3) 添加"推荐"菜单**/
/* ChannelModel recommendChanel = new ChannelModel();
recommendChanel.setResourceId(RECOMMEND_ID);//默认值为1
recommendChanel.setName(getResources().getString(R.string.hall_game_recommend));
mRecommendChannelList.add(0, recommendChanel);*/
}
}
(2)菜单的点击事件处理,我分给给“全部”,“一般推荐的菜单”,“更多” 这三种类型菜单,设置了TAG,当点击菜单按钮的时候,通过这个TAG类型去响应事件。
@Override
public void onClick(View view) {
//后期添加了点击事件要注意
int clickIndex = mMenuButtonList.indexOf(view);
mLogger.d(TAG, "clickIndex == " + clickIndex);
/** 点击按钮后,样式改变 **/
mMenuButtonList.get(mSelectedButtonPosition).setSelected(false);
((MenuTabView) view).setSelected(true);
view.requestFocus();
mSelectedButtonPosition = clickIndex;
/** 点击按钮后,要刷新焦点,点击更多不需要刷新 **/
mSelectedButton = (MenuTabView) view;
if ((Integer) view.getTag() != MORE_MENU_TAG) {
changeMenuButtonFocusEvent();
}
/**请求给干掉 **/
this.cancelHallRequest();
/** 逻辑处理 **/
switch ((Integer) view.getTag()) {
case MORE_MENU_TAG:
//(2)跳转到菜单页
((MainActivity) getActivity()).showMenuDialog();
break;
case NORMAL_MENU_TAG:
//(3)刷新数据
mSelectedButton.setNextFocusDownId(R.id.hall_data_gallery);
if (CollectionUtils.size(mRecommendChannelList) > 0) {
mGameId = mRecommendChannelList.get(mSelectedButtonPosition).getResourceId();
}
itemSelectedCallback();
break;
}
}
(3) 当用户进入菜单选项的dialog,选中了某个菜单选项,然后返回到比赛大厅时,比赛大厅的菜单列表的样式变化的同时,也要按照这个菜单选项进行比赛的过滤,这个逻辑会比较复杂,下面我来梳理一下:
从菜单选项的dialog会返回一个菜单的reSourceId值,先用这个reSourceId去比赛大厅菜单列表中循环匹配
如果循环完成后,并没有匹配上,这个时候要在“更多”这个菜单项前面添加该菜单选项。
- ** 如果匹配上了,那么分两种情况:第一,如果选中的是“全部”或者”更多“,从dialog带过来的菜单如果存在,那么该选项要进行删除。同时请求比赛接口 。**
- 第二,如果选中的是非”全部“和”更多“按钮,说明之前用户已经从dialog中选择过一次比赛,只需要修改该菜单选项的name,然后请求比赛接口。
/**
* 选中用户选择的菜单项
*/
public void showUserSelectedMenuInfo() {
/* if (StringUtils.isBlank(mChannelName) || getResources().getString(R.string.all_game).equals(mChannelName)) {
mLogger.e(TAG, "mChannelName为空");
}*/
if (CollectionUtils.size(mMenuButtonList) == 0
|| CollectionUtils.size(mRecommendChannelList) == 0) {
return;
}
mLogger.d(TAG, "mChannelName为" + mChannelName);
mLogger.d(TAG, "mGameId为:" + mGameId);
boolean menuSelected = false;
for (int i = 0; i < mRecommendChannelList.size(); i++) {
MenuTabView child = mMenuButtonList.get(i);
ChannelModel model = mRecommendChannelList.get(i);
mLogger.i(TAG, "循环时ResourceId == " + model.getResourceId());
if (mGameId == model.getResourceId()) {
//选中
mLogger.i(TAG, "选中了position == " + i);
mSelectedButton = child;
//之前选中的不能选中
mMenuButtonList.get(mSelectedButtonPosition).setSelected(false);
child.requestFocus();
child.setSelected(true);
mSelectedButtonPosition = i;
menuSelected = true;//已经选中了某个菜单页
mLogger.i(TAG, "mSelectedButtonPosition == " + mSelectedButtonPosition);
changeSelectedFocus(mSelectedButton);
/** (1)说明选中的是"全部"、"更多",此时删除非推荐菜单选项(从dialog带过来的菜单选项) */
if ((model.isRecommend()
|| model.getResourceId() == SearchParams.GAME_TYPE_ALL
|| model.getResourceId() == MORE_CHANNEL_ID)
&& normalTabView != null
&& model.getResourceId() != RECOMMEND_ID) {
removeNormalButton();
mLogger.i(TAG, "删除普通菜单");
}
/** (2)从dialog带过来的菜单选项 已经添加的话,修改该菜单的name即可**/
if (!model.isRecommend() && model.getResourceId() != SearchParams.GAME_TYPE_ALL
&& model.getResourceId() != RECOMMEND_ID
&& model.getResourceId() != MORE_CHANNEL_ID
&& normalTabView != null) {
child.setVisibility(View.VISIBLE);
child.setText(mChannelName);
mLogger.i(TAG, "显示普通的菜单");
}
break;
} else {
/** (1)更新焦点 **/
changeSelectedFocus(child);
/** (2)之前选中的菜单,不让其颜色变成选中状态(蓝色)**/
if (child.isSelected()) {
child.setSelected(false);
}
}
}
/**
用户从HallDialog中选择了某个普通的菜单,有则刷新,无则添加
**/
if (!menuSelected) {
addUserSelectedMenu();
}
- 比赛大厅卡片优化
比赛卡片分为对阵和非对阵,之前是把对阵和非对阵的样式放在一个布局里,现在我使用了非对阵和对阵两种viewType。
三 比赛大厅(香港)###
(1)由于日期列表有星期的限制,而且开头是以星期天开始的,所以计算出两个礼拜前第一个星期天,作为startDate,添加35(5 * 7天)天作为这个时间列表。
private void initDateTabs() {
/**
* (1) 计算出今天是星期几
* (2) 计算出两个礼拜后第一个礼拜天作为startDate
* (3) 开始接口请求日期列表
* (4) fill Date
*
*/
/**(1)计算今天是星期几 **/
today = new Date();
int todayWeek = TimeFormatUtil.getWeekOfDate(today);
mDate = TimeFormatUtil.getTimeForHHMMSS(today.getTime(), "yyyyMMdd");
todayValue = mDate;
/**(2)计算出两个礼拜后第一个礼拜日作为startDate作为请求参数 **/
Date startDateOfSunday = DaysUtil.getDateBefore(today, 14 + todayWeek);
mTabStartDate = TimeFormatUtil.getTimeForHHMMSS(startDateOfSunday.getTime(), "yyyyMMdd");
print("startDateOfSunday == " + mTabStartDate);
if (mDateList != null) {
mDateList.clear();
} else {
mDateList = new ArrayList<Date>();
}
for (int i = 0; i < SearchParams.TOTAL_HALL_DATE_COUNT; i++) {
mDateList.add(DaysUtil.getDateAfter(startDateOfSunday, i));
}
mSelectGalleyPosition = mDateList.indexOf(today);
print("mSelectGalleyPosition = " + mSelectGalleyPosition);
/** (3) 开始接口请求日期列表 **/
requestDateTabList();
}
(2)中间的赛事卡片使用了Leakback 中重写的RecyclerView,用TVRecyclerView也是可以实现的。
(3)整个时间轴的布局分别由TimeAxis(显示时间轴刻度的ViewGroup) 和TimeMarkerView(显示比赛大厅所有比赛时间的小圆点View + **提示当前选中比赛时间的View) ** 组成。
TimeAxis由0~24小时 总共25个小时单位的刻度组成,其内部通过Adapter来完成。
TimeMarkerView会根据比赛大厅所有比赛,计算其在时间轴上的marginLeft值,生成一个个小圆点,然后依次添加进TimeMarkerView中;同时也会根据当前选中比赛的时间,添加一个上面为下边中间带箭头的蓝色长方形和下面为一个蓝色小圆点组合而成的布局(CurrentTimeTipView)。
选中比赛和时间轴联动效果是一个属性动画,CurrentTimeTipView由初始的marginLeft值滑到目标比赛marginLeft值,期间这个过程是先加速后减速的。
四 机卡绑定###
机卡绑定流程如下:
(1)首先Application onCreate方法中会请求机卡绑定的接口
(2)机卡绑定分为TV机卡绑定(买电视送会员)和手机机卡绑定(买手机送的会员,要在TV端使用)两种类型。
(3)优先进行TV机卡绑定,直接跳转到用户中心APP即可,绑定成功之后,会触发SportsVipLoginObserver的回调,表示用户已经成为了VIP会员,成功后跳转到MainActivity。
- 跳转到用户中心进行机卡绑定的代码如下:
LoginUtils.bind();
- 绑定成功的回调代码如下:
@Override
public void callBack(boolean isLogin) {
if(isLogin && LoginUtils.isLeSportsVip()){
mLogger.d("binding success and update userVipInfo");
MainActivity.gotoMainActivity(this);
finish();
}
}
注意:这个页面如果按返回键的话,要跳转到首页
(4)其次进行手机机卡绑定,该逻辑是由我们体育这边来实现的。
(5)进入手机机卡绑定页面会出现一个展示绑定时长的列表,当我们点击“领取”,那么会调用一个绑定接口,绑定成功的话进入一个绑定成功页面,绑定失败则进入绑定失败页面。
(6)绑定成功后,要更新账户的信息,代码如下:
LoginUtils.updateAccountInfo();
绑定成功页:
绑定失败页:
注意:手机机卡绑定,绑定成功和失败 三个页面都需要对返回键进行处理,让其跳转到首页。
五 搜索###
(1)搜索整体的页面结构是一个ScrollerView里面套了三个ViewGroup:
SearchInputPanel(最左边的键盘布局),SearchSuggestionPanel(中间的关键字列表布局),SearchResultPanel(布局)。
(2)三个Panel 的滑动是通过SearchFragment来控制的,同时也每个panel滑入和滑出有一些相应的逻辑处理,这里我让SearchFragment实现了SlideController接口里面,里面封装了一些控制panel滑入,滑出的方法,最后让每个panel持有SearchFragment的引用。
(3)同时为了方便控制,我们让三个panel,都实现了SlideablePanel接口,控制左右滑动的时候,只要传SlideablePanel对象即可,项目中由SearchFragment继承了SlideablePanel接口,并控制搜索的滑动及各种回调。
public interface SlideablePanel {
// 滑入时的回调
void onSlideIn();
// 滑出时的回调
void onSlideOut();
// 是否可以滑入
boolean canSlideIn();
// 搜索界面退出回调
void onSearchBoardExit();
// 上报PV
void onPvReport();
// panel滑动控制器,由SearchBoardActivity来实现。
interface SlideController {
// 请求向左滑动
boolean requestToSlideLeft();
// 请求向右滑动
boolean requestToSlideRight();
// 是否当前的焦点panel
boolean isCurrentFocusPanel(SlideablePanel requester);
// 请求隐藏焦点
void requestToHideFocus();
// 请求显示焦点
void requestToShowFocus();
// 请求成为焦点panel
void requestToBeFocusPanel(SlideablePanel requester);
// 是否正在滑动
boolean isSliding();
void requestToInputPanel();
void exitSearch();
}
}
/**
* Controller回调,控制*
*/
@Override
public boolean requestToSlideLeft() {
if (mSliding) {
return false;
}
if (mCurrentPanel == mSuggestionPanel
&& mInputPanel.canSlideIn()) {
onSlide(mInputPanel);
return true;
} else if (mCurrentPanel == mResultPanel) {
if (mSuggestionPanel.canSlideIn()) {
onSlide(mSuggestionPanel);
return true;
} else {
onSlide(mInputPanel);
return true;
}
}
return false;
}
@Override
public boolean requestToSlideRight() {
if (mSliding) {
Logger.e(TAG, "mSliding --->" + mSliding + "");
return false;
}
if (mCurrentPanel == mInputPanel) {
Logger.e(TAG, "mCurrentPanel --->" + mInputPanel + "");
if (mSuggestionPanel.canSlideIn()) {
onSlide(mSuggestionPanel);
return true;
} else if (mResultPanel.canSlideIn()) {
onSlide(mResultPanel);
return true;
}
} else if (mCurrentPanel == mSuggestionPanel
&& mResultPanel.canSlideIn()) {
Logger.e(TAG, "mCurrentPanel --->" + mSuggestionPanel + "");
Logger.e(TAG, "可以滑动到mResultPanel");
onSlide(mResultPanel);
return true;
}
return false;
}
(3)ScrollerView内部滑动是通过Scroller来控制的
public void slideTo(int toScrollX, int duration) {
if (this.getScrollX() == toScrollX) {
return;
}
if (this.mFocusView == null) {
this.mFocusView = FocusViewUtil.findFocusViewOfContainer(this);
}
int dx = toScrollX - this.getScrollX();
this.mScroller.forceFinished(true);
this.mScroller.startScroll(this.getScrollX(), 0, dx, 0, duration);
this.handler.removeMessages(MSG_SCROLL);
this.handler.sendEmptyMessage(MSG_SCROLL);
}
(4) SearchInputPanel(左边键盘)是一个自定义的ViewGroup,需要注意的是焦点事件这块,键盘里所有按键的焦点控制逻辑都在这里。
提示:如果一个view的焦点控制比较难的时候,重写该View的dispatchKeyEvent方法,是一定能够解决的。
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (this.mCurrentFocusView == null) {
this.mCurrentFocusView = this.getFocusedChild();
}
if (event.getAction() == KeyEvent.ACTION_DOWN) {
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_BACK:
mSlideController.exitSearch();//退出搜索
return true;
case KeyEvent.KEYCODE_DPAD_LEFT:
this.onLeftKeyPressed();
return true;
case KeyEvent.KEYCODE_DPAD_DOWN:
this.onDownKeyPressed();
return true;
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_BUTTON_A:
this.onFocusViewClicked();
return true;
case KeyEvent.KEYCODE_DPAD_RIGHT:
this.onRightKeyPressed(event);
return true;
case KeyEvent.KEYCODE_DPAD_UP:
this.onUpKeyPressed();
return true;
case KeyEvent.KEYCODE_FORWARD_DEL:
this.onClearBtnClicked();
return true;
case KeyEvent.KEYCODE_DEL:
this.onDeleteBtnClicked();
return true;
// 键盘输入空格
case KeyEvent.KEYCODE_SPACE:
this.onNewInputChar(" ");
return true;
}
// 键盘输入字母数字
if (event.getKeyCode() >= KeyEvent.KEYCODE_0
&& event.getKeyCode() <= KeyEvent.KEYCODE_9) {
this.onNewInputChar(this.mDigits[event.getKeyCode()
- KeyEvent.KEYCODE_0]);
} else if (event.getKeyCode() >= KeyEvent.KEYCODE_A
&& event.getKeyCode() <= KeyEvent.KEYCODE_Z) {
this.onNewInputChar(this.mLetters[event.getKeyCode()
- KeyEvent.KEYCODE_A]);
}
return true;
}
return true;
}
(5)左边键盘(SearchInputPanel)和中间的关键词列表(SearchSuggestionPanel)以及SearchResultPanel之间的回调,数据传递等一些逻辑处理,都是通过SearchBoardDataEngine这个类中来实现的。例如:
suggestion的点击,光标滑动事件的回调
缓存的清理
重复suggestion的过滤
(6)搜索结果页SearchResultPanel的实现,主要难点在RecyclerView的使用上,详情请关注: http://www.jianshu.com/p/ac7e393689f9
(7)Fragment被创建的时候,要进行suggestion缓存的加载
@Override
public void onActivityCreated(Bundle savedInstanceState) {
//加载缓存数据
GlobalThreadPool.getUnLimitedThreadPool().execute(new Runnable() {
@Override
public void run() {
SearchBoardDataEngine.getInstance().getHistoryRecordAndRecommendData();
}
});
super.onActivityCreated(savedInstanceState);
}
(8)退出应用的时候记得清理释放资源
@Override
public void onDestroy() {
SearchBoardDataEngine.getInstance().clearStaticData();
if (mInputPanel != null) {
mInputPanel.clearResource();
}
if (mSuggestionPanel != null) {
mSuggestionPanel.clearResource();
SearchBoardDataEngine.unregisterDataObserver(mSuggestionPanel
.getDataObserver());
}
if (mResultPanel != null) {
SearchBoardDataEngine.unregisterDataObserver(mResultPanel
.getDataObserver());
mResultPanel.clearResource();
}
super.onDestroy();
}