Главная iPhone Mac OS X Форум О себе

SQL для самых маленьких

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

Введение в SQLite и создание базы данных

SQLite — это небольшая C библиотека, содержащая встраиваемый, не требующий настройки движок SQL базы данных. SQLite не используем схему клиент-сервер, как например MySQL, не представляет собой отдельный процесс, это просто библиотека, которая встраивается в ваше приложение, а для хранения каждой базы используется один-единственный файл. Такой подход позволяет сократить расход ресурсов, увеличить производительность и упростить программу.

Но минимализму, простоте и скорости работы были принесены в жертву ограниченность поддержки стандарта SQL, отсутствие сильного параллелизма (с базой данных могут работать одновременно несколько процессов или потоков, но не столь эффективно как это могут себе позволить более мощные СУБД) и ограниченная функциональность. Но это никак не повлияло на широкое использование SQLite в программах (Firefox, Pidgin, Amarok, F-Spot и пр.) и системах (Mac OS X версии 10.4 и старше, Skype, последние версии Symbian телефонов, и, конечно, iPhone и iPod Touch).

Типов данных в SQLite всего ничего:

  • NULL — пустое значение
  • INTEGER — целое число, занимающее от 1 до 8 байт в зависимости от значения
  • REAL — число с плавающей запятой, занимает 8 байт
  • TEXT — текстовая строка в кодировке базы данных (UTF-8, UTF-16BE or UTF-16-LE)
  • BLOB — двоичные данные

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

Откройте терминал и наберите следующую команду:


sqlite3 records.sql

Мы попадем в интерактивный режим взаимодействия с SQLite, чем-то похожий на mysqladmin: можно создавать новые базы, изменять список таблиц, редактировать записи, делать дампы и прочие полезные вещи. Более подробно о командах в интерактивном режиме можно прочесть на странице http://www.sqlite.org/sqlite.html.

В качестве параметра вызова утилиты мы указали имя базы данных, которая будет автоматически создана в текущем каталоге — в нашем случае в домашней папке пользователя. Осталось создать таблицу в базе и выйти из интерактивного режима. Введите следующую строку в консоли, а после нажмите Ctrl+D или наберите команду .quit.


CREATE TABLE records('id' INTEGER PRIMARY KEY, 'title' TEXT, 'txt' TEXT);

Добавьте файл records.sql из домашней папки в состав проекта txtEdit как файл ресурса.

Добавление ресурса в проект XCode

Добавление ресурса в проект XCode

Для общения с SQLite нам понадобится та самая библиотека, о которой шла речь выше. Ее, как и все внешние библиотеки, следует добавить в раздел Frameworks. Файл libsqlite3.0.dylib лежит в нестандартном пути - /Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS2.0.sdk/usr/lib/libsqlite3.0.dylib.

Инициализация работы с базой данных

Мы сделали все что от нас требуется, чтобы приступить к работе с базой. Как всегда, есть одно “но”. Как я писал в позапрошлом посте, все данные приложения складируются в так называемом bundle, представляющим из себя отдельный каталог с исполняемым файлом, со всеми ресурсами и данными приложения, запись в который запрещена. Таким образом, база, которую мы только что добавили в проект, после компиляции становится частью bundle и доступна только для чтения. Чтобы мы могли пользоваться ею полноценно, нам необходимо скопировать файл records.sql в папку Documents при старте программы. Для этих целей нам понадобится добавить в класс txtEditAppDelegate новый метод:


-(void)createEditableCopyOfDatabaseIfNeeded {
    BOOL success;
    NSError *error;
   
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths objectAtIndex:0];   
    NSString *writableDBPath = [documentsDirectory stringByAppendingPathComponent:@"records.sql"];
   
    NSFileManager *fileManager = [NSFileManager defaultManager];
    success = [fileManager fileExistsAtPath:writableDBPath];
    if (success) return;
   
    NSString *defaultDBPath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"records.sql"];
    success = [fileManager copyItemAtPath:defaultDBPath toPath:writableDBPath error:&error];
    if (!success) {
        NSAssert1(NO, @"Failed to create writable database file with message '%@'.", [error localizedDescription]);
    }
}

Перво-наперво мы должны проверить существование копии базы в каталоге Documents, чтобы случайно ее не затереть. Первые строчки кода нам знакомы по предыдущим постам: NSSearchPathForDirectoriesInDomains возвращает массив путей к папкам Documents текущего пользователя (точнее один путь), в строку documentsDirectory мы получаем полный путь к этой папке, а во writableDBPath формируем путь к файлу.

NSFileManager представляет класс по работе с файлами на диске. Метод fileExistsAtPath возвращает YES или NO в зависимости от результата проверки существования файла на указанном пути. В первом случае мы просто выходим из метода, иначе — делаем копию records.sql из bundle директории.

Для этих целей нам требуется получить путь к папке bundle, точнее к папке с ресурсами, в чем нам поможет класс NSBundle. Метод mainBundle возвращает объект класса NSBundle, который связан с текущим приложением, а resourcePath уже возвращает строку полного пути к папке ресурсов, к котором мы добавляем имя файла. Следующей строкой мы копируем с помощью объекта fileManager файл records.sql из папки приложения в папку Documents. В случае неудачной попытки записи дальнейшая работа программы становится бессмысленной, поэтому генерируем исключение командой NSAssert1 с сопутствующим сообщением об ошибке.

