Friday, April 16, 2010
My love/hate relationship with the iPhone SDK
There is a lot to hate about the iPhone SDK. But there's also a lot to love.
My app iTravel goes out and gets Wikitravel pages, and then gets subpages in the background. (eg if you ask for "New York City", it'll also go out and get "Manhattan", "Upper West Side", etc.) It also plots listings (sights, restaurants, etc.) for all these pages on a map. I wanted the background thread that was loading this data to automatically a new page's listings to the map, if the viewer was using one; so they can go to "New York City", go to the map, and then watch Manhattan slowly get barnacled by map annotations, one neighbourhood at a time.
I thought this was going to be difficult. I couldn't have been more wrong. Here's the background-thread code, in its entirety:
I'd like to do this in Android, too...but a) their map implementation works a lot slower, b) I don't think there even is a method to get the currently active Activity.
Now, the "showAnnotations:" method within MapViewController is obviously trickier. For one thing, it's synchronized, lest the user try to filter a map just when a background thread is adding listings - not really a UI issue, since this usually takes all of 1-2 seconds:
What follows is one of the reasons I hate the SDK. Basically, I want to do some fairly basic set arithmetic to ensure that we remove undesired annotations from the map (but keep them in our local "annotations" set in case we need to add them again) and add new ones that are desired. Because the "-unionSet:" etc. methods on NSMutableSet don't work at all like you'd expect, though, I basically have to do that by hand. I'll skip over that messy part to the good stuff:
My app iTravel goes out and gets Wikitravel pages, and then gets subpages in the background. (eg if you ask for "New York City", it'll also go out and get "Manhattan", "Upper West Side", etc.) It also plots listings (sights, restaurants, etc.) for all these pages on a map. I wanted the background thread that was loading this data to automatically a new page's listings to the map, if the viewer was using one; so they can go to "New York City", go to the map, and then watch Manhattan slowly get barnacled by map annotations, one neighbourhood at a time.
I thought this was going to be difficult. I couldn't have been more wrong. Here's the background-thread code, in its entirety:
-(void) refreshMapIfActive {
UIApplication *app = [UIApplication sharedApplication];
iTravelRightAppDelegate *appDelegate = app.delegate;
UINavigationController *controller = [appDelegate navigationController];
NSArray *viewControllers = [controller viewControllers];
UIViewController *currentController = [viewControllers lastObject];
if ([currentController class] == [MapViewController class])
[currentController performSelectorOnMainThread:@selector(showAnnotations:) withObject:nil waitUntilDone:NO];
}
I'd like to do this in Android, too...but a) their map implementation works a lot slower, b) I don't think there even is a method to get the currently active Activity.
Now, the "showAnnotations:" method within MapViewController is obviously trickier. For one thing, it's synchronized, lest the user try to filter a map just when a background thread is adding listings - not really a UI issue, since this usually takes all of 1-2 seconds:
-(void)doShowAnnotations:(NSArray*)annotationsToShow {
//autorelease pool
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
@synchronized (self) {
@try {
NSArray *wikiPageMarkers = [WikiPage getPageMarkersWithin:mapView.region];
BOOL added=NO;
double pageLevel = [Settings getMapPageLevel];
BOOL abovePageLevel = mapView.region.span.longitudeDelta > pageLevel || mapView.region.span.latitudeDelta > pageLevel;
if (abovePageLevel && [mapView.annotations count] > [wikiPageMarkers count]) { //clear away low-level annotations
[mapView performSelectorOnMainThread:@selector(removeAnnotations:) withObject:[annotations allObjects] waitUntilDone:YES];
[annotations removeAllObjects];
}
else if (!abovePageLevel)
{
UITabBarItem *selected = [self.tabBar selectedItem];
if (selected!=nil) {
//first, remove all annotations that don't fit the selection
NSMutableArray *toRemove = [NSMutableArray arrayWithCapacity:[mapView.annotations count]];
for (NSObject *annotation in mapView.annotations) {
if ([annotation class] == [Listing class]) {
Listing *listing = (Listing*) annotation;
if (!(selected.tag==MY && [listing isInMyListings] || selected.tag==[listing.category intValue]))
[toRemove addObject:listing];
}
}
[mapView performSelectorOnMainThread:@selector(removeAnnotations:) withObject:toRemove waitUntilDone:YES];
}
if (annotationsToShow==nil) { //if we don't have a specific request, get all the listings within the map's region
NSNumber *category = selected==nil ? nil : [NSNumber numberWithInt:selected.tag];
annotationsToShow = [ListingManager getListingsWithin:mapView.region forCategory:category];
}
//the following is incredibly messy because NSSet and NSMutableSet are unusable for our purposes.
What follows is one of the reasons I hate the SDK. Basically, I want to do some fairly basic set arithmetic to ensure that we remove undesired annotations from the map (but keep them in our local "annotations" set in case we need to add them again) and add new ones that are desired. Because the "-unionSet:" etc. methods on NSMutableSet don't work at all like you'd expect, though, I basically have to do that by hand. I'll skip over that messy part to the good stuff:
NSMutableArray *thereNotRequested = [NSMutableArray arrayWithArray:mapView.annotations];
for (annotation in mapView.annotations) {
if ([requested objectForKey:[annotation title]] != nil)
[thereNotRequested removeObject:annotation];
}
[mapView performSelectorOnMainThread:@selector(removeAnnotations:) withObject:thereNotRequested waitUntilDone:YES];
if ([arrayToAdd count]>0) {
[mapView performSelectorOnMainThread:@selector(addAnnotations:) withObject:arrayToAdd waitUntilDone:YES];
added=YES;
}
if (!added)
[self performSelectorOnMainThread:@selector(activityDone) withObject:nil waitUntilDone:NO];
}
@catch(NSException *exception) {
[Util doLog:[NSString stringWithFormat:@"Warning: adding annotations to map view failed: %@", [exception reason]]];
[self performSelectorOnMainThread:@selector(activityDone) withObject:nil waitUntilDone:NO];
}
@finally {
[pool release];
}
}
Labels: currentController, iPhone, maps, MapView, NSMutableSet, Objective-C, performSelector:, selector, selectors, sets, synchronization, threading, UINavigationController
Tuesday, April 6, 2010
SQLite on Android
So a commenter on Android Market suggested I allow users store my app data on their SD card rather than phone memory. Great idea, I thought, and promptly went Googling to look for some example code. Alas, there wasn't any. So I wrote my own; and here it is for those who find themselves in a similar situation.
The basic Android model is for all Activity classes to have an associated DBHelper class. I don't like this at all; you waste all kinds of time writing and instantiating and opening and closing your DBHelpers, for the sake of (in my app) maybe twenty different database calls. So instead I went and created a singleton DB object. Here's the static code:
(I call DB.Close() in the onDestroy() method of the app's Activities. Now, if you had multiple threads writing to the database simultaneously, this approach would probably get pretty messy in a hurry; fortunately, I don't.)
The Android SDK also includes a SQLiteOpenHelper object, which basically takes a Context object and returns a writeable database. I was a little irritated by the need to pass in a Context - it means you have a Context on hand to access the database at all - but presumed it was just necessary for some mysterious reason.
Not so mysterious at all. The bad news is, if you want your database to live on your phone's SD card, you can't use the SQLiteOpenHelper. The good news is, it's easy to write an SD-card-compatible variant, one that doesn't require any Context at all:
Turns out the only thing we ever needed that Context for was its "getDatabasePath()" method, as that varies by application; but if you're using the SD card, you don't need that at all. (The above implementation supports both SD-card and phone-memory databases. Just in case.)
So what happens in the DB object's instance(s)? Easy enough -
et voila: all your database access in one place, accessed by a simple DB.GetFor(Context context) call from anywhere in your app (and you don't even need the Context if you know the DB will live on the SD card!) and nicely abstracting out the crazy ten-positional-arguments-in-a-row "query" method on SQLiteDatabase.
The basic Android model is for all Activity classes to have an associated DBHelper class. I don't like this at all; you waste all kinds of time writing and instantiating and opening and closing your DBHelpers, for the sake of (in my app) maybe twenty different database calls. So instead I went and created a singleton DB object. Here's the static code:
public class DB {
private static DB instance;
SQLiteDatabase mDB;
DatabaseHelper mDbHelper;
final Context mCtx;
public static DB GetFor(Context context) {
if (instance==null)
instance = new DB(context);
if (!instance.isOpen())
instance.open();
return instance;
}
public static void Close() {
if (instance!=null && instance.isOpen())
instance.close();
instance=null;
}
/**
* Database creation sql statement
*/
static final String MESSAGE_CREATE =
"create table Message (_id integer primary key autoincrement, "
+ "content text not null, dateCreated datetime not null, dateViewed datetime, "
+ "messageTitle text, messageType integer, sender text, recipient text); ";
// [...other table definitions go here...]
static final String DATABASE_NAME = "iTravel";
static final int DATABASE_VERSION = 1;
(I call DB.Close() in the onDestroy() method of the app's Activities. Now, if you had multiple threads writing to the database simultaneously, this approach would probably get pretty messy in a hurry; fortunately, I don't.)
The Android SDK also includes a SQLiteOpenHelper object, which basically takes a Context object and returns a writeable database. I was a little irritated by the need to pass in a Context - it means you have a Context on hand to access the database at all - but presumed it was just necessary for some mysterious reason.
Not so mysterious at all. The bad news is, if you want your database to live on your phone's SD card, you can't use the SQLiteOpenHelper. The good news is, it's easy to write an SD-card-compatible variant, one that doesn't require any Context at all:
static class DatabaseHelper{
private SQLiteDatabase db;
private Context mCtx;
private DatabaseHelper(Context context) {
mCtx=context;
}
public void open() {
File dbDir=null, dbFile=null;
if (Settings.DoSDDB() && Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
dbDir = Environment.getExternalStorageDirectory();
dbFile = new File(dbDir, "iTravel.sqlite");
}
else
dbFile = mCtx.getDatabasePath("iTravel");
if (dbFile.exists()) {
Log.i("SQLiteHelper", "Opening database at "+dbFile);
db = SQLiteDatabase.openOrCreateDatabase(dbFile, null);
if (DATABASE_VERSION > db.getVersion())
upgrade();
}
else {
Log.i("SQLiteHelper", "Creating database at "+dbFile);
db = SQLiteDatabase.openOrCreateDatabase(dbFile, null);
create();
}
}
public void close() {
db.close();
}
public void create() {
Log.w(""+this, "Creating Database "+db.getPath());
db.execSQL(MESSAGE_CREATE);
// ...other tables go here
db.setVersion(DATABASE_VERSION);
}
public void upgrade() {
Log.w(""+this, "Upgrading database "+db.getPath() +" from version " + db.getVersion() + " to "
+ DATABASE_VERSION + ", which will destroy all old data");
db.execSQL("DROP TABLE IF EXISTS Message");
// ...other tables go here
create();
}
public SQLiteDatabase getWritableDatabase() {
if (db==null)
open();
return db;
}
Turns out the only thing we ever needed that Context for was its "getDatabasePath()" method, as that varies by application; but if you're using the SD card, you don't need that at all. (The above implementation supports both SD-card and phone-memory databases. Just in case.)
So what happens in the DB object's instance(s)? Easy enough -
public DB(Context ctx) {
this.mCtx = ctx;
}
public DB open() throws SQLException {
if (mDbHelper==null)
mDbHelper = new DatabaseHelper(mCtx);
if (mDB==null || !mDB.isOpen())
mDB = mDbHelper.getWritableDatabase();
return this;
}
public void close() {
mDbHelper.close();
}
public boolean isOpen() {
return mDB!=null && mDB.isOpen();
}
public boolean deleteAll() {
mDB.delete("Message", null, null);
// ...other tables go here
return true;
}
public Message[] fetchMessages(String where, String[] args) throws SQLException {
Cursor cursor = mDB.query(true, "Message",
new String[] {"_id", "messageTitle", "content" },
where, args, null, null, "dateCreated desc", null);
Message[] messages = new Message[cursor.getCount()];
cursor.moveToFirst();
int i=0;
while (!cursor.isAfterLast()) {
messages[i++] = new Message(cursor.getString(1), cursor.getString(2));
cursor.moveToNext();
}
cursor.close();
return messages;
}
public void saveMessage(Message message) {
ContentValues values = new ContentValues();
values.put("messageTitle", message.getTitle());
values.put("content", message.getData());
values.put("messageType", message.getMessageType());
values.put("dateCreated", System.currentTimeMillis());
mDB.insert("Message", null, values);
}
et voila: all your database access in one place, accessed by a simple DB.GetFor(Context context) call from anywhere in your app (and you don't even need the Context if you know the DB will live on the SD card!) and nicely abstracting out the crazy ten-positional-arguments-in-a-row "query" method on SQLiteDatabase.
Labels: Android, card, Context, DB, DBHelper, getDatabasePath, openOrCreateDatabase, SD, SDK, SQLite, SQLiteDatabase, SQLiteOpenHelper
Subscribe to Posts [Atom]