SureshJoshi.com ▼

Android Adventures - Going Retro One Last Time


2015-10-29

And here he is, Bryan himself, kicking it retro with his last post on building an Android app. Note: The feature image isn’t Bryan. At least… I don’t think it is…

If you haven’t read through the series, you won’t appreciate the journey nearly enough. And you also won’t be ready for Bryan’s sense of humour, so for all of our sakes - please take a read through: Part 1, Part 2, Part 3, Part 4.

Enter Bryan

Hi.

Nice of you to come.

Take a seat. Relax.

Let’s just chill.

No pressure. We’re cool.

It’s been a long, wild ride, despite what was initially promised, and we have become Adults, you and I. Yes, we’ve learned a lot about Android, but you know what? We’ve also learned a lot… about ourselves.

Today, on Android Adventures, we’re going to talk about best practices. We’re also going to introduce a little library which will allow us to clean up our code in a big way - RetroLambda.

That’s right. We’re going to have a conversation. A back-and-forth. A tête-à-tête, though yes, I will be doing most of the talking. But it’s fine. You like the sound of my voice.

We’re going to talk about naming conventions, lambda expressions, how to get DRY, the right place for Activity creation, and finally wrapping up this project and moving onto bigger and better things.

Let’s start with everybody’s favourite party ice-breaker - naming conventions.

A Rose by Any Other Name

Those of you who have been paying attention over the last 6 weeks have probably noticed a gradual migration away from plain CamelCase (myVariable) to something a little different.

From the Android Style Guide…

  • Non-public, non-static field names start with m.
  • Static field names start with s.
  • Other fields start with a lower case letter.
  • Public static final fields (constants) are ALL_CAPS_WITH_UNDERSCORES

I combine this with a structure each class name denotes its type or function, like GitRepoWebActivity.

Now, while for most of us, all this name talk is getting us hot and bothered, some people may just be bothered and disagree entirely. It’s okay, everyone’s allowed to be wrong.

In all honesty, it doesn’t matter what convention you choose - as long as you stick to it! I like this one because it leaves very little ambiguity as to where a variable or class belongs, what it does, and what kind of object it is - however, one could justifiably say it’s overly verbose and redundant, even if they were an uncultured heathen [SJ: And this is how flamewars start].

Lambdas

What are lambda expressions? Good question. I think the best way to understand them is to view them as a way to simplify code - even if some block of code isn’t technically wrong, it can be hard to follow or unpleasant to read. [SJ: Lambdas are essentially ‘light-weight’ anonymous methods… A very hand-wavy answer is that they amount to syntax sugar in many Java apps (they have other characteristics regarding control flow and capturing local variables that I won’t get into)]

That’s where lambdas come in. In broad strokes, a lambda expression is useful when accessing an interface with only one method within it - a great example is in the OnItemClickListener() class. A typical declaration of this class looks like:

mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
        @Override
            public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
            ... //your interface method implementation
            }
    });

With lambdas, we can change this to:

    mListView.setOnItemClickListener((parent, view, position, id) -> {
    ... //your interface method implementation
    });

In other words, you pass your parameters to the interface and replace all the @Overrides and ’public void methodname’s with a simple ->.

Pretty cool, right?

It turns out that lambda expressions aren’t natively supported by Android currently (Java 6/7), so we need to use a cool library called RetroLambda to enable this Java 8 functionality.

Retro Lambdas

Using RetroLambda is a marginally more difficult than other libraries, as it’s not so simple as a single line in our gradle script.

It’s actually several lines.

Here is a full build.gradle:

    buildscript {
        repositories {
            mavenCentral()
        }

        dependencies {
            classpath 'me.tatarka:gradle-retrolambda:3.2.0'
        }
    }
    apply plugin: 'com.android.application'
    apply plugin: 'me.tatarka.retrolambda'

    repositories {
        mavenCentral()
    }

    android {
        compileSdkVersion 22
        buildToolsVersion "21.1.2"

        defaultConfig {
            applicationId "course.examples.retrofittest"
            minSdkVersion 19
            targetSdkVersion 22
            versionCode 1
            versionName "1.0"
        }
        buildTypes {
            release {
                minifyEnabled false
                proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            }
        }
        compileOptions {
            sourceCompatibility JavaVersion.VERSION_1_8
            targetCompatibility JavaVersion.VERSION_1_8
        }
    }

    dependencies {
        compile fileTree(dir: 'libs', include: ['*.jar'])
        compile 'com.android.support:appcompat-v7:22.+'
        compile 'com.squareup.retrofit:retrofit:1.9.0'
        compile 'com.google.code.gson:gson:2.3'
        compile 'com.jakewharton:butterknife:7.0.1'
        compile 'com.squareup.picasso:picasso:2.3.3'
        compile 'com.mobsandgeeks:android-saripaar:2.0.2'
    }