Прежде чем приступить к работе с базой данных, определимся со способом представлением этих данных в нашем приложении. Предлагаю для этих целей использовать отдельный класс, назовем его Record, каждый экземпляр которого будет не только хранить данные, но и производить чтение и запись в базу. Коллекция этих объектов будет составлять массив записей, который будет храниться в классе txtEditAppDelegate и инициализоваться при старте приложения. При этом из базы будет производиться чтение только идентификатора записи и ее заголовка — чтение полного текста будет происходить в момент открытия окна редактирования. Чтобы не вызвать переполнение памяти, по окончании работы с документом его текст будет сразу сбрасываться в базу, а связанная с ним переменная - освобождаться.

Откройте txtEditAppDelegate.h и приведите его к следующему виду:


#import <UIKit/UIKit.h>
#import <sqlite3.h>

@class txtEditViewController;

@interface txtEditAppDelegate : NSObject <UIApplicationDelegate> {
    IBOutlet UIWindow *window;
    IBOutlet UINavigationController *navController;
   
    sqlite3 *database;
    NSMutableArray *records;
}

@property (nonatomic, retain) UIWindow *window;
@property (nonatomic, retain) UINavigationController *navController;
@property (nonatomic, retain) NSMutableArray *records;

-(void)createEditableCopyOfDatabaseIfNeeded;
-(void)initializeDatabase;

@end

В каждом классе, где мы будем использовать библиотеку sqlite, нам необходимо подключать ее в заголовочном файле. Именно в заголовочном, а не в .m-файле, в котором импортируются заголовки только написанных нами классов. Именно по этой причине строчка #import <sqlite3.h> располагается в txtEditAppDelegate.h, а в файл txtEditAppDelegate.m мы добавим другую строчку, описывающую импортирование еще не созданного нами класса:


#import "Record.h"

В переменной database будет храниться дескриптор базы, полученный при открытии, а в массиве records — записи из нее. Мы объявили эту переменную как NSMutableArray, так как мы будем его изменять по ходу работы программы.

Метод initializeDatabase будет отвечать за открытие базы данных и инициализацию начального списка записей. Так как SQLite представляет собой обычную Си библиотеку, а Objective-C является надстройкой над стандартным Си, то мы можем использовать код, написанный на Си, вперемешку с Objective-C.


-(void)initializeDatabase {
   // Создание массива записей
   NSMutableArray *recordsArray = [[NSMutableArray alloc] init];
   self.records = recordsArray;
   [recordsArray release];
   
   // Получаем путь к базе данных
   NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
   NSString *documentsDirectory = [paths objectAtIndex:0];
   NSString *path = [documentsDirectory stringByAppendingPathComponent:@"records.sql"];
   
   // Открываем базу данных
   if (sqlite3_open([path UTF8String], &database) == SQLITE_OK) {
       // Запрашиваем список идентификаторов записей
       const char *sql = "SELECT id FROM records ORDER BY id ASC";
       sqlite3_stmt *statement;
       
       // Компилируем запрос в байткод перед отправкой в базу данных
       if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK) {
           while (sqlite3_step(statement) == SQLITE_ROW) {
               int primaryKey = sqlite3_column_int(statement, 0);
               
               Record *record = [[Record alloc] initWithIdentifier:primaryKey database:database];
               [records addObject:record];
               [record release];
           }
       }
       
       sqlite3_finalize(statement);
   } else {
       // Даже в случае ошибки открытия базы закрываем ее для корректного освобождения памяти
       sqlite3_close(database);
       NSAssert1(NO, @"Failed to open database with message '%s'.", sqlite3_errmsg(database));
   }
}

Вся библиотека sqlite работает исключительно с UTF-строками как в параметрах вызовов методов, так и в базе данных, запомните это. Именно по этой причине при открытии базы мы преобразуем нашу строчку path в корректную для стандартного Си UTF-8 строку с нулевым байтом на конце. В случае успеха в переменную database возвращается дескриптор открытой базы данных. В противном случае — освобождаем ресурсы и вызываем исключение.

Чтобы каждый раз при использовании строковых переменных не производить перевод в формат Си, можно использовать объявление в стиле Си, как это сделано для переменной sql. Тип sqlite3_stmt описывает переменную, которая будет хранить скомпилированный в байткод SQL-запрос, понятный для SQLite. Чуть позже я покажу, что данную переменную можно использовать многократно.

sqlite3_prepare_v2 компилирует строку sql в байт-код, возвращаемый в переменную statement. Третий параметр вызова функции указывает на количество байт (не символов!) в строке запроса или, в случае отрицательного значения, на использование строки до первого завершающего символа — нулевого байта. В последний параметр возвращается значение, указывающее на положение в строке sql следующего запроса. Дело в том, что за один присест функция компилирует только один запрос из строки. Для нас это не критично, мы себе такого не позволяем.

Функция sqlite3_step может быть вызвана один или несколько раз для выполнения запроса и получения результата. Возвращаемое значение может быть представлено несколькими вариантами:

  • SQLITE_OK — операция была выполнена успешно
  • SQLITE_BUSY — файл базы данных заблокирован другим процессом. Повторный вызов sqlite3_step повторит попытку обращения к базе
  • SQLITE_DONE — запрос был успешно выполнен. Такой ответ отправляется при операциях над записями в базе, не возвращающих значение: удаление, добавление, обновление записей
  • SQLITE_ERROR — произошла ошибка. Строка, содержащая описание ошибки, может быть получена при помощи метода sqlite3_errmsg
  • SQLITE_MISUSE — неправильный вызов. Например, повторный вызов с тем же самым statement после получения ответа SQLITE_DONE или с некорректным значением statement
  • SQLITE_ROW — возвращается при запросах SELECT. Для получения каждой следующей строки в результатах запроса следует повторно вызвать sqlite3_step с прежними параметрами.

