安卓自動化測試入門-5-創建UI
在這個系列的博客中,我們新建了一個叫做Github User Search的Android App範例。在Part1-4的博客中,介紹了爲什麼我們應該寫測試, 如何爲了測試而配置項目, 創建API調用 和 創建一個Presenter 。請看一看前面的博客,因爲Part5將是這個系列的延續。
在Part5中,我們將會了解如何與Part4創建的Presenter交互,同時我們將會創建一個展示搜索結果列表的UI。
本文翻譯自Riggaroo的《 Introduction to Automated Android Testing – Part 5 》
注意:以下的測試特指“程序員編寫的自動化代碼測試”
水平有限,歡迎指教。如有錯漏,多多包涵。
作者的項目地址:
https://github.com/riggaroo/GithubUsersSearchApp。
請注意:每個分支對應這一系列博客的每一篇文章。
創建UI
關於用戶交互界面,我們想要一個簡單的列表用於顯示每個用戶的頭像,姓名和一些用戶的其它信息。
在Part4中,我們定義了一個Activity應該實現的View
約定。這就是編寫Android特有代碼的地方(例如某個控件的可見性改變,或者任何UI的變更都應該寫在這裏)。重溫一下View約定的定義:
interface UserSearchContract {
interface View extends MvpView {
void showSearchResults(List<User> githubUserList);
void showError(String message);
void showLoading();
void hideLoading();
}
interface Presenter extends MvpPresenter<View> {
void search(String term);
}
}
讓我們來實現這個View吧!
1 . 創建或導航到UserSearchActivity
。這個類將實現UserSearchContract.View
約定並繼承AppCompatActivity
。定義一個UserSearchContract.Presenter類型的變量userSearchPresenter
。這個就是我們用於調用網絡訪問的對象。
public class UserSearchActivity extends AppCompatActivity implements UserSearchContract.View {
private UserSearchContract.Presenter userSearchPresenter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_user_search);
userSearchPresenter = new UserSearchPresenter(Injection.provideUserRepo(), Schedulers.io(),
AndroidSchedulers.mainThread());
userSearchPresenter.attachView(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
userSearchPresenter.detachView();
}
@Override
public void showSearchResults(List<User> githubUserList) {
}
@Override
public void showError(String message) {
}
@Override
public void showLoading() {
}
@Override
public void hideLoading() {
}
}
在onCreate()
中,創建presenter對象。將Injection類定義的User Repo作爲第一個參數。(譯者注:有跟我一樣喜歡即時拷代碼進項目看的朋友嗎?我就當有了,我把原文的Injection代碼提前到下面。)傳遞ios()
和AndroidSchedulers.mainThread()
計劃進構造器,這樣RxJava的Subscription就知道應該在哪條線程上面執行自己的代碼了。
在下一行,你可以看到我調用userSearchPresenter.attachView(this)
。這個操作將View依附到Presenter,這樣Presenter就可以將變動通知給View。因爲Presenter並不會自動與Activity的生命週期聯動,所以在onDestroy()
中我們需要通知Presenter這時View已經不存在了,具體做法是調用userSearchPresenter.detachView()
。這樣就可以註銷RxJava的所有訂閱並防止內存泄露。
public class Injection {
private static final String BASE_URL = "https://api.github.com";
private static OkHttpClient okHttpClient;
private static GithubUserRestService userRestService;
private static Retrofit retrofitInstance;
public static UserRepository provideUserRepo() {
return new UserRepositoryImpl(provideGithubUserRestService());
}
static GithubUserRestService provideGithubUserRestService() {
if (userRestService == null) {
userRestService = getRetrofitInstance().create(GithubUserRestService.class);
}
return userRestService;
}
static OkHttpClient getOkHttpClient() {
if (okHttpClient == null) {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.BASIC);
okHttpClient = new OkHttpClient.Builder().addInterceptor(logging).build();
}
return okHttpClient;
}
static Retrofit getRetrofitInstance() {
if (retrofitInstance == null) {
Retrofit.Builder retrofit = new Retrofit.Builder().client(Injection.getOkHttpClient()).baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create());
retrofitInstance = retrofit.build();
}
return retrofitInstance;
}
}
2 . 在layout文件夾創建activity_user_search.xml
。這個文件將會包含一個RecyclerView
,一個ProgressBar
,一個錯誤TextView
和一個Toolbar
。我準備使用ConstraintLayout
來設計我的屏幕,所以我不會很詳細地述說細節,因爲大部分操作都是拖和放。(如果你想要知道更多ConstraintLayout的信息,你可以打開我的 另一篇博客 。)
activity_user_search.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_user_search"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="za.co.riggaroo.gus.presentation.search.UserSearchActivity">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintLeft_toLeftOf="@+id/activity_user_search"
app:layout_constraintRight_toRightOf="@+id/activity_user_search"
app:layout_constraintTop_toTopOf="@+id/activity_user_search">
</android.support.v7.widget.Toolbar>
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view_users"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="16dp"
android:clipToPadding="false"
android:scrollbars="vertical"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="@+id/activity_user_search"
app:layout_constraintLeft_toLeftOf="@+id/activity_user_search"
app:layout_constraintRight_toRightOf="@+id/activity_user_search"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
tools:listitem="@layout/list_item_user">
</android.support.v7.widget.RecyclerView>
<TextView
android:id="@+id/text_view_error_msg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/search_for_some_users"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="@+id/recycler_view_users"
app:layout_constraintLeft_toLeftOf="@+id/toolbar"
app:layout_constraintRight_toRightOf="@+id/recycler_view_users"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
tools:text="No Data has loaded"/>
<ProgressBar
android:id="@+id/progress_bar"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginBottom="16dp"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/activity_user_search"
app:layout_constraintLeft_toLeftOf="@+id/recycler_view_users"
app:layout_constraintRight_toRightOf="@+id/recycler_view_users"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
tools:visibility="visible"/>
</android.support.constraint.ConstraintLayout>
strings.xml
<resources>
<string name="app_name">Gus</string>
<string name="search_users">Search for users on Github...</string>
<string name="search_icon_title">Search</string>
<string name="search_for_some_users">Start typing to search</string>
</resources>
3 . 我們還需要添加一個SearchView到ToolBar,這樣我們就有地方鍵入搜索詞。添加一個menu_user_search.xml
文件到menu資源文件夾,在文件裏面,我們添加一個SearchView
:
menu_user_search.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_search"
android:icon="@drawable/ic_search"
android:title="@string/search_icon_title"
app:actionViewClass="android.support.v7.widget.SearchView"
app:showAsAction="always|collapseActionView" />
</menu>
添加一個ic_search.xml
文件到drawable文件夾:
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFF"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>
4 . 我們需要爲RecyclerView的每一個項創建一個layout。新建一個list_item_user.xml
文件。我使用ConstraintLayout,包含一個顯示頭像的ImageView和兩個TextView。
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/imageview_userprofilepic"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
app:layout_constraintLeft_toLeftOf="@+id/constraintLayout"
app:layout_constraintTop_toTopOf="@+id/constraintLayout"
app:srcCompat="@mipmap/ic_launcher" />
<TextView
android:id="@+id/textview_username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintLeft_toRightOf="@+id/imageview_userprofilepic"
app:layout_constraintTop_toTopOf="@+id/constraintLayout"
tools:text="Rebecca Franks" />
<TextView
android:id="@+id/textview_user_profile_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginStart="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
app:layout_constraintBottom_toBottomOf="@+id/constraintLayout"
app:layout_constraintLeft_toRightOf="@+id/imageview_userprofilepic"
app:layout_constraintRight_toRightOf="@+id/constraintLayout"
app:layout_constraintTop_toBottomOf="@+id/textview_username"
tools:text="JHB, South Africa. Lots of code, lots and lots and lots of code." />
</android.support.constraint.ConstraintLayout>
5 . 現在我們已經有了所有需要的layout,讓我們把它們綁定到Activity吧。首先,在onCreate()
方法獲取View的引用。
private UsersAdapter usersAdapter;
private SearchView searchView;
private Toolbar toolbar;
private ProgressBar progressBar;
private RecyclerView recyclerViewUsers;
private TextView textViewErrorMessage;
@Override
protected void onCreate(Bundle savedInstanceState) {
...
toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
progressBar = (ProgressBar) findViewById(R.id.progress_bar);
textViewErrorMessage = (TextView) findViewById(R.id.text_view_error_msg);
recyclerViewUsers = (RecyclerView) findViewById(R.id.recycler_view_users);
usersAdapter = new UsersAdapter(null, this);
recyclerViewUsers.setAdapter(usersAdapter);
}
添加RecyclerView的依賴,版本與compileSdkVersion匹配就好
compile 'com.android.support:recyclerview-v7:25.0.1'
添加UsersAdapter,用於RecyclerView。
public class UsersAdapter extends RecyclerView.Adapter<UserViewHolder> {
private final Context context;
private List<User> items;
UsersAdapter(List<User> items, Context context) {
this.items = items;
this.context = context;
}
@Override
public UserViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_user, parent, false);
return new UserViewHolder(v);
}
@Override
public void onBindViewHolder(UserViewHolder holder, int position) {
User item = items.get(position);
holder.textViewBio.setText(item.getBio());
if (item.getName() != null) {
holder.textViewName.setText(item.getLogin() + " - " + item.getName());
} else {
holder.textViewName.setText(item.getLogin());
}
Picasso.with(context).load(item.getAvatarUrl()).into(holder.imageViewAvatar);
}
@Override
public int getItemCount() {
if (items == null) {
return 0;
}
return items.size();
}
void setItems(List<User> githubUserList) {
this.items = githubUserList;
notifyDataSetChanged();
}
}
class UserViewHolder extends RecyclerView.ViewHolder {
final TextView textViewBio;
final TextView textViewName;
final ImageView imageViewAvatar;
UserViewHolder(View v) {
super(v);
imageViewAvatar = (ImageView) v.findViewById(R.id.imageview_userprofilepic);
textViewName = (TextView) v.findViewById(R.id.textview_username);
textViewBio = (TextView) v.findViewById(R.id.textview_user_profile_info);
}
}
6 . 我們需要將SearchView勾到Activity裏面並讓它觸發presenter的search()
方法。在onCreateOptionsMenu()
裏面,加上以下代碼:
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
getMenuInflater().inflate(R.menu.menu_user_search, menu);
final MenuItem searchActionMenuItem = menu.findItem(R.id.menu_search);
searchView = (SearchView) searchActionMenuItem.getActionView();
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
if (!searchView.isIconified()) {
searchView.setIconified(true);
}
userSearchPresenter.search(query);
toolbar.setTitle(query);
searchActionMenuItem.collapseActionView();
return false;
}
@Override
public boolean onQueryTextChange(String s) {
return false;
}
});
searchActionMenuItem.expandActionView();
return true;
}
這段代碼將會填充正確的菜單,找到搜索視圖並設置一個文本查詢監聽器。在這種情形下,只有當用戶點擊鍵盤上的提交按鈕,我們纔會做出反應,調用presenter的搜索方法。我們也可以在onQueryTextChange
方法裏面做這件事,不過考慮到Github API的調用頻率限制,我還是建議用onQueryTextSubmit
。正常情況下,搜索結果將會展現。
7 . 接下來,我們實現presenter將會在數據加載完成後調用的回調。
@Override
public void showSearchResults(List<User> githubUserList) {
recyclerViewUsers.setVisibility(View.VISIBLE);
textViewErrorMessage.setVisibility(View.GONE);
usersAdapter.setItems(githubUserList);
}
@Override
public void showError(String message) {
textViewErrorMessage.setVisibility(View.VISIBLE);
recyclerViewUsers.setVisibility(View.GONE);
textViewErrorMessage.setText(message);
}
@Override
public void showLoading() {
progressBar.setVisibility(View.VISIBLE);
recyclerViewUsers.setVisibility(View.GONE);
textViewErrorMessage.setVisibility(View.GONE);
}
@Override
public void hideLoading() {
progressBar.setVisibility(View.GONE);
recyclerViewUsers.setVisibility(View.VISIBLE);
textViewErrorMessage.setVisibility(View.GONE);
}
我們基本上只是反轉視圖的可見性並給userAdapter
設置網絡服務返回的新數據。
譯者注:如果遇到Toolbar相關的報錯This Activity already has an action bar supplied by the window decor. Do not request Window.FEATURE_SUPPORT_ACTION_BAR and set windowActionBar to false in your theme to use a Toolbar instead.
,應該是Theme的設置沒有關閉默認的action bar。Theme的代碼如下:
<resources>
<!-- Base application theme. -->
<style name="MyTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
</resources>
譯者注:記得添加網絡訪問權限
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="za.co.riggaroo.gus">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/MyTheme">
<activity android:name=".presentation.search.UserSearchActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
8 . 現在你可以跑一下這個App了。你應該可以搜索一個Github的用戶名並看到結果。
Yay!我們有了一個能工作的App了。這篇博客的代碼可以在 這裏 找到。在下一篇,我們將會介紹如何編寫UI的測試。
尋找廣州Android開發工程師工作,郵箱[email protected] 電話:13580579413 陳捷尉 2016.11.30