We’ve added lines here

buildscript {
        repositories {
            mavenCentral()
        }

        dependencies {
            classpath 'me.tatarka:gradle-retrolambda:3.2.0'
        }
    }

here

apply plugin: 'me.tatarka.retrolambda'

    repositories {
        mavenCentral()
    }

and here

compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

in order to enable that delicious Java 8 functionality.

We’ve already converted all our single-method interface calls into lambda expressions which you’ll see in just a moment.

DRY it off

Lots of things are dry. The desert. Saltines. This joke.

But few things are DRY, which is shorthand for a simple concept - Don’t Repeat Yourself.

That is, it stands for Don’t Repeat Yourself.

In other words, following DRY is good because it reduces the amount of times you repeat yourself.

For those of you following along, you’ve probably noticed that the code we’ve made in previous posts have been positively wet - which, as fun as it sounds, isn’t particularly good practice [SJ: ‘positively wet’ sounds positively crude - for shame, Bryan… For shame…].

If we find ourselves writing the same lines of code over and over again, we should probably just create a class or method and call that instead - it improves readability and reduces redundancy.

One such redundancy in our code are our repeated calls to the GitApi class, creating a new client each time we need to use our getFeed or getUser methods.

Instead, let’s change up our GitApi class and store it within a new class called GitClient.

Within this class, we’re going to create a new static method, which means no instance variables. We want to be sure that we aren’t instantiating new objects when we really don’t need to be. That code is below: [SJ: For the purpose of a simple example and avoiding flame wars - all singletons discussions regarding pros/cons/thread-safety/locks have been bypassed - if you’re interested in that for Java, please refer to this Wikipedia article]

public class GitClient {
    private static GitApi sGitApiService;
    private final static String BASE_URL = "https://api.github.com";

    public static GitApi getGitClient() {
        if (sGitApiService == null) {
            RestAdapter restAdapter = new RestAdapter.Builder()
                    .setEndpoint(BASE_URL)
                    .build();

            sGitApiService = restAdapter.create(GitApi.class);
        }

        return sGitApiService;
    }

    public interface GitApi {

        @GET("/repositories")
            //here is the other url part.best way is to start using /
        void getFeed(Callback<List<GitModel>> response);

        @GET("/users/{user}")
        void getUser(@Path("user") String user, Callback<User> response);

        }
    }

You’re probably already noticing some of that sweet, sweet, variable name convention - but aside from that, the new method, getGitClient, should look very familiar. The code block within it has been rewritten every single time we needed an API request in one of our activities!

Now, only one GitApi class is ever created, and subsequent calls simply reference that class and its methods. You’ll see some of the huge upsides of that when we look over our other classes.

Activity Activity Activity

Some of the better developers among you have probably already noticed a pretty big problem with the onClick implementations for our ListViewAdapter.

Our implementation of the ListViewAdapter is completely non-extensible - we can’t use it anywhere else! Sure, for our application, we only have one ListView, but what if we wanted a ListView in our other Activities too?

The solution is pretty great - to set our ImageView, we declare an interface within our ListViewAdapter like so:

private final OnImageViewClickedListener mOnImageViewClickedListener;
    public interface OnImageViewClickedListener {
        void onImageViewClicked(int position);
    }

and we assign it in the ListViewAdapter’s constructor:

public ListViewAdapter(Context context, List<GitModel> gitModelList, OnImageViewClickedListener listener) {
        mGitModelList = gitModelList;
        mFilteredGitModelList = gitModelList;
        mContext = context;
        mOnImageViewClickedListener = listener;
    }

and we set our image’s click listener to it in our getView method:

@Override
    public View getView(final int position, View convertView, ViewGroup parent) {
        ...

        if (mOnImageViewClickedListener != null) {
            holder.image.setOnClickListener(view -> mOnImageViewClickedListener.onImageViewClicked(position));
        }

        return convertView;
    }

You can already see we’ve started using lambdas to our benefit.

