Синхронизация в Android приложениях. Часть вторая

  • Tutorial
account
Коллеги, добрый день. Продолжим тему, начатую в прошлой статье, где мы рассмотрели механизм создания аккаунта на устройстве. Это было первым необходимым условием для использования SyncAdapter Framework'а.

Вторым условием является наличие ContentProvider'а, процесс написания которого разжеван в документации. Признаться честно, мне не очень нравится как там это описано: все кажется громоздким и сложным. Поэтому немного повелосипедим и еще разок пережуем эту тему. Можно было бы обойтись и провайдером-заглушкой, но мы люди серьезные и будем использовать всю мощь этого инструмента.

В комментариях к предыдущей части промелькнула просьба рассмотреть случай, когда нам не нужна авторизация, а только синхронизация. Такой случай и рассмотрим. В качестве примера возьмем и напишем простую rss читалку для чтения нашего любимого хабра и не только. Да вот так банально.

В приложении будет возможность добавлять/удалять ленты, просматривать список новостей и открывать их в браузере. Визуализировать процесс синхронизации и ее запуск будем с помощью добавленного недавно в support-library класса SwipeRefreshLayout. Почитать, что это и как использовать, можно тут.

Чтобы настраивать автоматическую синхронизацию через определенные интервалы времени, нам потребуется экран настроек этого добра. Желательно, чтобы доступ к нему был не только из приложения, но и из системного экрана нашего аккаунта (как на скриншоте к статье). Используем для этого PreferenceFragment'ы. С функциональностью определились, приступим.

Account


Как добавить аккаунт в приложение вы уже знаете из предыдущей части. Но для нашего приложения нам не потребуется авторизация, соответственно, заменим Authenticator пустой реализацией.
Authenticator.java
public class Authenticator extends AbstractAccountAuthenticator {

    public Authenticator(Context context) {
        super(context);
    }

    @Override
    public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
                             String[] requiredFeatures, Bundle options)
            throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options)
            throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType,
                               Bundle options) throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

    @Override
    public String getAuthTokenLabel(String authTokenType) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType,
                                    Bundle options) throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features)
            throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

}


Нам потребуется немного модифицировать файл res/xml/authenticator.xml, чтобы добавить ему возможность перехода на экран настроек синхронизации. Добавим параметр android:accountPreferences с указанием файла, из которого эти самые Preferences нужно подтянуть. При клике на элемент «Синхронизация» будет открываться SyncSettingsActivity нашего приложения.
authenticator.xml
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
    android:accountPreferences="@xml/account_prefs"
    android:accountType="com.elegion.newsfeed.account"
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_name"
    android:smallIcon="@drawable/ic_launcher" />


account_prefs.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
    android:persistent="true">

    <PreferenceCategory android:title="@string/general_settings" />

    <PreferenceScreen
        android:key="com.elegion.newsfeed.KEY_ACCOUNT_SYNC"
        android:summary="@string/sync_settings_summary"
        android:title="@string/sync">
        <intent
            android:action="com.elegion.newsfeed.ACTION_SYNC_SETTINGS"
            android:targetClass="com.elegion.newsfeed.activity.SyncSettingsActivity"
            android:targetPackage="com.elegion.newsfeed" />
    </PreferenceScreen>

</PreferenceScreen>


ContentProvider


Наш провайдер будет оберткой над SQLite базой данных, в которой мы будем хранить новости. Остановимся немного и подробнее рассмотрим его реализацию. Провайдер умеет работать с двумя типами Uri:
content://authority/table — выборка всех значений из таблицы
content://authority/table/_id — выборка одного значения по primary key
в методе onCreate с помощью PackageManager.getProviderInfo мы получаем authority для этого провайдера и регистрируем их в SQLiteUriMatcher. Что происходит в методах: провайдер берет из uri название таблицы, затем из SCHEMA для этой таблицы берется конкретная реализация SQLiteTableProvider (провайдера для таблицы). У SQLiteTableProvider вызываются соответствующие методы (по сути, происходит проксирование вызова). Такой подход позволяет для каждой таблицы кастомизировать работу с данными. В зависимости от результатов, ContentResolver (а с ним и наше приложение) получает уведомление об изменении данных. Для uri типа content://authority/table/_id переписывается условие where, чтобы обеспечить работу по первичному ключу. При желании, можно немного докрутить этот провайдер и вынести в библиотечный класс. Как показывает практика, такой реализации достаточно для 90% задач (остальные 10 — full text search, like nocase search).
SQLiteContentProvider.java
public class SQLiteContentProvider extends ContentProvider {

