视频在线搜索设计与实现

By Long Luo

一、在线搜索

之前搜索页面的一些缺陷:

  1. 具体实现位于 VideoListActivity 中,一方面会造成 VideoListActivity 代码过于庞大臃肿,另外一方面不便于后续功能扩展,结构不清晰;
  2. 依赖了大量系统控件,不便于后续解耦界面定制
  3. 今后搜索界面会参考第三方视频应用的实现,之前不便于增加搜索记录,或搜索独立出来,用于搜索本地视频,甚至将此搜索移植应用于其他应用中;

1.1 在线搜索实现效果

在线搜索因为是和第三方合作,涉及到很多网络相关的操作,简单来说就是利用 Http 协议向相关接口发起一次网络请求,服务器如果返回了正确的响应,App 会解析服务器返回的内容,并展示出来。

1.1.1 热词界面

热词界面是当搜索文本框文字为空时会弹出热词界面,会展示最近一段时间内搜索频率很高的词语。一方面可以节省大家输入文字,另外一方面你也可以了解当前的一些热点。

当你点击列表中的某个热词时,就会发起一次以此为关键词的搜索。

热词显示

1.1.2 关联词

搜索文本输入框含有文字时,会获取当前输入文字,以此为关键词获取网络的一些联想词,可以点击此联想词发起一次搜索。

关联词显示

1.1.3 搜索结果分类浏览

发起一次搜索之后,如果得到了服务器的正确响应,而且确实有相关视频内容。那么我们会将搜索结果展示在手机页面上。

搜索到的结果可以分不同频道浏览,会根据具体内容进行动态变化,有的可能有十几个频道,有的也就一个频道。频道页面可以滑动浏览,也可以选择在顶部页面选中或者滑动。

分类浏览时,第一个展示的页面是搜索到的全部视频内容,之后的会根据结果动态变化。

如下图所示:

搜索结果

分频道浏览:

搜索结果分类

1.1.4 语音搜索

语音搜索图标只有当搜索框里文字为空才会出现,否则出现搜索图标

点击语音搜索图标将会启动 VoiceSearch 这个 apk ,然后你可以说话,如果被正确识别之后,会发起一次搜索,并将结果展示出来。

语音搜索

1.1.5 语音搜索结果

语音搜索结果

二、在线搜索UI设计及实现

任何功能都离不开 UI 和代码。在此我们先讨论在线搜索界面的 UI 设计及具体实现:

SearchBar 即为顶部的搜索栏,包括返回、编辑框、搜索按钮、语音按钮等。假如采用标准 SDK ,还需要加上一个清除全部文字按钮。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<RelativeLayout
android:id="@+id/searchBar"
android:layout_width="match_parent"
android:layout_height="@dimen/searchBarHeight"
android:layout_alignParentTop="true"
android:background="@drawable/searchbar_bg"
android:focusable="true"
android:focusableInTouchMode="true"
android:gravity="center" >

<RelativeLayout
android:id="@+id/searchBack"
android:layout_width="wrap_content"
android:layout_height="match_parent" >

<ImageView
android:id="@+id/searchBackButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:background="@drawable/phone_search_back_arrow"
android:clickable="true"
android:contentDescription="@null"
android:focusable="true" />
</RelativeLayout>

<RelativeLayout
android:id="@+id/searchSubmitLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:layout_marginRight="@dimen/searchBar_SearchMarginRight" >

<ImageView
android:id="@+id/searchVoiceSubmit"
android:layout_width="@dimen/searchBar_VoiceSearchButtonWidth"
android:layout_height="@dimen/searchBar_VoiceSearchButtonHeight"
android:layout_centerVertical="true"
android:background="@drawable/video_search_voice_bg"
android:visibility="gone" />

<ImageView
android:id="@+id/searchSubmit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:background="@drawable/video_search_submit_bg" />
</RelativeLayout>

<RelativeLayout
android:id="@+id/searchInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_centerInParent="true"
android:layout_marginLeft="@dimen/searchBar_marginLeft"
android:layout_toLeftOf="@id/searchSubmitLayout"
android:layout_toRightOf="@id/searchBack" >

<com.oppo.widget.OppoEditText
android:id="@+id/searchKeyword"
android:layout_width="match_parent"
android:layout_height="@dimen/searchBar_EditTextHeight"
android:layout_centerInParent="true"
android:layout_centerVertical="true"
android:background="@drawable/video_search_input_bg"
android:ellipsize="end"
android:hint="@string/search_hit"
android:inputType="text"
android:paddingLeft="@dimen/searchBar_EditTextPaddingLeft"
android:paddingRight="@dimen/searchBar_EditTextPaddingRight"
android:singleLine="true"
android:textSize="14sp"
oppo:quickDelete="true" />

</RelativeLayout>
</RelativeLayout>

2.2 热词和关联词

实现热词和关联词需要 ListView 及相关缓冲、空瓶动画,使用 OPPO SDK 控件实现。

2.3 分类筛选页面

