Pull to refresh

Удержание баланса между функциональностью и совместимостью при разработке приложения

Reading time 22 min
Views 5K
Original author: Adam Powell
image

Разработчики приложений для Android, ориентирующиеся при разработке на все вышедшие устройства, наверняка знакомы с этой схемой:

image

На 1 июля 2010 года это было статистикой запущенных версий Android. С выходом новых версий Android разработчики стали задумываться: добавить в приложение новые функции, предоставленные новой версией, либо сделать его доступным на как можно большем количестве устройств.

Опытные разработчики уже удостоверились, что эти два варианта являются взаимоисключающими, и сохранение баланса между ними может быть болезненным. В этой статье я покажу вам, что это не так.

Несколько недель назад в нашей статье мы рассмотрели как обращаться с мультитачем на Android 2.0 (Eclair) и выше, получив в конце простое демонстрационное приложение. В этой статье мы переделаем наше приложение так, чтобы оно правильно работало на всех версиях, вплоть до Android 1.5. Вы можете взять исходники старого приложения на Google Code.

Проблемы в манифесте


Тэг uses-sdk в AndroidManifest.xml может быть уточнен двумя параметрами: minSdkVersion и targetSdkVersion. Этим вы можете сказать что приложение готово к работе на старых версиях, но в то же время может работать и на новых. Теперь вы можете использовать новые SDK для разработки. Но если вы будете использовать непосредственно функциональность новой платформы — вот что вы можете увидеть в системных журналах:

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)



Мы неправильно указали minSdkVersion, и вот он результат. Мы разрабатывали наше приложение на SDK 8 (Froyo), но оставив параметр minSdkVersion=”3” (Cupcake) мы как бы сказали приложению, что осознаем свои намерения, и не будем просить невозможного. Если мы оставим всё так как есть — наши пользователи со старыми версиями SDK увидят некрасивое сообщение об ошибке. Конечно же толпы обиженных пользователей будут оценивать ваше приложение в Маркете на 1 звезду. Чтобы избежать этого мы должны сделать безопасный доступ к функциям новой платформы без сердитых проверок на более старых версиях системы.

image

Метод отражения


Многие разработчики уже знакомы с практикой решения этой проблемы с помощью отражения (reflection). Отражение позволяет коду взаимодействовать со средой выполнения и определять когда целевые методы или классы существуют, и вызывать их, либо создавать экземпляр не касаясь их непосредственно.

Перспектива запросов функций других платформ целенаправленно либо условно ссылаясь на отражения — это не хорошо. Это уродливо. Это медленно. Это громоздко. Прежде всего, его интенсивное использование в коде превратит его в неудобный для дальнейшей поддержки мусор. Что если я скажу, что есть способ писать приложения ориентированные на Android 1.5 (Cupcake) используя 2.2 (Froyo) с помощью одного кода и не используя отражений?

Отложенная загрузка


Исследователь Bill Pugh опубликовал и распространил метод написания синглтонов в Java со всеми преимуществами использования отложенной загрузки ClassLoader'ов. Материал из Википедии, объясняющий этот метод. Код выглядит так:

public class Singleton {<br/>
  // Private constructor prevents instantiation from other classes<br/>
  private Singleton() {}<br/>
  /**<br/>
   * SingletonHolder загружается при первой проверке Singleton.getInstance() <br/>
   *или при первом доступе к SingletonHolder.INSTANCE, не ранее.<br/>
   */
<br/>
  private static class SingletonHolder { <br/>
    private static final Singleton INSTANCE = new Singleton();<br/>
  }<br/>
  public static Singleton getInstance() {<br/>
    return SingletonHolder.INSTANCE;<br/>
  }<br/>
}


Очень важная часть его работы описана в комментарии. Java классы загружаются и инициализируются при первом доступе — создает на первое время экземпляры класса либо получает доступ к методу или одному из его статических полей. Это важно для нас потому, что классы проверяются виртуальной машиной только когда они загружены, не раньше. Теперь у нас есть всё для того, чтобы писать приложения для Android не используя метод отражения.

Проектирование совместимости


