Правильная работа с БД в Android

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

    Способы работы с БД

    Существует три способа работы с данными в БД, которые сразу бросаются на ум:
    1) Вы создаете пустую структуру базы данных. Пользователь работает с приложением(создает заметки, удаляет их) и база данных наполняется. Примером может служить приложение NotePad в демо-примерах developer.android.com или на вашем дроид-девайсе.
    2) Вы уже имеете готовую БД, наполненную данными, которую нужно распространять с приложением, либо парсите данные из файла в assets.
    3) Получать данные из сети, по мере необходимости.
    Если есть какой-то еще один или два способа, то с радостью дополню данный список с вашей помощью.
    Все основные туториалы расчитаны как раз на первый случай. Вы пишите запрос на создание структуры БД и выполняете этот запрос в методе onCreate() класса SQLiteOpenHelper, например так:
    class MyDBHelper extends SQLiteOpenHelper {
        final String CREATE_TABLE = "CREATE TABLE myTable(...)";
        final String DB_NAME = "mySuperDB.db";
        Context mContext;
    	
        public MyDBHelper(Context context, int dbVer){
    	super(context, DB_NAME, null, dbVer);
    	mContext = context;
        }
    	
         @Override
         public void onCreate(SQLiteDatabase db) {
    	 db.execSQL(CREATE_TABLE);
         }
    	
         @Override
         public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    	 //проверяете какая версия сейчас и делаете апдейт
    	 db.execSQL("DROP TABLE IF EXISTS tableName");
    	 onCreate(db);
         }
    	...какой-то код
    }
    

    Примерно так. Более полный вариант класса и других составляющих можно посмотреть по ссылке внизу статьи.
    Дополнительно можно переопределить методы onOpen(), getReadableDatabase()/getWritableDatаbase(), но обычно хватает того, что выше и методов выборки данных.
    Далее, экземпляр этого класса создаем в нашем приложении при его запуске и выполняем запросы, то бишь проблемная часть пройдена. Почему она проблемная? Потому что, когда пользователь качает приложения с маркета, то не задумывается о вашей базе данных и может произойти что угодно. Скажем сеть пропала или процесс другой запустился, или вы написали уязвимый к ошибкам код.

    Кстати, есть еще один момент, на который стоит обратить внимание. Переменную экземпляра нашего класса можно создать и хранить в объекте Application и обращаться по мере необходимости, но нужно не забывать вызывать метод close(), так как постоянный коннект к базе — это тяжелый ресурс. Кроме того могут быть коллизии при работе с базой из нескольких потоков.
    Но есть и другой способ, например, создавать наш объект по мере необходимости обращения к БД. Думаю это вопрос предпочтения, но который также необходимо обсудить.

    А теперь самое главное. Что, если нам понадобилось использовать уже сушествующую БД с данными в приложении?
    Немного погуглив, Вы сразу наткнетесь на такую «замечательную статью» — www.reigndesign.com/blog/using-your-own-sqlite-database-in-android-applications в которой, как покажется, есть нужная панацея. Но не тут то было. В ней еще и ошибок несколько.

    Вот они:
    1) В методе createDataBase() строка:
    SQLiteDatabase dbRead = getReadableDatabase();
    и далее код… содержит crash приложения на НТС Desire, потому что получаем БД для чтения(она создается), но не закрывается.
    Добавляем строкой ниже dbRead.close() и фикс готов, но момент спорный.
    Вот что говорит дока на тему метода getReadableDatabase():
    Create and/or open a database. This will be the same object returned by getWritableDatabase() unless some problem, such as a full disk, requires the database to be opened read-only. In that case, a read-only database object will be returned. If the problem is fixed, a future call to getWritableDatabase() may succeed, in which case the read-only database object will be closed and the read/write object will be returned in the future.
    Like getWritableDatabase(), this method may take a long time to return, so you should not call it from the application main thread, including from ContentProvider.onCreate().

    И так. Данный метод не стоит вызывать в главном потоке приложения. В остальном все понятно.
    2) Ошибка: No such table android_metadata. Автор поста выкрутился, создав данную таблицу заранее в БД. Не знаю на сколько это правильный способ, но данная таблица создается в каждой sqlite-бд системой и содержит текущую локаль.
    3) Ошибка: Unable to open database file. Здесь много мнений, разных мнений, которые Вы можете прочесть по ссылкам ниже.

    stackoverflow.com/questions/3563728/random-exception-android-database-sqlite-sqliteexception-unable-to-open-database
    groups.google.com/group/android-developers/browse_thread/thread/a0959c4059359d6f
    code.google.com/p/android/issues/detail?id=949
    stackoverflow.com/questions/4937934/unable-to-open-database-file-on-device-htc-desire
    androidblogger.blogspot.com/2011/02/instable-android-and-unable-to-open.html

    Возможно, что проблемы связаны с тем, что один поток блокирует БД и второй не может к ней обратиться, возможно проблема в правах доступа к приложению(было замечено, что чаще проблемы с БД проявляются на телефонах марки НТС именно на тех моделях, которые нельзя рутануть, хотя не только на них, например на планшетах Асер), но как бы то ни было проблемы эти есть.
    Я склоняюсь к варианту, что проблема в потоках, не зря ведь нам не рекомендуют вызывать методы создания базы в главном потоке.

    Возможно выходом из этого будет следующее решение(рассматривается вариант №2). Используя первый вариант работы с базой, наполнить ее данными после создания, например:
            @Override
    	public void onCreate(SQLiteDatabase db) {
    	    db.execSQL(CREATE_TABLE);
    	    fillData(db);
    	}
    	
    	private void fillData(SQLiteDatabase db) {
    	    //разбираем файл data.xml лежащий например в assets 
                //и вставляем данные в базу	
                //либо читаем лежащие там же sql-скрипты и выполняем с помощью все того же db.execSQL() или аналогично	
    	}
    

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

    Заключение

    Вцелом, данный пост показывает(касательно способа №2) как делать не надо, но и также содержит пару любопытных мыслей.
    Метод getReadableDatabase() можно переопределить например так:
            @Override
    	public synchronized SQLiteDatabase getReadableDatabase() {
    		//Log.d(Constants.DEBUG_TAG, "getReadableDatabase() called");
    		SQLiteDatabase db;
    		try {
    			db = super.getReadableDatabase();
    		} 
    		catch (SQLiteException e) {
    			Log.d(Constants.DEBUG_TAG, e.getMessage());
    			File dbFile = myContext.getDatabasePath(DB_NAME);
    			Log.d(Constants.DEBUG_TAG,"db path="+dbFile.getAbsolutePath());
    			//db = SQLiteDatabase.openDatabase(/*DB_PATH + DB_NAME*/ dbFile.getAbsolutePath(), null, SQLiteDatabase.NO_LOCALIZED_COLLATORS);
    			db = SQLiteDatabase.openOrCreateDatabase(dbFile.getAbsolutePath(), null);
    		}
    		return db;
    	}
    

    Отличный туториал по данной теме тут — www.vogella.de/articles/AndroidSQLite/article.html

    Кстати: следуя практике самой платформы, поле первичного ключа стоит называть "_id".

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

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

    public class DBHelper extends SQLiteOpenHelper {
        
    	final static int DB_VER = 1;
    	final static String DB_NAME = "todo.db";
    	final String TABLE_NAME = "todo";
    	final String CREATE_TABLE = "CREATE TABLE "+TABLE_NAME+
    	                            "( _id INTEGER PRIMARY KEY , "+
    	                            " todo TEXT)";
    	final String DROP_TABLE = "DROP TABLE IF EXISTS "+TABLE_NAME;
    	final String DATA_FILE_NAME = "data.txt";
    	
    	Context mContext;
    	
    	public DBHelper(Context context) {
    		super(context, DB_NAME, null, DB_VER);
    		Log.d(Constants.DEBUG_TAG,"constructor called");
    		mContext = context;
    	}
    	
    	@Override
            public void onCreate(SQLiteDatabase db) {
    		Log.d(Constants.DEBUG_TAG,"onCreate() called");
    		db.execSQL(CREATE_TABLE);
        	        fillData(db);
            }
        
            @Override
            public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        	       db.execSQL(DROP_TABLE);
        	       onCreate(db);
            }
        
            private ArrayList<String> getData() {
        	      InputStream stream = null;
        	      ArrayList<String> list = new ArrayList<String>();
        	      try {
                        stream = mContext.getAssets().open(DATA_FILE_NAME);	
        	      }
        	      catch (IOException e) {
    		   Log.d(Constants.DEBUG_TAG,e.getMessage());
    	      }
        	
        	      DataInputStream dataStream = new DataInputStream(stream);
        	      String data = "";
        	      try {
        	             while( (data=dataStream.readLine()) != null ) {
        		             list.add(data);
        	             }
        	      }
        	      catch (IOException e) {
    		    e.printStackTrace();	
    	      }    
        	
        	      return list;
            }
        
            private void fillData(SQLiteDatabase db){
        	      ArrayList<String> data = getData();
        	      for(String dt:data) Log.d(Constants.DEBUG_TAG,"item="+dt);
        	
        	      if( db != null ){
        		    ContentValues values;
        		
        		    for(String dat:data){
        			values = new ContentValues();
        			values.put("todo", dat);
        			db.insert(TABLE_NAME, null, values);
        		    }
        	      }
        	      else {
        		    Log.d(Constants.DEBUG_TAG,"db null");
        	      }
            }
    }
    


    Файлик data.txt лежит в assets такой:
    Zametka #1
    Zametka #2
    Zametka #3
    Zametka #4

    И класс приложения:
    public class TODOApplication extends Application {
    	private DBHelper mDbHelper;
    	
    	@Override
    	public void onCreate(){
    		super.onCreate();
    		
    		mDbHelper = new DBHelper(getApplicationContext());
    		mDbHelper.getWritableDatabase();
    	}
    	
    	@Override
    	public void onLowMemory() {
    		super.onLowMemory();
    		mDbHelper.close();
    	}
    	
    	@Override
    	public void onTerminate(){
    		super.onTerminate();
    		mDbHelper.close();
    	}
    }
    

    Отмечу, что данный класс используется только для демонстрации и проверки того, что произойдет при вызове методов getReadableDatabase()/getWritableDatabase() и создании базы. В реальных проектах код нужно адаптировать.
    Кроме того в базе появилась табличка android_metadata(без моего участия), поэтому указанная выше ошибка решена.
    Надеюсь кому-то пригодится.

    Любопытные дополнения №1(от хабраюзера Kalobok)

    Я пока совсем отказался от SQLiteOpenHelper — оказалось, что в нем невозможно создать базу на SD карте. Теоретически, то, что он возвращает, должно использоваться как путь к базе. На практике SQLiteOpenHelper иногда использует его, а иногда обходит стороной — зависит от того, открываем ли мы базу на чтение или запись, существует ли она уже и т.д. SQLiteOpenHelper.getWritableDatabase вызывает Context.openOrCreateDatabase, который, в свою очередь, использует Context.validateFilePath, чтобы получить полный путь к файлу. Там используется приватный метод Context.getDatabasesDir, переопределить который нельзя — приехали. База будет создана в стандартной директории.

    А вот если мы вызвали SQLiteOpenHelper.getReadableDatabase, сначала он попытается вызвать все тот же getWritableDatabase. Но если это не получится, то он пойдет в обход Context.openOrCreateDatabase — сам вызовет Context.getDatabasePath (вот его-то мы можем подправить) и сам откроет нужную базу. Этот способ нас бы устроил, если бы он использовался всегда. Но увы. :(
    Вобщем, задумка с этим хелпером была хорошая, а реализация — левой ногой с бодуна.
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 31

      +3
      Спасибо, интересно. Перечитаю повнимательнее на досуге. Но я пока совсем отказался от SQLiteOpenHelper — оказалось, что в нем невозможно создать базу на SD карте. Подробностей сейчас не вспомню, но это было как-то связано с методом getDatabasePath. Теоретически, то, что он возвращает, должно использоваться как путь к базе. На практике SQLiteOpenHelper иногда использует его, а иногда обходит стороной — зависит от того, открываем ли мы базу на чтение или запись, существует ли она уже и т.д. Помню, что смотрел исходники и там было ясно видно, что путь используется криво. Если интересно, могу потом посмотреть еще раз и рассказать.
        +2
        Кажется, кое-что нашел. SQLiteOpenHelper.getWritableDatabase вызывает Context.openOrCreateDatabase, который, в свою очередь, использует Context.validateFilePath, чтобы получить полный путь к файлу. Там используется приватный метод Context.getDatabasesDir, переопределить который нельзя — приехали. База будет создана в стандартной директории.

        А вот если мы вызвали SQLiteOpenHelper.getReadableDatabase, сначала он попытается вызвать все тот же getWritableDatabase. Но если это не получится, то он пойдет в обход Context.openOrCreateDatabase — сам вызовет Context.getDatabasePath (вот его-то мы можем подправить) и сам откроет нужную базу. Этот способ нас бы устроил, если бы он использовался всегда. Но увы. :(

        Вобщем, задумка с этим хелпером была хорошая, а реализация — левой ногой с бодуна.
          0
          Со всем абсолютно согласен. И реализация действительно кривая, столько приколов встречается в такой простой вещи, что просто диву даюсь. А ведь почти каждое приложение(среднее) должно хранить и обрабатывать данные из БД.

          Вам, 2+ и с вашего позволения добавлю сказанное вами в пост:)
            0
            Да пожалуйста. :)
              0
              На счет мысли отказаться от SQLiteOpenHelper очень любопытно. Надо подумать:)

              Оформите целостное решение, добавлю в статью. Будет полезно.
                0
                А что там такого? Конечно, кое-какие удобства теряются. Но в целом никаких проблем не вижу. Понадобилась база — открыли (там, где нужно, а не где получится). Не нужна — закрыли. Ну да, положил все это в специальный классик, у которого есть методы-аналоги getWritable/ReadableDatabase. Апгрейдить базу мне не нужно, так что вполне могу обойтись без умного хелпера.
                  0
                  Ну мне сложно судить, я не пробовал, но по идеи мы будем просто сами пути нужные для базы передавать куда нужно и все.

                  Я и не говорю, что это сложно.
            +2
            Ребят, может вопрос и не в тему, но всё же…
            Я далёк от разработки под Android, но как я понял SQLiteOpenHelper это часть API Android SDK. А это открытый проект, раз уж вы полезли в код, то почему бы хотя бы не создать тикет в багтрекере дабы в следующих версиях не натыкаться на такую засаду? Этот же класс доступен аж с API Level 1.

              0
              А все указанные мною ошибки уже содержат issues на гугл коде, ссылка на один в топике есть.
              Остальное нам не под силу:)
              Но мысль правильная, спасибо!
          +2
          Тоже были мысли написать подобную статью. Спасибо что опередили. Один очень важный момент с которым мне приходилось столкнуться похож на ваше описание ошибки №1. Сейчас уже не вспомню детали но суть в том чтобы никогда и нигде не хранить ссылку на базу данных SQLiteDatabase dbRead = getReadableDatabase(), а работать на прямую с методами getReadableDatabase() и getWritebleDatabase(), а для закрытия базы использовать метод SQLiteOpenHelper.close().
            +1
            Пожалуйста, всегда рад помочь людям.

            Кроме того это накипело уже. Надоело столько лагов хватать и хочется найти уже решение, которое обходит многие(пускай не все) эти ошибки.

            +1:)
              0
              А я правильно понял мысль? Ты предлагаешь постоянно вызывать методы помощника getReadableDatabase() и getWritebleDatabase(), получая ссылку и тут же делать операци к базе?

              То бишь, например: dbHelper.getWritebleDatabase().insertData(list); dbHelper.getWritebleDatabase().getData(id);
                0
                да, только для операций с базой я бы предпочел обертку в виде метода для dbHelper, либо класса обертки над dbHelper.
                  0
                  Я понял. Такой принцип используется в туториале(ссылка в топике).
              0
              Отличное начинание, спасибо!
                0
                Хорошая статья, спасибо. вообще нельзя запускать ресурсоемкие вещи на ui потоке, т.к. чревато force close.
                По поводу импорта собственной БД из sql в ресурсах — вполне нормальная практика, у вас есть дамп базы в виде sql скрипта, запуск и выполнение которого будет легче и быстрее парсинга того же ресурсного xml
                  0
                  Да, но если данных много то либо понадобится тулза для того, чтобы сгенерировать такой sql-скрипт и не факт, что найдется(я пока не искал, но тем инструментом, что я пользуюсь такое сделать нельзя), либо писать самому парсер.

                  А вообще, я с Вами согласен, xml подольше будет, правда тоже еще зависит от используемого способа парсинга.
                    0
                    Да ну? а разные там SQLyog которые коннектятся к примеру к mysql бд, и её можно оттуда экспортить сразу в скрипт. причем импорт/экспорт потом нормально работают. я пока еще не пользовался такой тулзой которая не умеет делать экспорт базы =)

                    Если xml маленький то пожалуйста, но вы представьте xml-ину в 2-3 мега?
                      0
                      Не будем разводить спор.
                      SAX по идеи быстр, хоть я и не пробовал на больших объемах. А если уж данных так много, то лучше подкачивать их походу из сети, пользователи не любят большие apk-и.
                      У нас sqlite база, а хорошие тулзы всегда платные. Вот они справятся без проблем.
                        0
                        =)
                        я и не спорю, просто меня в свое время устраивал xml ипорт до определенного момента)
                        кстати о больших данных, ведь апк при сборке ужимается. а xml даже в 30 мег обычно жмется в килобайт 200… другое дело когда развернется приложение на девайсе…

                        а вообще если данных много, то да, имеет смысл подгружать их с сервера + фрагментировать под пользователя, и остальные данные грузить по мере надобности
                          0
                          смотря куда положите xml, в assets ничего не будет сжиматься, только в res(кроме подпапки raw).

                          Про подгрузку я тоже самое говорил:)
                  0
                  Добавлю небольшой код, позволяет накатывать инкрементальные изменения в БД на любую версию программы:

                  abstract public class DBHelper extends SQLiteOpenHelper {

                  private SQLiteDatabase database = null;
                  private int version = -1;

                  public DBHelper(Context context, String path, int version) {
                  super(context, path, null, version);
                  this.version = version;
                  }

                  public boolean open() {
                  try {
                  SQLiteDatabase db = this.getWritableDatabase();
                  if (db != null) {
                  return true;
                  }
                  } catch (Exception e) {
                  Log.e(TAG, "Error opening DB", e);
                  }
                  return false;
                  }

                  @Override
                  public void onCreate(SQLiteDatabase db) {
                  onUpgrade(db, 0, version);
                  }

                  abstract public void migrate(SQLiteDatabase db, int version);

                  @Override
                  public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
                  for (int i = oldVersion+1; i <= newVersion; i++) {
                  migrate(db, i);
                  }
                  }

                  @Override
                  public void onOpen(SQLiteDatabase db) {
                  super.onOpen(db);
                  this.database = db;
                  }

                  public SQLiteDatabase getDatabase() {
                  return database;
                  }
                  }



                  реализация migrate — обычный switch c DDL коммандами.
                  При изменении БД — увеличиваем версию в конструкторе и добавляем в migrate новый case в switch. Класс заботится, чтобы все DDL комманды были последовательно вызваны
                    0
                    Ну вот о чем я и говорил. См мой коммент выше. Отдавая прямую ссылку на БД вы рискуете стать жертвой непредсказуемых падений приложения.
                    0
                    Мои 5 копеек =)
                    А это не смотрели?
                      0
                      Нет, но обязательно посмотрю. Из сэмплов у меня есть вот такая ссылочка — code.google.com/p/krvarma-android-samples/

                      Еще было бы лучше, если бы Вы уточнили, что имеете ввиду.
                        0
                        О, пасиба за линк, довольно много чего вкусного.

                        Я имел ввиду один из способов работы с БД, а именно использование сторонней библиотеки в которой уже почти все есть =). Конечно, лучше наверно сразу использовать какой-нибудь ormlite, но все же.
                          0
                          Да, я думал об этом. Впринципе вариант, но либа это все равно обертка. Просто удобная. Важнее знать как правильно работать с тем, что есть.

                          А так да, можно.
                      0
                      Реквестую статью по правильной работе с БД на SD-карте(картах, ибо есть китайские девайсы с двумя картами).

                      Хотелось бы увидеть описание правильного создания БД, причем для разных API Level'ов — не секрет что разрабочики приложений часто плодят в корне SD-карты кучу папок, по папке на приложение, хотя для таких целей есть общий путь типа /корень-SD-карты/Android/data/имя-приложения/(databases|cache|etc).

                      Как получить этот путь для разных API — вот интересный вопрос. Если для API8 есть getExternalFilesDir, то для API1 есть только getExternalStorageDirectory которая возвращает только путь до корня карты. И, если писать приложение для Андроид 1.6, то приходится изворачиваться.

                      Еще бы хотелось чтобы были освещены моменты для случаев, когда карта недоступна (девайс подключен по USB, пользователь размонтировал карту и т.п.)
                        0
                        Я думаю, что не имеет смысл поддерживать такой низкий API Level. Да, желательно, но по мне так это уже перебор. Посмотрите статистику андроид-девайсов на developer.android.com, которая обновляется достаточно часто. Когда я смотрел больше половины всех девайсов работали на Андроид 2.2, большой % был у 2.1. Подозреваю, что сейчас % сместится в сторону 2.3.

                        А так, да, я уже в своих комментариях как-то обращал на это внимание, на то что Вы указали(к примеру Galaxy тоже по суте 2 карты содержит) и как будет мной сформирован более целостный подход по работе с БД, то обновится данная статья, либо напишу новую.

                        Следите за обновлениями. И спасибо за коммент, все учту:)
                          0
                          Да, если верить developer.android.com/resources/dashboard/platform-versions.html сейчас больше всего устройств с Андроид 2.2 (API8) — почти 56%

                          Устройств с версией 1.6 (API4) всего 2%. Это устройства хозяева которых:
                          — либо не знают как прошится на новую версию (или боятся это делать из риска сломать устройство по неосторожности)
                          — либо не могут, т.к. уже прошились с 1.5 до 1.6 (а более новой версии прошивки производитель устройства не выпустил и уже не выпустит).
                          Можно, конечно, на них забить при разработке приложений.

                          Устройства с 2.1 где-то посередине — чуть больше 15%. Это API7.
                          Забивать на этих пользователей уже совсем не хочется. Их довольно много. И большинство из них тоже «застряло», уже обновившись с 1.6 до 2.1 (и производители многих таких устройств тоже не выпустят обновлений даже до 2.2). И менять свои устройства на новые они будут еще года 2-3, а то и дольше.

                          А в API7 как я писал чуть выше, нет функции getExternalFilesDir.
                          Что делать?

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