分类筛选页面是一个难点,为了实现这个效果,使用了 2 种方案,但第一种方案页面无法滑动,最后选择可滑动页面方案。

2.3.1 水平ListView和ListView实现方案

这种方案是参考了 iQiyi 的实现方案,如下图所示:

2.3.1.1 iQiyi 搜索结果

iQiyi搜索结果

2.3.1.2 iQiyi实现

分类可滑动使用了一个 HorizontalListView 实现,搜索结果使用 ListView 实现,优点:

  1. 每个频道显示可以自定义,可以不仅仅显示频道名称,后续扩展方便,比如添加具体视频数字等;
  2. 代码逻辑简单,仅需要添加 ListView 点击实现接口,启动搜索,展示结果。

缺点:

  • 不同频道页面不可以滑动切换,无法满足 UE 需求。

2.3.2 滑动实现方案

按照 iQiyi 方案实现之后,由于需求变更。必须实现分类页面滑动切换效果,于是就有了第二种方案。

  1. 分类频道使用 HorizontalScrollView 实现, ScrollView 中添加相应的 View ,展示搜索分类;
  2. 由于页面可滑动,需要 ViewPagerFragments List ,在滑动时,启动相关的 fragment ,显示相关内容;
  3. 每个 fragment 需要 ListView 及相关的一些控件,发起搜索及相关实现;

三、在线搜索代码逻辑

3.1 搜索类

搜索类

iQiyi搜索接口: http://iface.iqiyi.com/api/searchIface?key=xxx&id=7f15c6eafc&type=xml&version=1.0&search_type=2&page_size=21&page_number=1&keyword=%E4%B8%96%E7%95%8C%E6%9D%AF

在某个频道内搜索: http://iface.iqiyi.com/api/searchIface?key=xxx&id=7f15c6eafc&type=xml&version=1.0&search_type=2&page_size=21&page_number=1&keyword=%E4%B8%96%E7%95%8C%E6%9D%AF&category_id=5

3.2 搜索结果频道分类

Weights

我们解析这个字段中的内容,就可以获取相关分类频道及其结果数量。

设计搜索的数据结构如下:

1
2
3
4
public class SearchResult {
public ArrayList<SearchFilterInfo> weightList;
public ArrayList<SearchVideoInfo> searchVideoList;
}

3.3 搜索结果页面

每一个分类页面都是一个 fragment ,全部页面是一个 fragments List 来管理,启动时我们需要初始化,将一些必要的数据传递给各个 fragment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void initFragment() {
int count = mSearchResult.weightList.size();

MyLog.d(TAG, "initFragment, count=" + count);

for (int i = 0; i < count; i++) {
Bundle data = new Bundle();
int channelId = StringUtils.toInt(mSearchResult.weightList.get(i).mSearchCategoryId, 0);
data.putString("channelTitle", mSearchResult.weightList.get(i).mSearchCategoryName);
data.putString("keyword", mKeyword);
data.putInt("channelId", channelId);
SearchResultFragment newFragment = new SearchResultFragment();
newFragment.setArguments(data);
MyLog.d(TAG, "bundle=" + data + ",id=" + channelId + ",keyword=" + mKeyword);

if (fragments != null) {
fragments.add(newFragment);
} else {
return;
}
}

ResultFragmentPageAdapter mAdapter = new ResultFragmentPageAdapter(getSupportFragmentManager(), fragments);

mSearchViewPager.setVisibility(View.VISIBLE);
mSearchViewPager.setAdapter(mAdapter);
mSearchViewPager.setOnPageChangeListener(pageListener);
// mSearchViewPager.setOffscreenPageLimit(3);
// mAdapter.notifyDataSetChanged();

initTabColumn();
}

在不同搜索结果页面进行切换时,实现一个监听器,获取当前选中页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public OnPageChangeListener pageListener = new OnPageChangeListener() {

@Override
public void onPageScrollStateChanged(int arg0) {
// MyLog.d(TAG, "onPageScrollStateChanged, arg0=" + arg0);

}

@Override
public void onPageScrolled(int arg0, float arg1, int arg2) {
// MyLog.d(TAG, "onPageScrolled, ");

}

@Override
public void onPageSelected(int position) {
MyLog.d(TAG, "onPageSelected, pos=" + position);
mSearchViewPager.setCurrentItem(position);
selectTab(position);

hideSoftInput();
}
};

在具体页面实现中,我们需要获取相关的参数及绘制页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private void getArgs() {

Bundle args = getArguments();
mChannelTitle = args != null ? args.getString("channelTitle") : "";
mChannelId = args.getInt("channelId");
mKeyWord = args != null ? args.getString("keyword") : "";

mLocalString = "title=" + mChannelTitle + ",id=" + mChannelId + ",mKeyword=" + mKeyWord;

MyLog.d(TAG, "getArgs, " + mLocalString);
}

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {

MyLog.d(TAG, "onCreateView, " + mLocalString);

View view = inflater.inflate(R.layout.search_result_fragment, null);

mOppoEmptyBottle = (TextView) view.findViewById(R.id.empty_result_view);
mOppoEmptyBottle.setText(R.string.search_no_result);

mNoNetworkView = (NoNetwork) view.findViewById(R.id.no_network_view);
// mNoNetworkView.setMessage(R.string.feedback_net_err);
mNoNetworkView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
MyLog.d(TAG, "mNoNetworkView, mKeyWord=" + mKeyWord + ",mChannelId=" + mChannelId);
startQuerySearch(mKeyWord, mChannelId);
}
});