We’re going to implement this interface in our MainActivity, and @Override the onImageViewClicked() method. When we create the ListViewAdapter, we pass a ‘this’ instance to the OnImageViewClickedListener parameter, because when MainActivity implements the interface, it effectively can be cast as an Activity, or an OnImageViewClickedListener, as we need it to be. When we click on the image, the onImageViewClicked() method we overrode is called in the MainActivity.

It should be pretty clear that this means our ListViewAdapter can be shared between any number of Activities, with each individual Activity implementing the onImageViewClicked() method in their own way!

But what about the TextViews?

We could set an interface to deal with those, but instead, let’s fix an issue I’ve had with this implementation from the beginning - I didn’t really want users to click on the TextViews, but on the item as a whole.

Because of that, we don’t need to involve the ListViewAdapter at all! We can just use an interface called onItemClickListener() (seem familiar?) in our MainActivity that fires whenever the list item is clicked.

Altogether, the new ListViewAdapter code is:

public class ListViewAdapter extends BaseAdapter implements Filterable {

    List<GitModel> mGitModelList;
    List<GitModel> mFilteredGitModelList;
    Context mContext;
    private final ItemFilter mFilter = new ItemFilter();

    private final OnImageViewClickedListener mOnImageViewClickedListener;
    public interface OnImageViewClickedListener {
        void onImageViewClicked(int position);
    }

    public ListViewAdapter(Context context, List<GitModel> gitModelList, OnImageViewClickedListener listener) {
        mGitModelList = gitModelList;
        mFilteredGitModelList = gitModelList;
        mContext = context;
        mOnImageViewClickedListener = listener;
    }

    @Override
    public int getCount() {
        return mFilteredGitModelList.size();
    }

    @Override
    public Object getItem(int position) {
        return mFilteredGitModelList.get(position);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(final int position, View convertView, ViewGroup parent) {

        ViewHolder holder;
        if (convertView == null) {
            convertView = LayoutInflater.from(mContext).inflate(R.layout.activity_list_view, parent, false);
            holder = new ViewHolder(convertView);
            convertView.setTag(holder);

        } else {
            holder = (ViewHolder) convertView.getTag();
        }
        Picasso.with(mContext)
                .load(mFilteredGitModelList.get(position).getOwner().getAvatar_url())
                .placeholder(R.drawable.placeholder)
                .into(holder.image);


        String name = mFilteredGitModelList.get(position).getName();
        int id = mFilteredGitModelList.get(position).getId();
        holder.text.setText(String.format(mContext.getString(R.string.listadapter_placeholder), name, id));
        holder.texttwo.setText(mFilteredGitModelList.get(position).getOwner().getLogin());

        if (mOnImageViewClickedListener != null) {
            holder.image.setOnClickListener(view -> mOnImageViewClickedListener.onImageViewClicked(position));
        }

        return convertView;
    }

    static class ViewHolder {
        @Bind(R.id.image_in_item)
        ImageView image;
        @Bind(R.id.textview_in_item)
        TextView text;
        @Bind(R.id.textview_in_item_two)
        TextView texttwo;

        public ViewHolder(View view) {
            ButterKnife.bind(this, view);
        }
    }

    public void setGitmodel(List<GitModel> gitModelList) {
        clearGitmodel();
        this.mGitModelList = gitModelList;
        this.mFilteredGitModelList = gitModelList;
        notifyDataSetChanged();
    }

    public void clearGitmodel() {
        this.mGitModelList.clear();
        this.mFilteredGitModelList.clear();
    }

    public Filter getFilter() {
        return mFilter;
    }

    private class ItemFilter extends Filter {
        @Override
        protected FilterResults performFiltering(CharSequence constraint) {

            String filterString = constraint.toString().toLowerCase();
            FilterResults results = new FilterResults();

            final List<GitModel> list = mGitModelList;
            int count = list.size();
            final ArrayList<GitModel> nlist = new ArrayList<>(count);

            GitModel filterableGitModel;

            for (int i = 0; i < count; i++) {
                filterableGitModel = list.get(i);
                if (filterableGitModel.getOwner().getLogin().toLowerCase().contains(filterString)) {
                    nlist.add(filterableGitModel);
                }
            }

            results.values = nlist;
            results.count = nlist.size();

            return results;
        }

        @SuppressWarnings("unchecked")
        @Override
        protected void publishResults(CharSequence constraint, Filter.FilterResults results) {
            mFilteredGitModelList = (ArrayList<GitModel>) results.values;
            notifyDataSetChanged();
        }
      }
    }

And the MainActivity code is

public class MainActivity extends Activity implements SwipeRefreshLayout.OnRefreshListener,
        ListViewAdapter.OnImageViewClickedListener {

    List<GitModel> mGitModel;
    ListViewAdapter mListAdapter;

    @Bind(R.id.swipe_container)
    SwipeRefreshLayout mSwipeRefreshLayout;

    @Bind(R.id.empty)
    TextView mEmptyTextView;

    @Bind(R.id.listView)
    ListView mListView;

    @Bind(R.id.filterbar)
    EditText mFilterString;

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

        mSwipeRefreshLayout.setOnRefreshListener(this);
        mFilterString.addTextChangedListener(searchTextWatcher);

        mGitModel = new ArrayList<>();
        mListAdapter = new ListViewAdapter(this, mGitModel, this);


        mListView.setEmptyView(mEmptyTextView);
        mListView.setAdapter(mListAdapter);
        mListView.setOnItemClickListener((parent, view, position, id) -> {
            String url = mGitModel.get(position).getOwner().getHtml_url();
            Intent i = new Intent(this, GitRepoWebActivity.class);
            i.putExtra(getString(R.string.extra_url), url);
            startActivity(i);
        });

        mSwipeRefreshLayout.setColorSchemeResources(android.R.color.holo_blue_bright,
                android.R.color.holo_green_light,
                android.R.color.holo_orange_light,
                android.R.color.holo_red_light);
    }