    private static final String DATABASE_NAME = "newsfeed.db";

    private static final int DATABASE_VERSION = 1;

    private static final String MIME_DIR = "vnd.android.cursor.dir/";

    private static final String MIME_ITEM = "vnd.android.cursor.item/";

    private static final Map<String, SQLiteTableProvider> SCHEMA = new ConcurrentHashMap<>();

    static {
        SCHEMA.put(FeedProvider.TABLE_NAME, new FeedProvider());
        SCHEMA.put(NewsProvider.TABLE_NAME, new NewsProvider());
    }

    private final SQLiteUriMatcher mUriMatcher = new SQLiteUriMatcher();

    private SQLiteOpenHelper mHelper;

    private static ProviderInfo getProviderInfo(Context context, Class<? extends ContentProvider> provider, int flags)
            throws PackageManager.NameNotFoundException {
        return context.getPackageManager()
                .getProviderInfo(new ComponentName(context.getPackageName(), provider.getName()), flags);
    }

    private static String getTableName(Uri uri) {
        return uri.getPathSegments().get(0);
    }


    @Override
    public boolean onCreate() {
        try {
            final ProviderInfo pi = getProviderInfo(getContext(), getClass(), 0);
            final String[] authorities = TextUtils.split(pi.authority, ";");
            for (final String authority : authorities) {
                mUriMatcher.addAuthority(authority);
            }
            mHelper = new SQLiteOpenHelperImpl(getContext());
            return true;
        } catch (PackageManager.NameNotFoundException e) {
            throw new SQLiteException(e.getMessage());
        }
    }

    @Override
    public Cursor query(Uri uri, String[] columns, String where, String[] whereArgs, String orderBy) {
        final int matchResult = mUriMatcher.match(uri);
        if (matchResult == SQLiteUriMatcher.NO_MATCH) {
            throw new SQLiteException("Unknown uri " + uri);
        }
        final String tableName = getTableName(uri);
        final SQLiteTableProvider tableProvider = SCHEMA.get(tableName);
        if (tableProvider == null) {
            throw new SQLiteException("No such table " + tableName);
        }
        if (matchResult == SQLiteUriMatcher.MATCH_ID) {
            where = BaseColumns._ID + "=?";
            whereArgs = new String[]{uri.getLastPathSegment()};
        }
        final Cursor cursor = tableProvider.query(mHelper.getReadableDatabase(), columns, where, whereArgs, orderBy);
        cursor.setNotificationUri(getContext().getContentResolver(), uri);
        return cursor;
    }

    @Override
    public String getType(Uri uri) {
        final int matchResult = mUriMatcher.match(uri);
        if (matchResult == SQLiteUriMatcher.NO_MATCH) {
            throw new SQLiteException("Unknown uri " + uri);
        } else if (matchResult == SQLiteUriMatcher.MATCH_ID) {
            return MIME_ITEM + getTableName(uri);
        }
        return MIME_DIR + getTableName(uri);
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        final int matchResult = mUriMatcher.match(uri);
        if (matchResult == SQLiteUriMatcher.NO_MATCH) {
            throw new SQLiteException("Unknown uri " + uri);
        }
        final String tableName = getTableName(uri);
        final SQLiteTableProvider tableProvider = SCHEMA.get(tableName);
        if (tableProvider == null) {
            throw new SQLiteException("No such table " + tableName);
        }
        if (matchResult == SQLiteUriMatcher.MATCH_ID) {
            final int affectedRows = updateInternal(
                    tableProvider.getBaseUri(), tableProvider,
                    values, BaseColumns._ID + "=?",
                    new String[]{uri.getLastPathSegment()}
            );
            if (affectedRows > 0) {
                return uri;
            }
        }
        final long lastId = tableProvider.insert(mHelper.getWritableDatabase(), values);
        getContext().getContentResolver().notifyChange(tableProvider.getBaseUri(), null);
        final Bundle extras = new Bundle();
        extras.putLong(SQLiteOperation.KEY_LAST_ID, lastId);
        tableProvider.onContentChanged(getContext(), SQLiteOperation.INSERT, extras);
        return uri;
    }