Полный список возвращаемых кодов может быть найден на странице http://sqlite.org/c3ref/c_abort.html

Для получения значений из результатов запроса в библиотеке sqlite существуют несколько различных методов, вызываемых в зависимости от типа возвращаемых данных: sqlite3_column_int, sqlite3_column_double, sqlite3_column_text и т.д (с полным списком методов можно ознакомиться на странице http://www.sqlite.org/c3ref/funclist.html). Первый параметр указывает на statement, второй — номер колонки из строки результатов, для которой возвращается ее значение. А дальше все просто — мы инициализируем объект Record с полученным primaryKey и заносим его в список записей records. Процедуру инициализации, как и весь класс Record, мы напишем чуть ниже.

Добавим вызовы двух описанных выше методов в applicationDidFinishLaunching.


— (void)applicationDidFinishLaunching:(UIApplication *)application {
    [self createEditableCopyOfDatabaseIfNeeded];
    [self initializeDatabase];
   
    [window addSubview:navController.view];
   
    // Override point for customization after app launch   
    [window makeKeyAndVisible];
}

И строчку в dealloc:


[records release];

Перед закрытием приложения мы должны закрыть базу данных. Этот процесс будет выглядеть следующим образом:


-(void)applicationWillTerminate:(UIApplication *)application {
  if (sqlite3_close(database) != SQLITE_OK) {
     NSAssert1(0, @"Error: failed to close database with message '%s'.", sqlite3_errmsg(database));
  }
}

Создание класса Record

Претворение намеченного плана в жизнь мы начнем с создания нового класса. В XCode вызовите File -> New File, раздел Cocoa Touch Classes -> NSObject subclass, присвойте новому классу имя Record и добавьте в состав проекта.

В заголовочном файле нам потребуется определить несколько переменных и подключить Foundation и sqlite3.


#import <Foundation/Foundation.h>
#import <sqlite3.h>

@interface Record : NSObject {
    sqlite3 *database;
   
    NSInteger primaryKey;
    NSString *title;
    NSString *txt;
}

@property (assign, nonatomic, readonly) NSInteger primaryKey;
@property (copy, nonatomic) NSString *title;
@property (copy, nonatomic) NSString *txt;

@end

Свойства определены несколько необычно. Мы используем простое присвоения (assign) для set-метода свойства, так как primaryKey не объект, а обычное число. Эта переменная будет доступна только для чтения (readonly), чтобы не вызвать проблем при синхронизации с базой — для каждой записи данный идентификатор является уникальным.

У оставшихся свойств get-методы копируют значение присваиваемой переменной (copy). Это сделано на случай, если в программе будут использоваться изменяемые переменные (NSMutableString) и операция retain вызовет использование переменной, которая по ходу программы может несколько раз поменять свое значение, что для нас не допустимо.

Вообще, как я прочел, рекомендуется для “переменных значений” (NSString, NSArray, NSDate…) использовать копирование для get-методов, а для “переменных классов” (NSObject, UIView, UILabel, любой класс вашего приложения…) — операцию retain. Насколько это корректно и правильно могут подсказать специалисты в комментариях к статьи и поправить меня, если не прав.

Напишем недостающий метод initWithIdentifier:database: в Record.m.


-(id)initWithIdentifier:(NSInteger)idKey database:(sqlite3 *)db {
    if (self = [super init]) {
        database = db;
        primaryKey = idKey;
       
        // Подготавливаем запрос перед отправкой в базу данных
        const char *sql = "SELECT title FROM records WHERE id=?";
        sqlite3_stmt *init_statement;
        if (sqlite3_prepare_v2(database, sql, -1, &init_statement, NULL) != SQLITE_OK) {
            NSAssert1(NO, @"Error: failed to prepare statement with message '%s'.", sqlite3_errmsg(database));
        }
       
        // Подставляем значение в запрос
        sqlite3_bind_int(init_statement, 1, self.primaryKey);
        
        // Получаем результаты выборки
        if (sqlite3_step(init_statement) == SQLITE_ROW) {
            self.title = [NSString stringWithUTF8String:(char *)sqlite3_column_text(init_statement, 0)];
        } else {
            self.title = @"";
        }
       
        sqlite3_finalize(init_statement);
    }
    return self;
}

Здесь мы столкнулись с новым (а для кого-то может быть привычным) синтаксисом запроса, когда под реальные значения резервируется место в тексте запроса. Такой подход позволяет избежать SQL-инъекций при программировании под web, в sqlite же это единственный способ формирования текста запроса и, должен сказать, не самый плохой. Связывание с переменными осуществляется группой функций sqlite3_bind_*, имя которых зависит от типа переменной. Будьте внимательны, второй параметр указывает номер зарезервированного места, причем нумерация начинается с единицы, а не с нуля!

Как вы помните, Си библиотека оперирует строками с завершающим нулевым байтом, поэтому возвращаемое значение конвертируем в стандартную для Objective-C строку.

Вы заметили, что мы каждый вызов initWithIdentifier:database: тратим время на инициализацию переменной init_statement. Давай разрешим ее повторное использование, объявив переменной класса, и перенесем вызов sqlite3_finalize(init_statement) в метод класса, который вызовем перед уничтожением всех объектов — перед выходом из программы.


static sqlite3_stmt *init_statement = nil;

-(id)initWithIdentifier:(NSInteger)idKey database:(sqlite3 *)db {
    if (self = [super init]) {
        database = db;
        primaryKey = idKey;
       
        // Инициализуем переменную init_statement при первом вызоме метода
        if (init_statement == nil) {
            // Подготавливаем запрос перед отправкой в базу данных
            const char *sql = "SELECT title FROM records WHERE id=?";
            if (sqlite3_prepare_v2(database, sql, -1, &init_statement, NULL) != SQLITE_OK) {
                NSAssert1(NO, @"Error: failed to prepare statement with message '%s'.", sqlite3_errmsg(database));
            }
        }
       
        // Подставляем значение в запрос
        sqlite3_bind_int(init_statement, 1, self.primaryKey);
        
        // Получаем результаты выборки
        if (sqlite3_step(init_statement) == SQLITE_ROW) {
            self.title = [NSString stringWithUTF8String:(char *)sqlite3_column_text(init_statement, 0)];
        } else {
            self.title = @"";
        }
       
        // Сбрасываем подготовленное выражение для повторного использования
        sqlite3_reset(init_statement);
    }
    return self;
}

// Метод класса объявляется со знаком "+" перед объявлением метода
+(void)finalizeStatements {
    if (init_statement) sqlite3_finalize(init_statement);
}

Вызов finalizeStatements необходимо производить перед выходом из приложения, в методе applicationWillTerminate класса txtEditAppDelegate.


-(void)applicationWillTerminate:(UIApplication *)application {
  [Record finalizeStatements];
    
  if (sqlite3_close(database) != SQLITE_OK) {
     NSAssert1(0, @"Error: failed to close database with message '%s'.", sqlite3_errmsg(database));
  }
}

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


-(void)readRecord {
    if (read_statement == nil) {
        const char *sql = "SELECT txt FROM records WHERE id=?";
        if (sqlite3_prepare_v2(database, sql, -1, &read_statement, NULL) != SQLITE_OK) {
            NSAssert1(NO, @"Error: failed to prepare statement with message '%s'.", sqlite3_errmsg(database));
        }
    }
   
    sqlite3_bind_int(read_statement, 1, self.primaryKey);
   
    if (sqlite3_step(read_statement) == SQLITE_ROW) {
        self.txt = [NSString stringWithUTF8String:(char *)sqlite3_column_text(read_statement, 0)];
    } else {
        self.txt = @"";
    }
   
    sqlite3_reset(read_statement);
}

-(void)updateRecord {
    // Если обновление уже проходило — выходим
    if (self.txt == nil) return;
   
    if (update_statement == nil) {
        const char *sql = "UPDATE records SET title=?, txt=? WHERE id=?";
        if (sqlite3_prepare_v2(database, sql, -1, &update_statement, NULL) != SQLITE_OK) {
            NSAssert1(NO, @"Error: failed to prepare statement with message '%s'.", sqlite3_errmsg(database));
        }
    }
   
    // title содержит несколько символов из начала записи для отображения в списке TableView
    NSUInteger lenToCut = (txt.length < TITLE_LENGTH) ? txt.length : TITLE_LENGTH;
    self.title = [txt substringToIndex:lenToCut];
   
    sqlite3_bind_text(update_statement, 1, [title UTF8String], -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(update_statement, 2, [txt UTF8String], -1, SQLITE_TRANSIENT);
    sqlite3_bind_int(update_statement, 3, self.primaryKey);
   
    if (sqlite3_step(update_statement) != SQLITE_DONE) {
        NSAssert1(NO, @"Error: failed to update with message '%s'.", sqlite3_errmsg(database));
    }
   
    sqlite3_reset(update_statement);
   
    self.txt = nil;
}

Метод readRecord самодокументируем, а вот по коду updateRecord дам несколько комментариев. Перед сохранением в переменную title помещается часть полного текста записи для отображения в списке в UITableView. Метод substringToIndex вызывает исключение, если длина строки меньше значения длины вырезаемой подстроки. Поэтому мы формируем переменную lenToCut и в начало файла добавляем объявление константы.


#define TITLE_LENGTH 50

Метод sqlite3_bind_text по сравнению с sqlite3_bind_int имеет парочку новых параметров: четвертый указывает на длину передаваемого текста в байтах (не символах!), а последний на то, как поступать с передаваемой строкой. SQLITE_TRANSIENT указывает sqlite на создание копии передаваемых данных в своей внутренней среде, иначе после выхода из sqlite3_bind_text строка [title UTF8String] будет уже не доступна к использованию в запросе. Давайте приведу еще пример, чтобы было понятнее. Мы могли бы написать следующим образом:


const char *newTitle = [title UTF8String];
sqlite3_bind_text(update_statement, 1, newTitle, -1, SQLITE_STATIC);

В этом случае переменная newTitle после вызова функции sqlite3_bind_text не изменится, не выйдет за пределы видимости и не будет освобождена — она статическая, поэтому мы могли бы вызвать sqlite3_bind_text с параметром SQLITE_STATIC.

В конце вызова updateRecord мы освобождаем переменную txt, чтобы сэкономить и так не резиновую память у iPhone.

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


-(void)insertIntoDatabase:(sqlite3 *)db {
    database = db;
   
    // Если пользователь ничего не ввел, то запись в базу не производится
    if ((self.txt == nil) || (txt.length == 0)) return;
   
    if (insert_statement == nil) {
        const char *sql = "INSERT INTO records(title, txt) VALUES(?, ?)";
        if (sqlite3_prepare_v2(database, sql, -1, &insert_statement, NULL) != SQLITE_OK) {
            NSAssert1(NO, @"Error: failed to prepare statement with message '%s'.", sqlite3_errmsg(database));
        }
    }
   
    NSUInteger lenToCut = (txt.length < TITLE_LENGTH) ? txt.length : TITLE_LENGTH;
    self.title = [txt substringToIndex:lenToCut];
   
    sqlite3_bind_text(insert_statement, 1, [title UTF8String], -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(insert_statement, 2, [txt UTF8String], -1, SQLITE_TRANSIENT);
   
    if (sqlite3_step(insert_statement) == SQLITE_DONE) {
        primaryKey = sqlite3_last_insert_rowid(database);
    } else {
        NSAssert1(NO, @"Error: failed to insert into the database with message '%s'.", sqlite3_errmsg(database));
    }
   
    sqlite3_reset(insert_statement);
   
    self.txt = nil;
}

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

Объявляем read_statement, update_statement и insert_statement как статические переменные и добавляем их освобождение в метод finalizeStatements.


+(void)finalizeStatements {
    if (init_statement) sqlite3_finalize(init_statement);
    if (read_statement) sqlite3_finalize(read_statement);
    if (update_statement) sqlite3_finalize(update_statement);
    if (insert_statement) sqlite3_finalize(insert_statement);
}

И под конец, не забудьте также добавить в заголовочный файл объявления всех описанных методов.


+(void)finalizeStatements;
-(id)initWithIdentifier:(NSInteger)idKey database:(sqlite3 *)db;
-(void)readRecord;
-(void)updateRecord;
-(void)insertIntoDatabase:(sqlite3 *)db;

И напоследок, не забываем про освобождение переменных.


— (void)dealloc {
    [title release];
    [txt release];
    [super dealloc];
}

Взаимодействие с интерфейсом

Теперь, когда все подготовительные операции завершены, мы можем приступить к объединению нашей модели с контроллерами. Первым делом, возьмемся за mainViewController: напишем вывод списка записей и модифицируем методы, реагирующие на выбор документа из списка и создание новой записи. Но прежде подключим нужные нам классы.


#import "txtEditAppDelegate.h"
#import "Record.h"

Для того, чтобы получить доступ к переменной records, содержащейся в txtEditAppDelegate, мы воспользуемся необычным ходом.


// Возвращает число строк для указанной секции
-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    txtEditAppDelegate *appDelegate = (txtEditAppDelegate *)[[UIApplication sharedApplication] delegate];
    return [appDelegate.records count];
}

sharedApplication возвращает синглтон объекта UIApplication, который создается в main.m. Так как сам UIApplication в работе программы не участвует, то мы получаем ссылку на его делегата, являющимся объектом класса txtEditAppDelegate. Ну а дальше — дело техники :)


-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Rec"];
   
    if (cell == nil) {
        cell = [[[UITableViewCell alloc] initWithFrame:CGRectZero reuseIdentifier:@"Rec"] autorelease];
    }
   
    // Получаем ссылку на делегат класса UIApplication и доступ к его переменным
    txtEditAppDelegate *appDelegate = (txtEditAppDelegate *)[[UIApplication sharedApplication] delegate];
    Record *record = (Record *)[appDelegate.records objectAtIndex:indexPath.row];
   
    cell.text = record.title;
    return cell;
}