    @Override
    public void onRefresh() {
        GitClient.getGitClient().getFeed(new Callback<List<GitModel>>() {
            @Override
            public void failure(RetrofitError error) {
                mSwipeRefreshLayout.setRefreshing(false);
                Toast.makeText(getApplicationContext(), "Failed to load", Toast.LENGTH_SHORT).show();
            }

            @Override
            public void success(final List<GitModel> gitmodel, Response response) {
                mGitModel = gitmodel;
                mSwipeRefreshLayout.setRefreshing(false);
                mListAdapter.setGitmodel(gitmodel);
            }
        });
    }

    private TextWatcher searchTextWatcher = new TextWatcher() {
        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
        }

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

        @Override
        public void afterTextChanged(Editable s) {
            mListAdapter.getFilter().filter(s.toString());
        }
    };

    @Override
    public void onImageViewClicked(int position) {
        String name = mGitModel.get(position).getOwner().getLogin();
        // Launching new Activity on selecting text
        Intent i = new Intent(this, UserProfileActivity.class);
        // sending data to new activity
        i.putExtra(getString(R.string.extra_name), name);
        startActivity(i);
      }
    }

Stringing you along

You may have noticed some element invocations we haven’t seen before - R.string.extra_name, for one.

Rather than having hard-coded variables we pass around from class to class, it’s a much better practice to pre-define certain variables in an XML resource file that is available throughout the app. Specifically, in our res/values/strings.xml file, we have…

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <string name="app_name">RetroFitTest</string>
    <string name="hello_world">Hello world!</string>
    <string name="action_settings">Settings</string>
    <string name="please_wait">Please wait. Loading…</string>
    <string name="listadapter_placeholder">Name: %1$s\t id: %2$d\n</string>

    <string name="extra_url">course.examples.retrofittest.extra_url</string>
    <string name="extra_name">course.examples.retrofittest.extra_name</string>

</resources>

As you can see, we define the strings to be passed (like extra_url or extra_name), as well as being able to define strings with placeholders for variables that we can set in the code (like listadapter_placeholder).

This also lets you do cool things like enable different languages (localization), since you’re only calling the name of the string resource, not its contents.

Final Thoughts

Before we talk about anything else, let’s include our last two Activities - we’ve implemented all of our changes so they do look pretty different.

UserProfileActivity

public class UserProfileActivity extends Activity implements Validator.ValidationListener {
    private Validator mValidator;
    private ProgressDialog mProgressDialog;
    private boolean mIsContentLoaded;

    @Bind(R.id.profile_image)
    ImageView image;

    @NotEmpty
    @Bind(R.id.username_edit)
    EditText mUsernameEditText;

    @NotEmpty
    @Bind(R.id.name_edit)
    EditText mNameEditText;

    @NotEmpty
    @Email
    @Bind(R.id.email_edit)
    EditText mEmailEditText;

    @NotEmpty
    @Bind(R.id.public_repos_edit)
    EditText mPublicReposEditText;