    @Override
    public int delete(Uri uri, String where, String[] whereArgs) {
        final int matchResult = mUriMatcher.match(uri);
        if (matchResult == SQLiteUriMatcher.NO_MATCH) {
            throw new SQLiteException("Unknown uri " + uri);
        }
        final String tableName = getTableName(uri);
        final SQLiteTableProvider tableProvider = SCHEMA.get(tableName);
        if (tableProvider == null) {
            throw new SQLiteException("No such table " + tableName);
        }
        if (matchResult == SQLiteUriMatcher.MATCH_ID) {
            where = BaseColumns._ID + "=?";
            whereArgs = new String[]{uri.getLastPathSegment()};
        }
        final int affectedRows = tableProvider.delete(mHelper.getWritableDatabase(), where, whereArgs);
        if (affectedRows > 0) {
            getContext().getContentResolver().notifyChange(uri, null);
            final Bundle extras = new Bundle();
            extras.putLong(SQLiteOperation.KEY_AFFECTED_ROWS, affectedRows);
            tableProvider.onContentChanged(getContext(), SQLiteOperation.DELETE, extras);
        }
        return affectedRows;
    }

    @Override
    public int update(Uri uri, ContentValues values, String where, String[] whereArgs) {
        final int matchResult = mUriMatcher.match(uri);
        if (matchResult == SQLiteUriMatcher.NO_MATCH) {
            throw new SQLiteException("Unknown uri " + uri);
        }
        final String tableName = getTableName(uri);
        final SQLiteTableProvider tableProvider = SCHEMA.get(tableName);
        if (tableProvider == null) {
            throw new SQLiteException("No such table " + tableName);
        }
        if (matchResult == SQLiteUriMatcher.MATCH_ID) {
            where = BaseColumns._ID + "=?";
            whereArgs = new String[]{uri.getLastPathSegment()};
        }
        return updateInternal(tableProvider.getBaseUri(), tableProvider, values, where, whereArgs);
    }

    private int updateInternal(Uri uri, SQLiteTableProvider provider,
                               ContentValues values, String where, String[] whereArgs) {
        final int affectedRows = provider.update(mHelper.getWritableDatabase(), values, where, whereArgs);
        if (affectedRows > 0) {
            getContext().getContentResolver().notifyChange(uri, null);
            final Bundle extras = new Bundle();
            extras.putLong(SQLiteOperation.KEY_AFFECTED_ROWS, affectedRows);
            provider.onContentChanged(getContext(), SQLiteOperation.UPDATE, extras);
        }
        return affectedRows;
    }

    private static final class SQLiteOpenHelperImpl extends SQLiteOpenHelper {

        public SQLiteOpenHelperImpl(Context context) {
            super(context, DATABASE_NAME, null, DATABASE_VERSION);
        }

        @Override
        public void onCreate(SQLiteDatabase db) {
            db.beginTransactionNonExclusive();
            try {
                for (final SQLiteTableProvider table : SCHEMA.values()) {
                    table.onCreate(db);
                }
                db.setTransactionSuccessful();
            } finally {
                db.endTransaction();
            }
        }

        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            db.beginTransactionNonExclusive();
            try {
                for (final SQLiteTableProvider table : SCHEMA.values()) {
                    table.onUpgrade(db, oldVersion, newVersion);
                }
                db.setTransactionSuccessful();
            } finally {
                db.endTransaction();
            }
        }

    }

}


Теперь нужно прописать провайдер в AndroidManifest.xml и обратить внимание на параметр android:syncable=«true». Этот флаг говорит о том, что наш провайдер поддерживает синхронизацию.
AndroidManifest.xml
<provider
    android:name=".sqlite.SQLiteContentProvider"
    android:authorities="com.elegion.newsfeed"
    android:exported="false"
    android:syncable="true" />


Также представляет интерес класс FeedProvider — реализация SQLiteTableProvider для работы с лентами новостей. При вставке (!) в эту таблицу (подписка на новую ленту) будет вызываться принудительная синхронизация. За это отвечает метод onContentChanged, который дергается из SQLiteContentProvider при изменении данных (insert/update/delete). Для таблицы будет создан триггер (onCreate), который будет удалять связанные с лентой новости. Почему стоит вызывать синхронизацию только при вставке? Чтобы избежать зацикливания, потому что наш провайдер будет обновлять таблицу (добавлять заголовок, ссылку на картинку, дату публикации и т.д.). Дополнительные параметры синхронизации передаются через syncExtras.
FeedProvider.java
public class FeedProvider extends SQLiteTableProvider {