В переменную record получаем ссылку на указанный в indexPath элемент списка и выводим в ячейке заголовок этой записи.


-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    txtEditAppDelegate *appDelegate = (txtEditAppDelegate *)[[UIApplication sharedApplication] delegate];
    txtEditViewController *controller = self.txtViewController;
   
    Record *record = (Record *)[appDelegate.records objectAtIndex:indexPath.row];
    [record readRecord];
    controller.record = record;
   
    [[self navigationController] pushViewController:controller animated:YES];
}

На код выше хочу обратить ваше внимание. Спросите зачем я произвел бессмысленное присвоение для переменную controller? А вы помните как txtViewController инициализируется?

txtViewController в момент запуска приложения еще не определен, равен nil. Чтобы его инициализовать, необходимо обратить к нему хотя бы раз, чтобы он прочел интерфейс своего класса и связи из xib-файла. А до тех пор это лишь пустая переменная. И если бы я сделал присвоение вида txtViewController.record = record, а затем вызвал pushViewController:self.txtViewController, то во-первых, значение выбранной записи в него бы не передалось, а во-вторых, при выходе из режима редактирования я бы приобрел несколько неприятных минут поисков своей ошибки.


-(IBAction)addRecord {
    txtEditViewController *controller = self.txtViewController;
    controller.record = [[[Record alloc] init] autorelease];
   
    [[self navigationController] pushViewController:controller animated:YES];
}