Как оказалось, на практике это довольно просто. Как правило вы хотите, чтобы ваше приложение корректно деградировало на старых версиях платформы, убирая некоторые функции, либо разрабатывая альтернативные. Поскольку функциональность Android связана только с версией API у вас есть только одна вещь, за которой вы должны следить при проектировке совместимости.

В большинстве случаев этот вариант поддержки может быть реализован как простая иерархия классов. Вы можете спроектировать ваше приложение для доступа к функциям, чувствительным к версиям, используя независящий от версии интерфейс либо абстрактные классы. Подкласс в интерфейсе, предназначенном для запуска на новых версиях платформы, может поддерживать новые возможности системы, а подклассы, предназначенные для запуска на более старых версиях платформы, должны предоставлять функциональность приложения альтернативными путями.

Воплощение принципов на практике


В начале этой статьи я сказал, что мы будем переделывать сделанное ранее приложение для работы с мультитачем с версии API 3 (Cupcake) под версию 8 (Froyo). В опубликованной ранее статье я отметил, что GestureDetectors является полезным образцом для абстрагирования обработки событий сенсора. Тогда я не понимал как быстро это реализуют и протестируют. Мы можем переделать зависящие от версии элементы демо-приложения и реализовать всё это с помощью абстрактного GestureDetector.

Перед тем как начать работу, мы должны изменить наш манифест, чтобы заявить о поддержке API версии 3 с помощью minSdkVersion в тэге uses-sdk. Имейте в виду, что мы до сих пор ориентируемся на версию 8, это также нужно отметить в параметре targetSdkVersion вашего манифеста. Теперь наш манифест будет выглядеть так:

<?xml version="1.0" encoding="utf-8"?><br/>
<manifest xmlns:android="schemas.android.com/apk/res/android"<br/>
      package="com.example.android.touchexample"<br/>
      android:versionCode="1"<br/>
      android:versionName="1.0"><br/>
    <application android:icon="@drawable/icon" android:label="@string/app_name"><br/>
        <activity android:name=".TouchExampleActivity"<br/>
                  android:label="@string/app_name"><br/>
            <intent-filter><br/>
                <action android:name="android.intent.action.MAIN" /><br/>
                <category android:name="android.intent.category.LAUNCHER" /><br/>
            </intent-filter><br/>
        </activity><br/>
    </application><br/>
    <uses-sdk android:minSdkVersion="3" android:targetSdkVersion="8" /><br/>
</manifest>


Наш класс TouchExampleView не совместим с версиями Android вплоть до Froyo из-за использования ScaleGestureDetector, и не совместим с версиями ниже Eclair из-за использования нового метода MotionEvent который считывает данные с мультитача. Мы должны абстрагировать эту функциональность в классы, которые не будут загружаться на версиях, не поддерживающих данную функциональность. Для этого мы создадим новый класс, назовем его VersionedGestureDetector.

В приложении-примере пользователю доступно 2 жеста, перетаскивание (drag) и масштаб (scale). Следовательно, VersionedGestureDetector должен определять два события: onDrag и onScale. TouchExampleView должен получать экземпляр класса VersionedGestureDetector соответствующий версии платформы, фильтровать поступающие события через него, и соответственно реагировать на onDrag и onScale.

Первый вариант VersionedGestureDetector будет таким:

public abstract class VersionedGestureDetector {<br/>
    OnGestureListener mListener;<br/>
 <br/>
    public abstract boolean onTouchEvent(MotionEvent ev);<br/>
 <br/>
    public interface OnGestureListener {<br/>
        public void onDrag(float dx, float dy);<br/>
        public void onScale(float scaleFactor);<br/>
    }<br/>
}


Сначала приложение стартует с простейшей функциональностью, ориентированной на Cupcake. Для простоты в этом примере мы будем воплощать поддержку каждой версии с помощью статического внутреннего класса в VersionedGestureDetector. Конечно, вы можете реализовать это так, как вам захочется, при этом используя показанную выше технику отложенной загрузки, либо эквивалентную ей. Не трогайте классы, которые напрямую используют функциональность, не поддерживаемую данной версией платформы.