    public static final String TABLE_NAME = "feeds";

    public static final Uri URI = Uri.parse("content://com.elegion.newsfeed/" + TABLE_NAME);

    public FeedProvider() {
        super(TABLE_NAME);
    }

    public static long getId(Cursor c) {
        return c.getLong(c.getColumnIndex(Columns._ID));
    }

    public static String getIconUrl(Cursor c) {
        return c.getString(c.getColumnIndex(Columns.IMAGE_URL));
    }

    public static String getTitle(Cursor c) {
        return c.getString(c.getColumnIndex(Columns.TITLE));
    }

    public static String getLink(Cursor c) {
        return c.getString(c.getColumnIndex(Columns.LINK));
    }

    public static long getPubDate(Cursor c) {
        return c.getLong(c.getColumnIndex(Columns.PUB_DATE));
    }

    public static String getRssLink(Cursor c) {
        return c.getString(c.getColumnIndex(Columns.RSS_LINK));
    }

    @Override
    public Uri getBaseUri() {
        return URI;
    }

    @Override
    public void onContentChanged(Context context, int operation, Bundle extras) {
        if (operation == INSERT) {
            extras.keySet();
            final Bundle syncExtras = new Bundle();
            syncExtras.putLong(SyncAdapter.KEY_FEED_ID, extras.getLong(KEY_LAST_ID, -1));
            ContentResolver.requestSync(AppDelegate.sAccount, AppDelegate.AUTHORITY, syncExtras);
        }
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL("create table if not exists " + TABLE_NAME +
                "(" + Columns._ID + " integer primary key on conflict replace, "
                + Columns.TITLE + " text, "
                + Columns.LINK + " text, "
                + Columns.IMAGE_URL + " text, "
                + Columns.LANGUAGE + " text, "
                + Columns.PUB_DATE + " integer, "
                + Columns.RSS_LINK + " text unique on conflict ignore)");
        db.execSQL("create trigger if not exists after delete on " + TABLE_NAME +
                " begin " +
                " delete from " + NewsProvider.TABLE_NAME + " where " + NewsProvider.Columns.FEED_ID + "=old." + Columns._ID + ";" +
                " end;");
    }

    public interface Columns extends BaseColumns {
        String TITLE = "title";
        String LINK = "link";
        String IMAGE_URL = "imageUrl";
        String LANGUAGE = "language";
        String PUB_DATE = "pubDate";
        String RSS_LINK = "rssLink";
    }

}


За сим кроличья норка заканчивается, и начинается зазеркалье.

SyncAdapter


Перед тем как ворваться в процесс создания SyncAdapter'а, давайте подумаем, зачем вообще это нужно, какие преимущества дает. Если верить документации, то, как минимум, мы получим:

  • Проверку состояния и запуск синхронизации при доступности сети.
  • Планировщик, который выполнит синхронизацию по критериям и/или расписанию.
  • Автоматический запуск синхронизации, если она по каким-то причинам не удалась в прошлый раз.
  • Экономию заряда батареи, так как система будет реже переключать радио модуль. Плюс синхронизация не запустится при критическом уровне заряда.
  • Интеграцию в интерфейс настроек системы.

Уже неплохо, правда? Добавим, что при использовании ContentProvider'а, мы можем запускать синхронизацию при изменении данных в нем. Это полностью снимает с нас необходимость отслеживать изменение данных в приложении и выполнять синхронизацию в «ручном режиме».

Процесс интеграции этого добра очень похож на процесс интеграции своего аккаунта в приложение. Нам потребуется реализация AbstractThreadedSyncAdapter и Service для интеграции в систему. AbstractThreadedSyncAdapter имеет всего один абстрактный метод onPerformSync, в котором и происходит вся магия. Что же именно тут происходит? В зависимости от переданных extras-параметров (помните syncExtras в FeedProvider.onContentChanged) синхронизируется или одна лента или все. В общем, мы выбираем из базы ленты, парсим rss по ссылке и складываем в нашу базу с помощью ContentProviderClient provider. Для информирования системы о статусе (количестве обновлений, ошибок и т.д.) синхронизации используется SyncResult syncResult.
SyncAdapter.java
public class SyncAdapter extends AbstractThreadedSyncAdapter {