mLoadingProcessView = inflater.inflate(R.layout.loading_progress_view_large, null);
loadingProgress = (ProgressBar) view.findViewById(R.id.loading_process);

mResultList = (OppoListView) view.findViewById(R.id.search_result_list);
// mSearchResultAdapter = new SearchVideoAdapter(mActivity);
// mResultList.setAdapter(mSearchResultAdapter);

mResultList.setOnItemClickListener(mListItemClickListener);
mResultList.setOnScrollListener(mOnScrollListener);

init();
initData();

return view;
}

四、问题及解决

在完成这个需求过程中,也遇到了一些问题,不过最终还是都得以解决了。在这里,挑选几个典型问题来说明下,仅供大家后续参考:

4.1 无法输入中文问题

搜索栏文本输入框有2种输入方式:

  1. 直接在编辑框中输入文字;
  2. 选中列表中的热词或者关联词,填充编辑框,启动搜索;

最开始,在相应的 mTextWatcher 和列表点击中使用了 setKeywords() 方法来实现编辑框的文字输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
mTextWatcher = new TextWatcher() {

@Override
public void afterTextChanged(Editable s) {
MyLog.d(TAG, "afterTextChanged:s=" + s);

mSearchKeyword.setMaxWidth(mSearchKeyword.getWidth());

if (StringUtils.isEmptyStr(s.toString())) {
mSearchFlush.setVisibility(View.GONE);
} else {
mSearchFlush.setVisibility(View.VISIBLE);
}

mKeyword = s.toString();
setKeyWords(mKeyword);

if (StringUtils.isEmptyStr(s.toString())) {
startQueryHotWords();
} else {
startQueryAssWords(StringUtils.encodeUTF8(mKeyword));
}
}

@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {

}
};

但是在手动输入文字中, setKeywords() 方法由于需要兼顾列表输入文字方法,需要先取消监听 mTextWatcher ,然后 setText() ,再添加 mTextWatcher ,这样就造成了每输入一个字符都会在编辑框中显示,就无法输入中文了。

1
2
3
4
mSearchKeyword.removeTextChangedListener(mTextWatcher);
mSearchKeyword.setText(word);
mSearchKeyword.setSelection(word.length());
mSearchKeyword.addTextChangedListener(mTextWatcher);

解决方法:

再建一个 setListSearchWords(String word) 方法,用于列表选词,问题得以解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void setListSearchWords(String word) {
// mKeyword = VideoUtils.encodeUTF8(word);
mKeyword = word;

MyLog.d(TAG, "setListSearchWords, word=" + word + ",mKeyword=" + mKeyword);

if ((mSearchKeyword != null) && (word != null)) {
mSearchKeyword.removeTextChangedListener(mTextWatcher);
mSearchKeyword.setText(word);
mSearchKeyword.setSelection(word.length());
mSearchKeyword.addTextChangedListener(mTextWatcher);

if (StringUtils.isEmptyStr(word)) {
mSearchVoiceSubmit.setVisibility(View.VISIBLE);
mSearchSubmit.setVisibility(View.GONE);
mSearchFlush.setVisibility(View.GONE);
} else {
mSearchVoiceSubmit.setVisibility(View.GONE);
mSearchSubmit.setVisibility(View.VISIBLE);
mSearchFlush.setVisibility(View.VISIBLE);
}

hideVoiceSearch();
}

// mSearchSubmit.setVisibility(View.VISIBLE);
}

4.2 视频异常退出问题

在实际中遇到了一些异常退出问题:

4.2.1 滑动页面异常退出

原因是没有从服务器获取到正确的搜索视频结果,使用 Message 传递时,虽然 message.obj 不为空,但搜索结果为空,导致退出。

解决方案: 增加相应的空指针判断。

4.2.2 选择列表一个热词同时按下返回键,视频退出

原因是 ActivityonDestory() 中,销毁了对应的 fragments List ,但是在此之前已经启动搜索,搜索结果通过 Handler ,绘制页面,但 fragments 已经为空,导致出现空指针。

解决方案: 增加相应的空指针判断。

文章修改历史

  • Created By Long Luo at 2014/6/27 20:09:59
  • Completed By Long Luo at 2014/7/2 16:39:54
  • Modified By Long Luo at 2018年9月26日00点06分 at Hangzhou, China.
  • 修改图片图床 2024.03.03 in Shenzhen.
  • 之前代码截屏修改为代码 2024.08.11 in Shenzhen.