addRecord действует аналогичным образом, ведь мы не знаем куда пользователь нажмет первым делом — на добавление новой записи или редактирование записи из списка.

Пришла очередь взяться за txtEditViewController, и первым делом отредактируем заголовочный файл и включим переменную record в его состав.


#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>

@class Record;

@interface txtEditViewController : UIViewController <UITextViewDelegate> {
    IBOutlet UITextView *txtView;
    Record *record;
}

@property (nonatomic, retain) UITextView *txtView;
@property (nonatomic, retain) Record *record;

-(void)doneAction:(id)sender;

@end

В txtEditViewController.m добавьте синтез свойства record, его release в dealloc и импорт заголовочного файла класса Record и txtEditAppDelegate. После загрузки контроллера мы должны считать из record значение txt в поле TextView, а при закрытии окна редактирования — записать обратно.


-(void)viewDidAppear:(BOOL)animated {
    txtView.text = record.txt;
}

-(void)viewWillDisappear:(BOOL)animated {
    record.txt = txtView.text;
   
    txtEditAppDelegate *appDelegate = (txtEditAppDelegate *)[[UIApplication sharedApplication] delegate];
    [appDelegate updateOrAddRecordIntoDatabase:self.record];
   
    [self doneAction:self];
}

В класс txtEditAppDelegate мы передаем переменную record, чтобы уже так, в зависимости от того новая эта запись или отредактированная, txtEditAppDelegate отправил ее либо на добавление в базу данных через insertIntoDatabase, а затем в список records, либо в updateRecord. И, конечно, нам придется добавить описание и реализацию этого метода в txtEditAppDelegate.