    public static final String KEY_FEED_ID = "com.elegion.newsfeed.sync.KEY_FEED_ID";

    public SyncAdapter(Context context) {
        super(context, true);
    }

    @Override
    public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider,
                              SyncResult syncResult) {
        final long feedId = extras.getLong(KEY_FEED_ID, -1);
        if (feedId > 0) {
            syncFeeds(provider, syncResult, FeedProvider.Columns._ID + "=?", new String[]{String.valueOf(feedId)});
        } else {
            syncFeeds(provider, syncResult, null, null);
        }
    }

    private void syncFeeds(ContentProviderClient provider, SyncResult syncResult, String where, String[] whereArgs) {
        try {
            final Cursor feeds = provider.query(
                    FeedProvider.URI, new String[]{
                            FeedProvider.Columns._ID,
                            FeedProvider.Columns.RSS_LINK
                    }, where, whereArgs, null
            );
            try {
                if (feeds.moveToFirst()) {
                    do {
                        syncFeed(feeds.getString(0), feeds.getString(1), provider, syncResult);
                    } while (feeds.moveToNext());
                }
            } finally {
                feeds.close();
            }
        } catch (RemoteException e) {
            Log.e(SyncAdapter.class.getName(), e.getMessage(), e);
            ++syncResult.stats.numIoExceptions;
        }
    }

    private void syncFeed(String feedId, String feedUrl, ContentProviderClient provider, SyncResult syncResult) {
        try {
            final HttpURLConnection cn = (HttpURLConnection) new URL(feedUrl).openConnection();
            try {
                final RssFeedParser parser = new RssFeedParser(cn.getInputStream());
                try {
                    parser.parse(feedId, provider, syncResult);
                } finally {
                    parser.close();
                }
            } finally {
                cn.disconnect();
            }
        } catch (IOException e) {
            Log.e(SyncAdapter.class.getName(), e.getMessage(), e);
            ++syncResult.stats.numIoExceptions;
        }
    }

}


Реализация SyncService тоже очень проста. Все, что нам нужно это отдать IBinder объект системе, для связи с нашим SyncAdapter'ом. Чтобы система поняла, что за адаптер мы регистрируем, понадобится xml-мета файл sync_adapter.xml, а также прописать все это добро в AndroidManifest.xml.
SyncService.java
public class SyncService extends Service {

    private static SyncAdapter sSyncAdapter;

    @Override
    public void onCreate() {
        super.onCreate();
        if (sSyncAdapter == null) {
            sSyncAdapter = new SyncAdapter(getApplicationContext());
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        return sSyncAdapter.getSyncAdapterBinder();
    }

}


sync_adapter.xml
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
    android:accountType="com.elegion.newsfeed.account"
    android:allowParallelSyncs="false"
    android:contentAuthority="com.elegion.newsfeed"
    android:isAlwaysSyncable="true"
    android:supportsUploading="false"
    android:userVisible="true" />


AndroidManifest.xml
<service
    android:name=".sync.SyncService"
    android:exported="false"
    android:process=":sync">
    <intent-filter>
        <action android:name="android.content.SyncAdapter" />
    </intent-filter>
    <meta-data
        android:name="android.content.SyncAdapter"
        android:resource="@xml/sync_adapter" />
</service>


А теперь пример


image
Вот так будет выглядеть окно со списком лент. Как вы помните, мы договорились использовать SwipeRefreshLayout для принудительной синхронизации и визуализации этого процесса. Список лент FeedList.java и список новостей NewsList.java будут наследоваться от общего родителя SwipeToRefreshList.java.

Для отслеживания статуса синхронизации, необходимо зарегистрировать Observer в ContentResolver'е (метод SwipeToRefreshList.onResume()). Для этого служит метод ContentResolver.addStatusChangeListener. В методе SwipeToRefreshList.onStatusChanged проверяем статус синхронизации с помощью метода ContentResolver.isSyncActive и передаем этот результат в метод SwipeToRefreshList.onSyncStatusChanged, который будет переопределен наследниками. Все, что будет делать этот метод — прятать/показывать полоску прогресса у SwipeRefreshLayout. Так как SyncStatusObserver.onStatusChanged вызывается из отдельного потока, оборачиваем результат в хэндлер. Метод SwipeToRefreshList.onRefresh в потомках запускает принудительную синхронизацию с помощью ContentResolver.requestSync.

Все списки загружаются и отображаются с помощью CursorLoader + CursorAdapter, которые тоже замечательно работают в связке с ContentProvider'ом, избавляя нас от необходимости следить за актуальностью списков. Как только новый элемент будет добавлен в провайдер, все CursorLoader'ы получат уведомления и актуализируют данные в CursorAdapter'ах.
SwipeToRefreshList.java
public class SwipeToRefreshList extends Fragment implements SwipeRefreshLayout.OnRefreshListener, SyncStatusObserver,
        AdapterView.OnItemClickListener, SwipeToDismissCallback {

    private SwipeRefreshLayout mRefresher;

    private ListView mListView;

    private Object mSyncMonitor;

    private SwipeToDismissController mSwipeToDismissController;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        final View view = inflater.inflate(R.layout.fmt_swipe_to_refresh_list, container, false);
        mListView = (ListView) view.findViewById(android.R.id.list);
        return (mRefresher = (SwipeRefreshLayout) view);
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        mRefresher.setColorScheme(
                android.R.color.holo_blue_light,
                android.R.color.holo_red_light,
                android.R.color.holo_green_light,
                android.R.color.holo_orange_light
        );
        mSwipeToDismissController = new SwipeToDismissController(mListView, this);
    }