private static class CupcakeDetector extends VersionedGestureDetector {<br/>
    float mLastTouchX;<br/>
    float mLastTouchY;<br/>
    @Override<br/>
    public boolean onTouchEvent(MotionEvent ev) {<br/>
        switch (ev.getAction()) {<br/>
        case MotionEvent.ACTION_DOWN: {<br/>
            mLastTouchX = ev.getX();<br/>
            mLastTouchY = ev.getY();<br/>
            break;<br/>
        }<br/>
        case MotionEvent.ACTION_MOVE: {<br/>
            final float x = ev.getX();<br/>
            final float y = ev.getY();<br/>
            mListener.onDrag(- mLastTouchX, y - mLastTouchY);<br/>
 <br/>
            mLastTouchX = x;<br/>
            mLastTouchY = y;<br/>
            break;<br/>
        }<br/>
        }<br/>
        return true;<br/>
    }<br/>
}


Это простая реализация организации события onDrag при перемещении указателя по экрану. Значения, которые он принимает равны пройденному указателем пути по X и по Y.

Начиная с версии Eclair мы должны четко отслеживать идентификатор указателя чтобы не допустить появления дополнительных указателей, уходящих за пределы экрана. Базовая реализация onTouchEvent в CupcakeDetector может отслеживать перемещения указателя, но с двумя хитростями. Мы должны добавить методы getActiveX и getActiveY для получения соответствующих координат и переопределения их в EclairDetector для получения корректных координат указателя.
 <br/>
private static class CupcakeDetector extends VersionedGestureDetector {<br/>
    float mLastTouchX;<br/>
    float mLastTouchY;<br/>
 <br/>
    float getActiveX(MotionEvent ev) {<br/>
        return ev.getX();<br/>
    }<br/>
    float getActiveY(MotionEvent ev) {<br/>
        return ev.getY();<br/>
    }<br/>
    @Override<br/>
    public boolean onTouchEvent(MotionEvent ev) {<br/>
        switch (ev.getAction()) {<br/>
        case MotionEvent.ACTION_DOWN: {<br/>
            mLastTouchX = getActiveX(ev);<br/>
            mLastTouchY = getActiveY(ev);<br/>
            break;<br/>
        }<br/>
        case MotionEvent.ACTION_MOVE: {<br/>
            final float x = getActiveX(ev);<br/>
            final float y = getActiveY(ev);<br/>
            mListener.onDrag(- mLastTouchX, y - mLastTouchY);<br/>
 <br/>
            mLastTouchX = x;<br/>
            mLastTouchY = y;<br/>
            break;<br/>
        }<br/>
        }<br/>
        return true;<br/>
    }<br/>
}


Теперь EclairDetector, переопределенный новыми методами getActiveX и getActiveY. Большая часть этого кода должна быть вам знакома из оригинального примера, описанного в начале статьи.
 <br/>
private static class EclairDetector extends CupcakeDetector {<br/>
    private static final int INVALID_POINTER_ID = -1;<br/>
    private int mActivePointerId = INVALID_POINTER_ID;<br/>
    private int mActivePointerIndex = 0;<br/>
    @Override<br/>
    float getActiveX(MotionEvent ev) {<br/>
        return ev.getX(mActivePointerIndex);<br/>
    }<br/>
    @Override<br/>
    float getActiveY(MotionEvent ev) {<br/>
        return ev.getY(mActivePointerIndex);<br/>
    }<br/>
    @Override<br/>
    public boolean onTouchEvent(MotionEvent ev) {<br/>
        final int action = ev.getAction();<br/>
        switch (action & MotionEvent.ACTION_MASK) {<br/>
        case MotionEvent.ACTION_DOWN:<br/>
            mActivePointerId = ev.getPointerId(0);<br/>
            break;<br/>
        case MotionEvent.ACTION_CANCEL:<br/>
        case MotionEvent.ACTION_UP:<br/>
            mActivePointerId = INVALID_POINTER_ID;<br/>
            break;<br/>
        case MotionEvent.ACTION_POINTER_UP:<br/>
            final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) <br/>
                >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;<br/>
            final int pointerId = ev.getPointerId(pointerIndex);<br/>
            if (pointerId == mActivePointerId) {<br/>
                // This was our active pointer going up. Choose a new<br/>
                // active pointer and adjust accordingly.<br/>
                final int newPointerIndex = pointerIndex == 0 ? 1 : 0;<br/>
                mActivePointerId = ev.getPointerId(newPointerIndex);<br/>
                mLastTouchX = ev.getX(newPointerIndex);<br/>
                mLastTouchY = ev.getY(newPointerIndex);<br/>
            }<br/>
            break;<br/>
        }<br/>
        mActivePointerIndex = ev.findPointerIndex(mActivePointerId);<br/>
        return super.onTouchEvent(ev);<br/>
    }<br/>
}