-(void)updateOrAddRecordIntoDatabase:(Record *)record {
    if (record.primaryKey != 0) {
        [record updateRecord];
    } else {
        [record insertIntoDatabase:database];
        [records addObject:record];
    }
}

Если вы все сделали правильно, то старт программы ознаменует собой не появления 100+ ошибок компиляции, а открытием нашего наикрутейшего приложения.

Исправление багов

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

Давайте составим план наших действий. Чтобы избавиться от этого бага, нам необходимо менять размер UITextView при входе в режим редактирования (метод textViewDidBeginEditing) и возвращать его обратно при выходе (метод textViewDidEndEditing). Плюс, нужно отслеживать ориентацию аппарата и менять размер TextView в зависимости от положения. Также не забываем про возможный вариант, когда в процессе набора текста iPhone будет повернут на 90 градусов и нам также нужно будет изменить область редактирования. Чтобы отслеживать этот режим предлагаю ввести переменную editMode. Добавьте в объявления переменных в txtEditViewController.h строку:


BOOL editMode;

И сразу же добавьте строчку editMode = NO в метод viewDidAppear для инициализации значения. При окончании редактирования размер TextView должен стать таким же как у View, его содержащего.


— (void)textViewDidEndEditing:(UITextView *)textView {
    [textView setFrame:self.view.frame];
   
    editMode = NO;
}

ViewController содержит переменную interfaceOrientation, указывающую на текущее положение аппарата.


-(void)resizeTextViewIfNeeded {
    if ((self.interfaceOrientation == UIInterfaceOrientationPortrait) ||
        (self.interfaceOrientation == UIInterfaceOrientationPortraitUpsideDown)) {
        [txtView setFrame:CGRectMake(0, 0, 320, 200)];
    } else {
        [txtView setFrame:CGRectMake(0, 0, 480, 106)];
    }
}

Размер экрана у iPhone 320 на 480 пикселей. Точкой отсчета является левый верхний угол. Значения высоты TextView при появлении клавиатуры на экране были выведены мною экспериментально. Не исключая, что есть лучший вариант выхода из подобной ситуации или другие возможности получения этих координат, и если вы обладаете этими знаниями, буду очень благодарен за совет по приведению кода в пристойный вид.

resizeTextViewIfNeeded будем вызывать в двух местах: при изменении положения iPhone во встроенной методе didRotateFromInterfaceOrientation от UIViewController, а также во textViewDidBeginEditing.


— (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation {
    if (editMode == YES) {
        [self resizeTextViewIfNeeded];
    }
}

-(void)textViewDidBeginEditing:(UITextView *)textView {
    UIBarButtonItem *buttonDone = [[UIBarButtonItem alloc]
                                    initWithBarButtonSystemItem:UIBarButtonSystemItemDone
                                    target:self
                                    action:@selector(doneAction:)];
    self.navigationItem.rightBarButtonItem = buttonDone;
    [buttonDone release];
   
    [self resizeTextViewIfNeeded];
    editMode = YES;
}

Чтобы мы могли вызывать resizeTextViewIfNeeded, добавьте объявление метода в заголовочном файле.

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

Приложение txtEdit 0.2 для iPhone

Скачать архив проекта (25Кб)

В качестве источников информации для статьи были использованы:

Для статьи не использовались, но вы можете найти их полезными:

Комментарий

Комментарии

Алексей Блинов 4.09.2008 13:40

Как всегда отличный урок. Спасибо.

Насчет операции в get-методе. То, что ты прочел в общем и целом верно, но бывают и исключения. Что, если мне, к примеру, нужно, чтобы два разных класса содержали переменные, ссылающиеся на один и тот же NSArray, содержимое которого будет меняться в процессе, и обоим классам нужно эти изменения отслеживать? Тогда копирование точно не подойдет — нужен retain. Бывают также случаи, когда копирование – довольно дорогостоящая операция.

Еще в паре мест в коде у тебя вместо дефиса стоит mdash. XCode их отображает одинаково, поэтому я долго думал, почему скопированный код выдает мне ошибку Unknown token. :)

ответить
Evgeniy Krysanov 4.09.2008 15:35