    @Override
    public void onResume() {
        super.onResume();
        mRefresher.setOnRefreshListener(this);
        mSyncMonitor = ContentResolver.addStatusChangeListener(
                ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
                        | ContentResolver.SYNC_OBSERVER_TYPE_PENDING,
                this
        );
        mListView.setOnItemClickListener(this);
        mListView.setOnTouchListener(mSwipeToDismissController);
        mListView.setOnScrollListener(mSwipeToDismissController);
    }

    @Override
    public void onPause() {
        mRefresher.setOnRefreshListener(null);
        ContentResolver.removeStatusChangeListener(mSyncMonitor);
        mListView.setOnItemClickListener(null);
        mListView.setOnTouchListener(null);
        mListView.setOnScrollListener(null);
        super.onPause();
    }

    @Override
    public final void onRefresh() {
        onRefresh(AppDelegate.sAccount);
    }

    @Override
    public final void onStatusChanged(int which) {
        mRefresher.post(new Runnable() {
            @Override
            public void run() {
                onSyncStatusChanged(AppDelegate.sAccount, ContentResolver
                        .isSyncActive(AppDelegate.sAccount, AppDelegate.AUTHORITY));
            }
        });
    }

    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {

    }

    @Override
    public boolean canDismissView(View view, int position) {
        return false;
    }

    @Override
    public void dismissView(View view, int position) {

    }

    public void setListAdapter(ListAdapter adapter) {
        final DataSetObserver dataSetObserver = mSwipeToDismissController.getDataSetObserver();
        final ListAdapter oldAdapter = mListView.getAdapter();
        if (oldAdapter != null) {
            oldAdapter.unregisterDataSetObserver(dataSetObserver);
        }
        mListView.setAdapter(adapter);
        adapter.registerDataSetObserver(dataSetObserver);
    }

    protected void onRefresh(Account account) {

    }

    protected void onSyncStatusChanged(Account account, boolean isSyncActive) {

    }

    protected void setRefreshing(boolean refreshing) {
        mRefresher.setRefreshing(refreshing);
    }

}


image
Итак, с принудительной синхронизацией разобрались. Но самый сок — синхронизация автоматическая. Помните, мы добавляли в наш аккаунт поддержку экрана настроек? Хорошая практика — не заставлять пользователя совершать лишних действий. Поэтому доступ к этому экрану продублирован кнопкой в экшен баре.

Что он из себя представляет — видно слева. Технически же — это активити с одним PreferenceFragment'ом (SyncSettings.java), настройки которого берутся из res/xml/sync_prefs.xml.