EclairDetector вызывает super.onTouchEvent после определения идентификатора указателя запускает CupcakeDetector для определения события перемещения (drag). Мультиплатформенность не должна стать причиной для дублирования кода.

Наконец, давайте добавим ScaleGestureDetector, который будет реализовывать поддержку жеста масштабирования для Froyo. Для того чтобы избежать перемещения во время масштабирования мы должны добавить несколько изменений в CupcakeDetector. У некоторых тачскринов есть проблемы с масштабированием, поэтому мы должны учесть это.
Мы добавим метод shouldDrag в CupcakeDetector который будет осуществлять проверку перед отправкой события onDrag.

Финальная версия CupcakeDetector:

private static class CupcakeDetector extends VersionedGestureDetector {<br/>
    float mLastTouchX;<br/>
    float mLastTouchY;<br/>
 <br/>
    float getActiveX(MotionEvent ev) {<br/>
        return ev.getX();<br/>
    }<br/>
    float getActiveY(MotionEvent ev) {<br/>
        return ev.getY();<br/>
    }<br/>
 <br/>
    boolean shouldDrag() {<br/>
        return true;<br/>
    }<br/>
    @Override<br/>
    public boolean onTouchEvent(MotionEvent ev) {<br/>
        switch (ev.getAction()) {<br/>
        case MotionEvent.ACTION_DOWN: {<br/>
            mLastTouchX = getActiveX(ev);<br/>
            mLastTouchY = getActiveY(ev);<br/>
            break;<br/>
        }<br/>
        case MotionEvent.ACTION_MOVE: {<br/>
            final float x = getActiveX(ev);<br/>
            final float y = getActiveY(ev);<br/>
 <br/>
            if (shouldDrag()) {<br/>
                mListener.onDrag(- mLastTouchX, y - mLastTouchY);<br/>
            }<br/>
 <br/>
            mLastTouchX = x;<br/>
            mLastTouchY = y;<br/>
            break;<br/>
        }<br/>
        }<br/>
        return true;<br/>
    }<br/>
}


EclairDetector остается неизменным. FroyoDetector ниже. shouldDrag должен возвращать положительное значение пока неактивно масштабирование.

private static class FroyoDetector extends EclairDetector {<br/>
    private ScaleGestureDetector mDetector;<br/>
    public FroyoDetector(Context context) {<br/>
        mDetector = new ScaleGestureDetector(context,<br/>
                new ScaleGestureDetector.SimpleOnScaleGestureListener() {<br/>
                    @Override public boolean onScale(ScaleGestureDetector detector) {<br/>
                        mListener.onScale(detector.getScaleFactor());<br/>
                        return true;<br/>
                    }<br/>
                });<br/>
    }<br/>
    @Override<br/>
    boolean shouldDrag() {<br/>
        return !mDetector.isInProgress();<br/>
    }<br/>
    @Override<br/>
    public boolean onTouchEvent(MotionEvent ev) {<br/>
        mDetector.onTouchEvent(ev);<br/>
        return super.onTouchEvent(ev);<br/>
    }<br/>
}


Теперь у нас есть реализация детектора жестов, теперь мы должны найти способ создать его. Давайте создадим метод VersionedGestureDetector.