    @NotEmpty
    @Bind(R.id.public_gists_edit)
    EditText mPublicGistsEditText;

    @NotEmpty
    @Bind(R.id.followers_edit)
    EditText mFollowersEditText;

    @NotEmpty
    @Bind(R.id.following_edit)
    EditText mFollowingEditText;

    @OnClick(R.id.done_button)
    void done() {
        if (mIsContentLoaded) {
            mValidator.validate();
        } else {
            Toast.makeText(getApplicationContext(), "Cannot validate unloaded content", Toast.LENGTH_SHORT).show();
        }

    }

    @OnClick(R.id.cancel_button)
    void cancel() {
        finish();
    }

    @Override
    public void onValidationSucceeded() {
        Toast.makeText(this, "Yay! we got it right!", Toast.LENGTH_SHORT).show();
        finish();
    }

    @Override
    public void onValidationFailed(List<ValidationError> errors) {
        for (ValidationError error : errors) {
            View view = error.getView();
            String message = error.getCollatedErrorMessage(this);

            // Display error messages ;)
            if (view instanceof EditText) {
                ((EditText) view).setError(message);
            } else {
                Toast.makeText(this, message, Toast.LENGTH_LONG).show();
            }
        }
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.user_profile);
        ButterKnife.bind(this);

        mValidator = new Validator(this);
        mValidator.setValidationListener(this);

        mProgressDialog = new ProgressDialog(this);
        mProgressDialog.setMessage("Please wait. Loading...");
        mProgressDialog.setCancelable(true);
        mProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
        // Need this to avoid crashes when backing out of web activity too fast
        if (!isFinishing()) {
            mProgressDialog.show();
        }

        Picasso.with(getApplicationContext())
                .load(R.drawable.placeholder)
                .into(image);

        Intent i = getIntent();
        String name = i.getStringExtra(getString(R.string.extra_name));

        GitClient.getGitClient().getUser(name, new Callback<User>() {
            @Override
            public void failure(RetrofitError error) {
                mIsContentLoaded = false;
                mProgressDialog.dismiss();
                Toast.makeText(getApplicationContext(), "Failed to load", Toast.LENGTH_SHORT).show();
            }

            @Override
            public void success(User user, Response response) {
                mIsContentLoaded = true;

                Picasso.with(getApplicationContext())
                        .load(user.getAvatar())
                        .into(image);

                mUsernameEditText.setText(user.getLogin());
                mNameEditText.setText(user.getName());
                mEmailEditText.setText(user.getEmail());
                mPublicReposEditText.setText(user.getPublicRepos());
                mPublicGistsEditText.setText(user.getPublicGists());
                mFollowersEditText.setText(String.valueOf(user.getFollowers()));
                mFollowingEditText.setText(String.valueOf(user.getFollowing()));

                mProgressDialog.dismiss();
            }
        });
      }
    }

GitRepoWebActivity

public class GitRepoWebActivity extends Activity {
    @Bind(R.id.webview)
    WebView mWebView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.repo_webview);
        ButterKnife.bind(this);

        String url = getIntent().getStringExtra(getString(R.string.extra_url));
        mWebView.loadUrl(url);
        mWebView.setWebViewClient(new WebViewClient() {
            private ProgressDialog mProgressDialog;

            @Override
            public void onPageStarted(WebView view, String url, Bitmap favicon) {
                super.onPageStarted(view, url, favicon);
                mProgressDialog = new ProgressDialog(view.getContext());
                mProgressDialog.setMessage(getString(R.string.please_wait));
                mProgressDialog.setCancelable(true);
                mProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
                // Need this to avoid crashes when backing out of web activity too fast
                if (!isFinishing()) {
                    mProgressDialog.show();
                }
            }

            @Override
            public void onPageFinished(WebView view, String url) {
                super.onPageFinished(view, url);
                Log.e("TAG", "onPageEnded");

                mProgressDialog.dismiss();
            }
        });
      }
    }

Some final words. We’ve gone through a lot, you and I. When we began this journey, we were young, fresh-faced, naive and unknowing. But now, we have taken that first step into old man cynicism and practicality. We’re positively adults.

As our application has grown, so have we, and it’s time to move on - with this last post, we’re closing the curtains on our beloved Git Repo Activity. It was a good, family project and we’re sad to see it go.

Join us next time, for an all-new project, in Android Adventures 2 Part 1 - Back 2 tha Hood.

Feature Photo credit: Badwsky / CC BY-NC-SA