Изменение параметров отслеживаем в методе onSharedPreferenceChanged (реализация OnSharedPreferenceChangeListener). Для включения периодической синхронизации существует метод ContentResolver.addPeriodicSync, для отключения, как ни странно, — ContentResolver.removePeriodicSync. Для обновления интервала синхронизации используется так же метод ContentResolver.addPeriodicSync. Потому что, как говорит документация к этому методу: «If there is already another periodic sync scheduled with the account, authority and extras then a new periodic sync won't be added, instead the frequency of the previous one will be updated.» (если синхронизация уже запланирована, extra и authority не будут добавлены в новую синхронизацию, вместо этого будет обновлен интервал предыдущей).



sync_prefs.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">

    <PreferenceCategory
        android:key="com.elegion.newsfeed.KEY_SYNC"
        android:title="@string/sync">

        <CheckBoxPreference
            android:defaultValue="false"
            android:key="com.elegion.newsfeed.KEY_AUTO_SYNC"
            android:summary="@string/auto_sync_summary"
            android:title="@string/auto_sync" />

        <ListPreference
            android:defaultValue="@string/auto_sync_interval_default"
            android:dependency="com.elegion.newsfeed.KEY_AUTO_SYNC"
            android:entries="@array/auto_sync_intervals"
            android:entryValues="@array/auto_sync_interval_values"
            android:key="com.elegion.newsfeed.KEY_AUTO_SYNC_INTERVAL"
            android:title="@string/auto_sync_interval" />

    </PreferenceCategory>

</PreferenceScreen>


SyncSettings.java
public class SyncSettings extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener {

    private static final String KEY_AUTO_SYNC = "com.elegion.newsfeed.KEY_AUTO_SYNC";

    private static final String KEY_AUTO_SYNC_INTERVAL = "com.elegion.newsfeed.KEY_AUTO_SYNC_INTERVAL";

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.sync_prefs);
        final ListPreference interval = (ListPreference) getPreferenceManager()
                .findPreference(KEY_AUTO_SYNC_INTERVAL);
        interval.setSummary(interval.getEntry());
    }

    @Override
    public void onResume() {
        super.onResume();
        getPreferenceManager().getSharedPreferences()
                .registerOnSharedPreferenceChangeListener(this);
    }

    @Override
    public void onPause() {
        getPreferenceManager().getSharedPreferences()
                .unregisterOnSharedPreferenceChangeListener(this);
        super.onPause();
    }

    @Override
    public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
        if (TextUtils.equals(KEY_AUTO_SYNC, key)) {
            if (prefs.getBoolean(key, false)) {
                final long interval = Long.parseLong(prefs.getString(
                        KEY_AUTO_SYNC_INTERVAL,
                        getString(R.string.auto_sync_interval_default)
                ));
                ContentResolver.addPeriodicSync(AppDelegate.sAccount, AppDelegate.AUTHORITY, Bundle.EMPTY, interval);
            } else {
                ContentResolver.removePeriodicSync(AppDelegate.sAccount, AppDelegate.AUTHORITY, new Bundle());
            }
        } else if (TextUtils.equals(KEY_AUTO_SYNC_INTERVAL, key)) {
            final ListPreference interval = (ListPreference) getPreferenceManager().findPreference(key);
            interval.setSummary(interval.getEntry());
            ContentResolver.addPeriodicSync(
                    AppDelegate.sAccount, AppDelegate.AUTHORITY,
                    Bundle.EMPTY, Long.parseLong(interval.getValue())
            );
        }
    }

}


Собрав все это в кучу, мы получаем рабочее приложение, со всеми плюшками, которые предоставляет нам система Android. За кадром осталось много всего вкусного, но и этого достаточно, чтобы понять мощь SyncAdapter Framework'а.

Вот, вроде бы и все. Полные исходники проекта можно взять тут. Благодарю за внимание. Конструктивная критика приветствуется.

Синхронизация в Android приложениях. Часть первая.
  • +32
  • 37k
  • 2
e-Legion
90.67
Лидер мобильной разработки в России
Share post

Similar posts

Comments 2

    +1
    Подскажите, пожалуйста, как автоматическое тестирование реализовать для синхронизаций?
      +1
      Сам задавался этим вопросом. Смотря что именно нужно покрыть тестами. Все статусы синхронизации пишутся в SyncResult примерно так:
      ++syncResult.stats.numParseExceptions;
      syncResult.stats.numUpdates += provider.update();
      syncResult.stats.numDeletes += provider.delete();
      

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

    Only users with full accounts can post comments. Log in, please.