public static VersionedGestureDetector newInstance(Context context,<br/>
        OnGestureListener listener) {<br/>
    final int sdkVersion = Integer.parseInt(Build.VERSION.SDK);<br/>
    VersionedGestureDetector detector = null;<br/>
    if (sdkVersion < Build.VERSION_CODES.ECLAIR) {<br/>
        detector = new CupcakeDetector();<br/>
    } else if (sdkVersion < Build.VERSION_CODES.FROYO) {<br/>
        detector = new EclairDetector();<br/>
    } else {<br/>
        detector = new FroyoDetector(context);<br/>
    }<br/>
    detector.mListener = listener;<br/>
    return detector;<br/>
}


Поскольку мы ориентируемся на Cupcake, мы еще не имеем доступа к Build.VERSION.SDK_INT. Вместо него мы должны использовать ныне устаревший Build.VERSION.SDK.

Наш VersionedGestureDetector готов, теперь нужно совместить его с TouchExampleView, который стал значительно короче.

public class TouchExampleView extends View {<br/>
    private Drawable mIcon;<br/>
    private float mPosX;<br/>
    private float mPosY;<br/>
    private VersionedGestureDetector mDetector;<br/>
    private float mScaleFactor = 1.f;<br/>
    public TouchExampleView(Context context) {<br/>
        this(context, null, 0);<br/>
    }<br/>
    public TouchExampleView(Context context, AttributeSet attrs) {<br/>
        this(context, attrs, 0);<br/>
    }<br/>
    public TouchExampleView(Context context, AttributeSet attrs, int defStyle) {<br/>
        super(context, attrs, defStyle);<br/>
        mIcon = context.getResources().getDrawable(R.drawable.icon);<br/>
        mIcon.setBounds(00, mIcon.getIntrinsicWidth(), mIcon.getIntrinsicHeight());<br/>
 <br/>
        mDetector = VersionedGestureDetector.newInstance(context, new GestureCallback());<br/>
    }<br/>
    @Override<br/>
    public boolean onTouchEvent(MotionEvent ev) {<br/>
        mDetector.onTouchEvent(ev);<br/>
        return true;<br/>
    }<br/>
    @Override<br/>
    public void onDraw(Canvas canvas) {<br/>
        super.onDraw(canvas);<br/>
        canvas.save();<br/>
        canvas.translate(mPosX, mPosY);<br/>
        canvas.scale(mScaleFactor, mScaleFactor);<br/>
        mIcon.draw(canvas);<br/>
        canvas.restore();<br/>
    }<br/>
    private class GestureCallback implements VersionedGestureDetector.OnGestureListener {<br/>
        public void onDrag(float dx, float dy) {<br/>
            mPosX += dx;<br/>
            mPosY += dy;<br/>
            invalidate();<br/>
        }<br/>
        public void onScale(float scaleFactor) {<br/>
            mScaleFactor *= scaleFactor;<br/>
            // Don't let the object get too small or too large.<br/>
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));<br/>
            invalidate();<br/>
        }<br/>
    }<br/>
}


Заключение


Вот мы и адаптировали наше приложение для корректной работы на Android 1.5 через лучшие новейшие функции, предоставленные платформой, и без единого использования отражений. Те же самые принципы можно применить к любой новой особенности Android, позволяя вашему приложению запускаться на более старых версиях Android:
  • ClassLoader загружает классы отложенно проверяя их при первом доступе.
  • Функциональность и интерфейс, зависящие от версии платформы.
  • Зависящая от версии реализация базированная на версии платформы определенной во время выполнения. Это избавляет ClassLoader от использования классов, которые не смогут быть выполнены корректно.


Чтобы увидеть окончательную версию — посетите раздел «Cupcake» на Google Code.

Дополнительная информация


В этом примере мы не стали предлагать альтернативного пути для пользователе, использующих ОС, вышедшие до Froyo, т. к. ScaleGestureDetector стал доступен только в 2.2. Для реальных приложений мы хотели бы предложить альтернативный путь. Традиционно телефоны с Android имеют хардварные кнопки управления зумом. Классы ZoomControls и ZoomButtonsController помогут вам в реализации данного пути. Реализация этого и будет упражнением для читателя.
Tags:
Hubs:
+57
Comments 15
Comments Comments 15

Articles