два разных класса содержали переменные, ссылающиеся на один и тот же NSArray

Не спорю, все зависит от ситуации.

Еще в паре мест в коде у тебя вместо дефиса стоит mdash

А это уже претензия к движку :) Хотя это не его “минус”, а скорее “плюс”. Он очень хорошо обрабатывает текст, чтобы тот выглядел валидным в браузере. Если не считать ссылок “Добавить пост в” в конце статьи, то весь блог полностью XHTML валидный.

ответить
Val 22.09.2008 13:41

Очень хороший пример. Спасибо. Хотелось бы обсудить другой вариант. Если база сама по себе — коммерческий продукт (нет аналогов). Поэтом её требуется как-то закрыть. Если поместить в папку “Документс”, то любой вытащит данные. Под виндоус я делал стандартную базу mdb, но шифровал данные внутри базы каким-то-там-многобитным алгоритмом. Успешно. А для мака такой же способ? Или можно как-то попроще? Если базу включить в папку проекта с “только чтением”, вероятно её смогут всё же вытащить и декомпилировать?

ответить
Evgeniy Krysanov 22.09.2008 13:58

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

Папка Documents не является той папкой, которая доступна всем программам как в Windows. Эта папка создается индивидуально для каждого приложения и запись и чтение из нее ограничено приложением, которому она принадлежит (в прошлом или позапрошлом посте я описывал состав папок).

Так что пользуясь легальным iPhone, доступ к этой базе извне получить не выйдет (ага, смешная фраза, учитывая недавние инструкции в сети как по Wi-Fi можно законнектиться к любому iPhone/iPod Touch и зайти под root). Но “человек сделал, человек и сломать сможет” и лучше перестраховаться.

ответить
Val 22.09.2008 14:00

Мерси. Бережёного Бог бережёт.

ответить
hardwarrior 30.10.2008 21:55

Отличная статья! Но есть 2 вопроса: 1) Можно ли как то получить доступ к базе в документсах на эмуляторе? 2) Есть ли в xcode визуальный браузер для SQLite баз. Хотя бы только на чтение, хотя бы не для всякого файлика-базы, а с эмулятора или девайса, на котором отлаживается)))

ответить
Evgeniy Krysanov 31.10.2008 14:24

Спасибо.

  1. При отладке приложения на iPhone можно скачать содержимое папки Documents на компьютер через XCode. При работе в эмуляторе такие папки тоже создаются, но я не могу сейчас сказать где они лежат — под рукой нет мака.
  2. Консольный редактор есть. Визуального в XCode нет, но есть сторонние приложения:
    http://mac.softpedia.com/get/Developer-Tools/SQLite-Database-Browser.shtml
    http://sqlabs.net/sqlitemanager.php
ответить
Merlin 23.11.2008 6:16

Сам начал искать редактор после прочтения статьи. Вот нашел замечательный и главное бесплатны. Правда реализован он в виде плагина для Firefox =)

https://addons.mozilla.org/en-US/firefox/addon/5817

ответить
vinny 1.11.2008 11:12

Респект за статью
В функции -(void)initializeDatabase (а может еще в каких-нибудь) в самом конце не хватает строчки sqlite3_close(database); или мне что-то мощно не по глазам :)))

ответить
Evgeniy Krysanov 1.11.2008 13:58

Эта строчка есть, в applicationWillTerminate. База не закрывает до окончания работы программы.

ответить
Alexander 4.11.2008 17:57

Есть проблема с файлом базы.

Файл базы уже подключен к проекту.

  1. sqlite3 mydatabase.db
  2. создаем таблицу.
  3. закрываем.
  4. запускаем приложение где пробуем сделать select из этой базы.
  5. получаем ошибку о том что такой таблицы нет.
  6. копируем файл базы в папку /Users/…./Library/Application Support/iPhone Simulator/User/Applications/…../
  7. запускаем снова приложение.
  8. все работает.

чем это можно объяснить и как это можно исправить?

ответить
Ярослав Моталов 5.11.2008 20:05

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

ответить
amis 2.01.2009 11:10

По поводу изменения размеров UIView при появлении/сокрытии клавиатуры думаю код не очень удачен. А как будет работать ваша программа если выйдет IPhone nano с несколько другим размером экрана или Apple что-то в клавиатуру добавит например смайлики что изменит её размер. Не очень хорошая мысль задавать координаты явно в коде. Кстати говоря в документации iPhone Application Programming Guide в разделе Moving Content That Is Located Under the Keyboard, есть куда более правильный метод автоматического перестроения UIView при появлении клавиатуры.

ответить
amis 2.01.2009 11:10

Кстати спасибо за Ваши статьи

ответить
Alexander 8.01.2009 6:33

Я нашёл ещё один баг. Если у меня заметка на весь экран айфона, я её открываю, хочу добавить текст в конце. Нажимаю на последнюю строчку, выезжает клавиатура, а фокус получается далеко не на той строчке, которую я нажимал. Видимо, дело в ресайзинге view. Надо почитать Moving Content That Is Located Under the Keyboard. Спасибо за статьи. Прям не знаю чё бы я без них делал.

PS: блин, опять не туда коммент засунул. Пока статью осилишь, мозг уже отдыха просит.

ответить
Дмитрий 9.01.2009 2:13

В чем тайный смысл этой конструкции?

NSMutableArray *recordsArray = [[NSMutableArray alloc] init];
self.records = recordsArray;
[recordsArray release];

