Maintaining a balance between functionality and compatibility when developing an application
- Transfer
Developers of Android applications that focus on developing all released devices are probably familiar with this scheme:
As of July 1, 2010, these were statistics on running versions of Android. With the release of new versions of Android, developers began to think: to add new functions to the application provided by the new version, or to make it available on as many devices as possible.
Experienced developers have already made sure that these two options are mutually exclusive, and maintaining a balance between them can be painful. In this article, I will show you that this is not so.
A few weeks ago in our article, we examined how to handle multitouch on Android 2.0 (Eclair) and higher, getting a simple demo application at the end. In this article, we will redo our application so that it works correctly on all versions, up to Android 1.5. You can take the source code of the old application on Google Code .
Issues in the manifest
The uses-sdk tag in AndroidManifest.xml can be specified with two parameters: minSdkVersion and targetSdkVersion . By this you can say that the application is ready to work on older versions, but at the same time it can work on new ones. Now you can use the new SDK for development. But if you use the functionality of the new platform directly, this is what you can see in the system logs:
E/dalvikvm( 792): Could not find method android.view.MotionEvent.getX, referenced from method com.example.android.touchexample.TouchExampleView.onTouchEvent
W/dalvikvm( 792): VFY: unable to resolve virtual method 17: Landroid/view/MotionEvent;.getX (I)F
W/dalvikvm( 792): VFY: rejecting opcode 0x6e at 0x0006
W/dalvikvm( 792): VFY: rejected Lcom/example/android/touchexample/TouchExampleView;.onTouchEvent (Landroid/view/MotionEvent;)Z
W/dalvikvm( 792): Verifier rejected class Lcom/example/android/touchexample/TouchExampleView;
D/AndroidRuntime( 792): Shutting down VM
W/dalvikvm( 792): threadid=3: thread exiting with uncaught exception (group=0x4000fe70)
We specified minSdkVersion incorrectly , and here it is the result. We developed our application on SDK 8 (Froyo), but leaving the minSdkVersion = ”3” (Cupcake) parameter, we kind of told the application that we were aware of our intentions and would not ask for the impossible. If we leave it as it is, our users with older versions of the SDK will see an ugly error message. Of course, crowds of offended users will rate your application in the Market for 1 star. To avoid this, we must make secure access to the functions of the new platform without angry checks on older versions of the system.
Reflection method
Many developers are already familiar with the practice of solving this problem using reflection. Reflection allows the code to interact with the runtime and determine when the target methods or classes exist, and call them, or instantiate without touching them directly.
The prospect of feature requests from other platforms purposefully or conditionally referring to reflections is not good. This is ugly. It is slow. This is cumbersome. First of all, its heavy use in the code will turn it into inconvenient garbage for further support. What if I say that there is a way to write applications oriented to Android 1.5 (Cupcake) using 2.2 (Froyo) with one code and without using reflections?
Delayed loading
Researcher Bill Pugh has published and distributed a method for writing singletones in Java with all the benefits of using ClassLoader lazy loading. Wikipedia material explaining this method. The code looks like this:
public class Singleton {
// Private constructor prevents instantiation from other classes
private Singleton() {}
/**
* SingletonHolder загружается при первой проверке Singleton.getInstance()
*или при первом доступе к SingletonHolder.INSTANCE, не ранее.
*/
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
A very important part of his work is described in the commentary. Java classes are loaded and initialized at first access - it creates instances of the class for the first time or gets access to the method or one of its static fields. This is important for us because classes are checked by the virtual machine only when they are loaded, not earlier. Now we have everything to write Android applications without using the reflection method.
Compatibility Design
As it turned out, in practice it is quite simple. As a rule, you want your application to correctly degrade on older versions of the platform, removing some functions, or developing alternative ones. Since Android functionality is only associated with the version of the API, you have only one thing that you should follow when designing compatibility.
In most cases, this support option can be implemented as a simple class hierarchy. You can design your application to access version-sensitive functions using a version-independent interface or abstract classes. A subclass in the interface designed to run on new versions of the platform can support new system capabilities, and subclasses designed to run on older versions of the platform should provide application functionality in alternative ways.
Putting principles into practice
At the beginning of this article, I said that we will redo the previously made multi-touch application from API version 3 (Cupcake) to version 8 (Froyo). In a previously published article, I noted that GestureDetectors is a useful model for abstracting sensor event processing. Then I did not understand how quickly this is implemented and tested. We can redo the version- specific elements of the demo application and implement all this using the abstract GestureDetector .
Before we get started, we need to modify our manifest to declare support for API version 3 using minSdkVersion in the uses-sdk tag . Keep in mind that we are still focusing on version 8, this should also be noted in the parametertargetSdkVersion of your manifest. Now our manifest will look like this:
package="com.example.android.touchexample"
android:versionCode="1"
android:versionName="1.0">
android:label="@string/app_name">
Our TouchExampleView class is not compatible with Android versions up to Froyo due to the use of ScaleGestureDetector , and is not compatible with versions below Eclair due to the use of the new MotionEvent method which reads data from multitouch. We must abstract this functionality into classes that will not load on versions that do not support this functionality. To do this, we will create a new class, call it VersionedGestureDetector .
In the sample application, the user has 2 gestures, drag and scale. Therefore, the VersionedGestureDetector must define two events: onDrag and onScale .TouchExampleView should receive an instance of the VersionedGestureDetector class corresponding to the platform version, filter incoming events through it, and accordingly respond to onDrag and onScale .
The first versioned VersionDGestureDetector would be:
public abstract class VersionedGestureDetector {
OnGestureListener mListener;
public abstract boolean onTouchEvent(MotionEvent ev);
public interface OnGestureListener {
public void onDrag(float dx, float dy);
public void onScale(float scaleFactor);
}
}
First, the application starts with the simplest Cupcake-oriented functionality. For simplicity, in this example we will implement support for each version using a static inner class in the VersionedGestureDetector . Of course, you can implement this as you like, while using the delayed loading technique shown above, or equivalent to it. Do not touch classes that directly use functionality that is not supported by this version of the platform.
private static class CupcakeDetector extends VersionedGestureDetector {
float mLastTouchX;
float mLastTouchY;
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
mLastTouchX = ev.getX();
mLastTouchY = ev.getY();
break;
}
case MotionEvent.ACTION_MOVE: {
final float x = ev.getX();
final float y = ev.getY();
mListener.onDrag(x - mLastTouchX, y - mLastTouchY);
mLastTouchX = x;
mLastTouchY = y;
break;
}
}
return true;
}
}
This is a simple implementation of organizing the onDrag event when moving the pointer around the screen. The values that it takes are equal to the path traveled by the pointer in X and Y.
Starting with the Eclair version, we must clearly monitor the identifier of the pointer to prevent the appearance of additional pointers that go off the screen. The basic onTouchEvent implementation in CupcakeDetector can track pointer movements, but with two tricks. We must add the getActiveX and getActiveY methods to get the corresponding coordinates and override them in the EclairDetector to get the correct pointer coordinates.
private static class CupcakeDetector extends VersionedGestureDetector {
float mLastTouchX;
float mLastTouchY;
float getActiveX(MotionEvent ev) {
return ev.getX();
}
float getActiveY(MotionEvent ev) {
return ev.getY();
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
mLastTouchX = getActiveX(ev);
mLastTouchY = getActiveY(ev);
break;
}
case MotionEvent.ACTION_MOVE: {
final float x = getActiveX(ev);
final float y = getActiveY(ev);
mListener.onDrag(x - mLastTouchX, y - mLastTouchY);
mLastTouchX = x;
mLastTouchY = y;
break;
}
}
return true;
}
}
Now EclairDetector , overridden by new methods getActiveX and getActiveY . Most of this code should be familiar to you from the original example described at the beginning of the article.
private static class EclairDetector extends CupcakeDetector {
private static final int INVALID_POINTER_ID = -1;
private int mActivePointerId = INVALID_POINTER_ID;
private int mActivePointerIndex = 0;
@Override
float getActiveX(MotionEvent ev) {
return ev.getX(mActivePointerIndex);
}
@Override
float getActiveY(MotionEvent ev) {
return ev.getY(mActivePointerIndex);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
mActivePointerId = ev.getPointerId(0);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mActivePointerId = INVALID_POINTER_ID;
break;
case MotionEvent.ACTION_POINTER_UP:
final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
>> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
// This was our active pointer going up. Choose a new
// active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mActivePointerId = ev.getPointerId(newPointerIndex);
mLastTouchX = ev.getX(newPointerIndex);
mLastTouchY = ev.getY(newPointerIndex);
}
break;
}
mActivePointerIndex = ev.findPointerIndex(mActivePointerId);
return super.onTouchEvent(ev);
}
}
EclairDetector calls super.onTouchEvent after determining the identifier of the pointer and runs CupcakeDetector to determine the drag event. Multi-platform should not be a reason for duplication of code.
Finally, let's add a ScaleGestureDetector that will implement zoom gesture support for Froyo. In order to avoid movement during scaling, we need to add a few changes to the CupcakeDetector . Some touchscreens have problems with scaling, so we must take this into account.
We will add the shouldDrag method to CupcakeDetectorwhich will check before sending the onDrag event .
Final version of CupcakeDetector :
private static class CupcakeDetector extends VersionedGestureDetector {
float mLastTouchX;
float mLastTouchY;
float getActiveX(MotionEvent ev) {
return ev.getX();
}
float getActiveY(MotionEvent ev) {
return ev.getY();
}
boolean shouldDrag() {
return true;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
mLastTouchX = getActiveX(ev);
mLastTouchY = getActiveY(ev);
break;
}
case MotionEvent.ACTION_MOVE: {
final float x = getActiveX(ev);
final float y = getActiveY(ev);
if (shouldDrag()) {
mListener.onDrag(x - mLastTouchX, y - mLastTouchY);
}
mLastTouchX = x;
mLastTouchY = y;
break;
}
}
return true;
}
}
EclairDetector remains unchanged. FroyoDetector below. shouldDrag should return a positive value while scaling is inactive.
private static class FroyoDetector extends EclairDetector {
private ScaleGestureDetector mDetector;
public FroyoDetector(Context context) {
mDetector = new ScaleGestureDetector(context,
new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override public boolean onScale(ScaleGestureDetector detector) {
mListener.onScale(detector.getScaleFactor());
return true;
}
});
}
@Override
boolean shouldDrag() {
return !mDetector.isInProgress();
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
mDetector.onTouchEvent(ev);
return super.onTouchEvent(ev);
}
}
Now we have a gesture detector implementation, now we have to find a way to create it. Let's create a VersionedGestureDetector method .
public static VersionedGestureDetector newInstance(Context context,
OnGestureListener listener) {
final int sdkVersion = Integer.parseInt(Build.VERSION.SDK);
VersionedGestureDetector detector = null;
if (sdkVersion < Build.VERSION_CODES.ECLAIR) {
detector = new CupcakeDetector();
} else if (sdkVersion < Build.VERSION_CODES.FROYO) {
detector = new EclairDetector();
} else {
detector = new FroyoDetector(context);
}
detector.mListener = listener;
return detector;
}
Since we focus on Cupcake, we still do not have access to Build.VERSION.SDK_INT . Instead, we should use the now obsolete Build.VERSION.SDK .
Our VersionedGestureDetector is ready, now you need to combine it with TouchExampleView , which has become much shorter.
public class TouchExampleView extends View {
private Drawable mIcon;
private float mPosX;
private float mPosY;
private VersionedGestureDetector mDetector;
private float mScaleFactor = 1.f;
public TouchExampleView(Context context) {
this(context, null, 0);
}
public TouchExampleView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public TouchExampleView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mIcon = context.getResources().getDrawable(R.drawable.icon);
mIcon.setBounds(0, 0, mIcon.getIntrinsicWidth(), mIcon.getIntrinsicHeight());
mDetector = VersionedGestureDetector.newInstance(context, new GestureCallback());
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
mDetector.onTouchEvent(ev);
return true;
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.translate(mPosX, mPosY);
canvas.scale(mScaleFactor, mScaleFactor);
mIcon.draw(canvas);
canvas.restore();
}
private class GestureCallback implements VersionedGestureDetector.OnGestureListener {
public void onDrag(float dx, float dy) {
mPosX += dx;
mPosY += dy;
invalidate();
}
public void onScale(float scaleFactor) {
mScaleFactor *= scaleFactor;
// Don't let the object get too small or too large.
mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));
invalidate();
}
}
}
Conclusion
So we adapted our application to work correctly on Android 1.5 through the best latest features provided by the platform, and without a single use of reflections. The same principles can be applied to any new Android feature, allowing your application to run on older versions of Android:
- ClassLoader loads classes deferredly checking them at the first access.
- Functionality and interface, depending on the version of the platform.
- A version-specific implementation based on the version of the platform defined at runtime. This saves ClassLoader from using classes that cannot be executed correctly.
To see the final version, visit the Cupcake section on Google Code .
Additional Information
In this example, we did not propose an alternative path for the user using OSs released before Froyo, because ScaleGestureDetector became available only in 2.2. For real applications, we would like to suggest an alternative way. Traditionally, Android phones have hardkey zoom buttons. The ZoomControls and ZoomButtonsController classes will help you implement this path. The implementation of this will be an exercise for the reader.