И этой:

success = [fileManager....];
if (!success) {
    NSAssert1(NO, ....);

Не лучше ли:

records = [[NSMutableArray alloc] init];

и

success = [fileManager....];
NSAssert1(success, ....);
ответить
Чеслав 15.03.2009 15:11

А что нужно сделать, что бы запустить отладку на iPhone. Выдает вот что Line Location Tool:0: CodeSign error: Code Signing Identity ‘iPhone Developer’ does not match any code-signing certificate in your keychain. Once added to the keychain, touch a file or clean the project to continue.

Спасибо! И еще… Не могли бы вы написать пример по получению файлов xml из интернета, с последующим их ччтением

ответить
Val 15.03.2009 21:38

Нужно загрузить сертификат для Вашего iPhone с сайта Apple iPhone Developer Center. Там нужно за 100 долларов зарегистрироваться сначала.

Там же много примеров кода, в том числе загрузка и парсинг файлов xml.

ответить
alexey pakhomov 8.04.2009 2:10

А как быть если данные в бд в кодировке UTF-16. есть функции для работы с UTF-16. открытие БД проходит без проблем (sqlite3_open16) а вот уже sqlite3_prepare16_v2 не хочет работать. не знаю где баг. помогите разобраться

ответить
Val 8.04.2009 4:27

я базу заливал в mySQL, там конвертировал в  UTF-8, потом опять заливал в sqlite. У меня правда была в кирилице в mdb база. Но принцип можно использовать. Я так полистал гугл — много жалоб на этот оператор. Может проблема в именах — в коде Objective-C  по умолчанию UTF-8 идёт. Это просто гипотезы.

ответить
alexey pakhomov 8.04.2009 22:12

а можете поделица ссылкой или личным опытом как заливать в mysql и конвертировать там?

ответить
alexey pakhomov 8.04.2009 22:21

а мож кто в курсе: бд делаю в excell под mac os.там сохраняю файл как utf 16 txt(так как только тут нормально русский язык оторбражаеться). импорт потом в бд делаю через плагин для firefoxa. все отлично работает вот только utf-16 не устраивает мож кто знает как сохранять в utf-8???

ответить
alexey pakhomov 9.04.2009 1:56

все.разобрался сам. спасибо )

ответить
iMike 22.04.2009 12:25

В первую очередь хочу выразить автору свою благодарность за этот труд. Особенно очень нравится стиль написания статей. Как для меня, так можно еще меньше давать кода (лучше описать идею метода) в статье да бы дать читателю как можно больше на освоение и самостоятельную реализацию. Хотя я в обще впервые сталкиваюсь с С языком — до этого все время писал на Delphi. Но вот купил себе iPhone и как всегда решил открыть для себя что то новое — но это уже другая история.

А теперь по статье! Я нашел еще один “бок” в Вашей статье (аппликации). Попробуйте сделать следующие действия (в симуляторе): нажмите “+”, то есть добавить новую запись, и как только он создастся — нашу кнопку “Back”. Все отлично, в нашу базу новая запись не попадет, так как сработает проверка в методе -(void)insertIntoDatabase:(sqlite3 *)db (файл Record.m):

if((self.txt==nil)||(txt.length==0)) return;

Таким образом мы вернемся к списку записей. А теперь попробуйте кликнуть по пустой ячейке в нашей таблице, следующей сразу же за последней непустой. Мы попадем в окно редактирования. Как это получилось? Давайте заглянем в файл txtEditAppDelegate.m в метод -(void)updateOrAddRecordIntoDatabase:(Record *)record:

if(record.primaryKey!=0){
    [record updateRecord];
}else{
    [record insertIntoDatabase:database]; //Вставить запись в базу, но там сработает проверка на пустоту
    [records addObject:record]; //а вот посылкой этого сообщения мы добавляем пустую запись в наш список
}

По этому метод должен выглядеть так:

if(record.primaryKey!=0){
    [record updateRecord];
}else{
    if((record.txt==nil)||(record.txt.length==0)) return;
    [record insertIntoDatabase:database]; 
    [records addObject:record]; 
}

И теперь подобное не повторится. Естественно в insertIntoDatabase: проверка на пустоту уже не понадобится, так что от туда ее можно убрать.

ЗЫ Надеюсь понятливо описал:)

ответить
Антон С 13.11.2009 3:42

Можно сделать по другому:

if (record.primaryKey != 0) {
    [record updateRecord];
} else {
    if ([record insertIntoDatabase:database])
        [records addObject:record];
}

Только для этого надо будет изменить тип функции insertIntoDatabase: с (void) на (BOOL) и добавить в функцию корректный return тут:

if ((self.text == nil) || (text.length == 0))
    return FALSE;

и добавить в конце функции (BOOL)insertIntoDatabase: строчку:

return TRUE;
ответить
Constantine 30.11.2010 23:31

ребята откуда взялись 2 файла mainViewController.h и mainViewController.m их же не было в “правильном” текстовом редакторе…

ответить

Форма комментирования для «SQL для самых маленьких»

Обязательное поле. Не больше 30 символов.

Обязательное поле

captcha image Пожалуйста, введите символы, которые вы видите на изображении

сергей 25.01.2011 19:15

Добрый день. А какую локальную БД ещё можно использовать для МакОС? Все примеры SQLLite направлены именно для айфона, я так посмотрю. А я ищу для большого брата решение…

ответить
.