thomas.dohmke.de - tagged with programmierung http://thomas.dohmke.de/feed en-us http://blogs.law.harvard.edu/tech/rss Sweetcron thomas@dohmke.de iPhone Code Schnipsel: Kennung des Gerätes http://thomas.dohmke.de/items/view/321/iphone-code-schnipsel-kennung-des-gerates

Gesucht

Die Kennung (Identifier bzw. UDID) eines iPhone oder iPod Touch programmatisch ermitteln.

Lösung

UIDevice *device = [UIDevice currentDevice]; NSString *uniqueIdentifier = [device uniqueIdentifier];

UIDevice bietet außerdem Instanzmethoden zur Bestimmung von Name (name) und Modell (model) des Gerätes sowie Name (systemName) und Version (systemVersion) des Betriebssystems. Beispiel für mein iPhone:

Name: MyPhone Model: iPhone SystemName: iPhone OS SystemVersion: 2.2.1

]]>
Sat, 18 Apr 2009 13:11:00 +0200 http://thomas.dohmke.de/items/view/321/iphone-code-schnipsel-kennung-des-gerates
iPhone Code Schnipsel: Den Benutzer warten lassen http://thomas.dohmke.de/items/view/320/iphone-code-schnipsel-den-benutzer-warten-lassen

Gegeben

Der Benutzer füllt seine Benutzerdaten in zwei Textfelder ein und klickt dann auf Login. Anschließend wird ein Request an den Server gesendet und abhängig von der Antwort zum Hauptmenü gewechselt oder eine Fehlermeldung angezeigt.

Gesucht

Während des Wartens auf die Antwort soll die aktuelle Ansicht durch ein HUD mit einem UIActivityIndicatorView überlagert werden.

Lösung

Wir laden die Klasse SFHFHUDView in Form der beiden Dateien SFHFHUDView.h und SFHFHUDView.m von GitHub runter und binden diese in unser Xcode-Projekt ein. Anschließend kann das HUD wie folgt erzeugt und angezeigt

SFHFHUDView *HUDView = [[SFHFHUDView alloc] initWithFrame:CGRectMake(0, 0, 320, 480)]; [HUDView setStatusText:@"Connecting..."]; [HUDView setShowsStatusLabel:YES]; [HUDView startActivityIndicator]; [HUDView makeKeyAndVisible];

sowie später wieder entfernt werden:

[HUDView resignKeyWindow]; [HUDView setHidden:YES];

]]>
Fri, 17 Apr 2009 18:33:00 +0200 http://thomas.dohmke.de/items/view/320/iphone-code-schnipsel-den-benutzer-warten-lassen
iPhone Code Schnipsel: Eingestellte Sprache http://thomas.dohmke.de/items/view/319/iphone-code-schnipsel-eingestellte-sprache

Gesucht

Welche Sprache hat der Benutzer auf seinem iPhone oder iPod Touch eingestellt?

Lösung

NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults]; NSArray* allLanguages = [defaults objectForKey:@"AppleLanguages"]; NSString* currentLanguage = [allLanguages objectAtIndex:0];

currentLanguage enthält dann das Sprachkürzel nach ISO 639-1, z.B. de für Deutsch oder en für Englisch.

]]>
Fri, 17 Apr 2009 12:23:00 +0200 http://thomas.dohmke.de/items/view/319/iphone-code-schnipsel-eingestellte-sprache
iPhone Code Schnipsel: Erweitertes NSLog http://thomas.dohmke.de/items/view/249/iphone-code-schnipsel-erweitertes-nslog

Gegeben

Diverse Aufrufe von NSLog innerhalb eines iPhone-Projektes, z.B.

  • (void)applicationDidFinishLaunching:(UIApplication *)application { NSLog(@"App did finish launching.");

    // ...

    Gesucht

    In der Debug-Konfiguration soll zusätzlich Dateiname und Zeilennummer ausgegeben werden, in der Release-Konfiguration soll hingegen die Ausgabe deaktiviert werden.

    Lösung

    Inspiriert durch John Muchows Blogpost Filename and Line Number with NSLog: Part II und wie folgt abgewandelt:

LogHelper.h:

if DEBUG

define CMLog(format, ...) [LogHelper logWithPath:FILE line:LINE string:(format), ## VA_ARGS]

else

define CMLog(format, ...)

endif

@interface LogHelper : NSObject { }

  • (void)logWithPath:(char *)path line:(NSUInteger)line string:(NSString *)format, ...;

LogHelper.m:

import "LogHelper.h"

@implementation LogHelper

  • (void)logWithPath:(char *)path line:(NSUInteger)line string:(NSString *)format, ... { NSString *pathString = [[NSString alloc] initWithBytes:path length:strlen(path) encoding:NSUTF8StringEncoding];

    va_list argList; va_start(argList, format); NSString *formattedString = [[NSString alloc] initWithFormat:format arguments:argList]; va_end(argList);

    NSLog([NSString stringWithFormat:@"%@ (%d): %@", [pathString lastPathComponent], line, formattedString]); [formattedString release]; }

@end

Unter Target > AppName > Get Info ist dann im Reiter Build für die Konfiguration Debug die Einstellung Other C Flags mit -DDEBUG zu ergänzen und im Code NSLog durch CMLog zu ersetzen.

]]>
Sun, 15 Mar 2009 16:11:00 +0100 http://thomas.dohmke.de/items/view/249/iphone-code-schnipsel-erweitertes-nslog
iPhone Code Schnipsel: Hinweis nur einmal anzeigen http://thomas.dohmke.de/items/view/243/iphone-code-schnipsel-hinweis-nur-einmal-anzeigen

Gesucht¶

Beim ersten Start der App soll der Benutzer einmalig eine Meldung erhalten.

Lösung¶

Man nehme die Klassen UIAlertView und NSUserDefaults und rührt ein wenig um:

NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; NSString *showScrollingHint = [defaults stringForKey:@"LaunchMessage"]; if ((!showScrollingHint) || ([showScrollingHint isEqualToString:@"YES"])) { UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Message" message:@"If you like this app, tell it to all your friends." delegate:self cancelButtonTitle:@"Alrighty" otherButtonTitles:nil, nil]; [alert show]; } [defaults setObject:@"NO" forKey:@"LaunchMessage"];

Bei Bedarf kann man zusätzlich ein Settings.bundle erstellen, so dass der Benutzer die Einstellung zurücksetzen kann und damit den Hinweis erneut angezeigt bekommt.

]]>
Thu, 12 Mar 2009 22:03:00 +0100 http://thomas.dohmke.de/items/view/243/iphone-code-schnipsel-hinweis-nur-einmal-anzeigen
iPhone Code Schnipsel: Link aus UIWebView in MobileSafari öffnen http://thomas.dohmke.de/items/view/236/iphone-code-schnipsel-link-aus-uiwebview-in-mobilesafari-offnen

Gegeben¶

In einem UIWebView wird ein String dargestellt (UIWebView bietet faktisch die einzige Möglichkeit auf dem iPhone, "Rich-Text" darzustellen):

NSString *path = [[NSBundle mainBundle] bundlePath]; NSURL *baseURL = [NSURL fileURLWithPath:path]; [webView loadHTMLString:HTMLString baseURL:baseURL];

Gesucht¶

Links innerhalb des Strings sollen sich bei Aufruf durch den Benutzer in MobileSafari öffnen.

Lösung¶

Delegate des UIWebView auf eine Subklasse von UIViewController setzen, dort die Methode webView:shouldStartLoadWithRequest:navigationType: definieren:

  • (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType { NSRange range = [[[request URL] absoluteString] rangeOfString:@"file:///"]; if (range.location == 0) { return YES; }

    [[UIApplication sharedApplication] openURL:[request URL]]; return NO; }

    Die Abfrage auf "file:///" ist notwendig, da die Delegate-Methode auch beim Laden des eigentlichen Strings aufgerufen wird. In dem Fall ist der Rückgabewert YES, so dass das UIWebView die Darstellung startet. In allen anderen Fällen wird MobileSafari über openURL aufgerufen und NO zurückgegeben.

    Siehe auch¶

    iPhone Code Schnipsel: Link in MobileSafari öffnen

]]>
Tue, 10 Mar 2009 20:14:00 +0100 http://thomas.dohmke.de/items/view/236/iphone-code-schnipsel-link-aus-uiwebview-in-mobilesafari-offnen
iPhone Code Schnipsel http://thomas.dohmke.de/items/view/233/iphone-code-schnipsel

Unter der Rubrik iPhone Code Schnipsel veröffentlichen wir hier seit ein paar Tagen kurze Ausschnitte aus dem Code, die wir bei der täglichen Arbeit mit dem iPhone-SDK immer wieder mal brauchen. So können wir sie selbst schnell wiederfinden und möglicherweise hilft es auch dem ein oder anderen Leser weiter:

Subviews umdrehen Text überblenden Verfügbare Schriftarten ermitteln Link in MobileSafari öffnen

Fortsetzung folgt...

]]>
Mon, 09 Mar 2009 16:14:00 +0100 http://thomas.dohmke.de/items/view/233/iphone-code-schnipsel
iPhone Code Schnipsel: Link in MobileSafari öffnen http://thomas.dohmke.de/items/view/232/iphone-code-schnipsel-link-in-mobilesafari-offnen

Gegeben¶

Ein View mit einem Button:

  • (void)loadView { UIButton *button = //... [button addTarget:self action:@selector(openLink:) forControlEvents:UIControlEventTouchDown]; [view addSubview:button]; }

    Gesucht¶

    Beim Drücken des Buttons soll sich ein zuvor festgelegter Link (z.B. die Twitter-Seite des Entwicklers) in MobileSafari öffnen.

    Lösung¶

  • (IBAction)openLink:(id)sender { NSURL *url = [NSURL URLWithString:@"http://twitter.com/ashtom"]; [[UIApplication sharedApplication] openURL:url]; }

    Will man statt MobileSafari ins Mail-Programm springen, hilft ein mailto:-Link:

  • (IBAction)openLink:(id)sender { NSURL *url = [NSURL URLWithString:@"mailto:thomas@dohmke.de"]; [[UIApplication sharedApplication] openURL:url]; }

    Betreff und Inhalt der E-Mail können mit den üblichen mailto:-Optionen mit vordefinierten Werten ausgestattet werden.

]]>
Mon, 09 Mar 2009 15:55:00 +0100 http://thomas.dohmke.de/items/view/232/iphone-code-schnipsel-link-in-mobilesafari-offnen
PLInstrument in iPhone-Projekte einbinden http://thomas.dohmke.de/items/view/231/plinstrument-in-iphone-projekte-einbinden

Als Ergänzung zu den heutigen Linktips erklärt dieser Artikel kurz die Einbindung von PLInstrument in ein bestehendes iPhone-Projekt:

PLInstrument aus dem Subversion-Repository auschecken: svn checkout http://plinstrument.googlecode.com/svn/trunk/ plinstrument Datei PLInstrument.xcodeproj mit Xcode öffnen. Unter Overview das Target PLInstrument-iPhoneOS auswählen, außerdem die Konfiguration auf Release setzen. Mit Cmd-B kompilieren. Unter Overview das Target PLInstrument-iPhoneSimulator auswählen. Mit Cmd-B kompilieren. Im Terminal in das Verzeichnis von PLInstrument wechseln und dort folgenden Befehl ausführen, um eine gemeinsame Bibliothek für iPhone und iPhone Simulator zu erstellen:lipo build/Release-iphonesimulator/libPLInstrument-iphonesimulator.a build/Release-iphoneos/libPLInstrument-iphoneos.a -create -output libPLInstrument-iphoneos.a Die erstellte Datei libPLInstrument-iphoneos.a in das eigene iPhone-Projekt einbinden, außerdem alle .h-Dateien aus dem Ordner plinstrument/Source einbinden. Mit Cmd-B kompilieren, es sollte dabei keine Fehlermeldung auftreten.

Anschließend kann PLInstrument innerhalb des eigenen Codes benutzt werden, z.B. um die Zeit für einen Datenbank-Request zu ermitteln:

import "PLInstrument.h"

// ...

PLIAbsoluteTime start = PLICurrentTime(); NSUInteger iterations = 0;

NSObject<PLResultSet> *results; results = [database executeQuery:@"SELECT * FROM test;"]; while ([results next]) { iterations++; // Do something with the results } [results close];

PLIAbsoluteTime finish = PLICurrentTime(); PLInstrumentResult *result = [PLInstrumentResult resultWithStartTime:start endTime:finish iterations:iterations]; NSLog(@"Select took %dns per iteration.", [result intervalPerIteration]); Bei Ausführung der App wird die mittlere Zeit pro Iteration in Nanosekunden auf der Konsole ausgegeben:

2009-03-08 18:55:06.734 Playground[2736:a203] Select took 2089470ns per iteration.

Es ist zu beachten, dass die API und Dokumentation von PLInstrument laut des Entwicklers derzeit nicht vollständig ist und entsprechend Änderungen unterliegen kann.

]]>
Sun, 08 Mar 2009 19:52:00 +0100 http://thomas.dohmke.de/items/view/231/plinstrument-in-iphone-projekte-einbinden
iPhone Code Schnipsel: Verfügbare Schriftarten ermitteln http://thomas.dohmke.de/items/view/229/iphone-code-schnipsel-verfugbare-schriftarten-ermitteln

Gesucht¶

Alle auf dem iPhone verfügbaren Schriftarten.

Lösung¶

NSMutableArray *familyNames = [[NSMutableArray alloc] initWithArray:[UIFont familyNames]]; [familyNames sortUsingSelector:@selector(compare:)]; for (NSInteger index = 0; index < [familyNames count]; index++) { NSLog(@"Family name: %@", [familyNames objectAtIndex:index]); NSMutableArray *fontNames; fontNames = [[NSMutableArray alloc] initWithArray: [UIFont fontNamesForFamilyName: [familyNames objectAtIndex:index]]]; for (NSInteger subIndex = 0; subIndex < [fontNames count]; subIndex++) { NSLog(@"- Font name: %@", [fontNames objectAtIndex:subIndex]); } [fontNames release];
} [familyNames release];

]]>
Sat, 07 Mar 2009 19:57:00 +0100 http://thomas.dohmke.de/items/view/229/iphone-code-schnipsel-verfugbare-schriftarten-ermitteln
iPhone Code Schnipsel: Text überblenden http://thomas.dohmke.de/items/view/227/iphone-code-schnipsel-text-uberblenden

Gegeben¶

Ein Textfeld in Form einer UITextView:

UITextView *textView = [[UITextView alloc] initWithFrame:[[UIScreen mainScreen] applicationFrame]]; [textView setText:@"Hello World."]; self.view = textView;

Gesucht¶

Der Aufruf von setText soll mit einem Überblendeffekt (englisch: fading) animiert werden.

Lösung¶

[CATransaction begin]; CATransition *animation = [CATransition animation]; animation.type = kCATransitionFade; animation.duration = 0.5; [textView setText:@"It's a sunny day."]; [[textView layer] addAnimation:animation forKey:@"fadeText"]; [CATransaction commit];

Der Wert von duration gibt die Dauer in Sekunden an, der Wert von type die Art der Animation. kCATransitionFade entspricht dem gewünschten Überblendeffekt. Mögliche Alternativen sind kCATransitionMoveIn (neuer Text bewegt sich rein), kCATransitionPush (neuer Text bewegt sich rein, alter Text bewegt sich raus) und kCATransitionReveal (neuer Text erscheint, alter Text bewegt sich raus).

]]>
Fri, 06 Mar 2009 16:12:00 +0100 http://thomas.dohmke.de/items/view/227/iphone-code-schnipsel-text-uberblenden
iPhone Code Schnipsel: Subviews umdrehen http://thomas.dohmke.de/items/view/221/iphone-code-schnipsel-subviews-umdrehen

Gegeben¶

Ein Fenster mit zwei Subviews:

[window addSubview:view1]; [window addSubview:view2];

Gesucht¶

Zwischen den Views wechseln und dabei eine Animation anzeigen, die ein Umdrehen symbolisiert.

Lösung¶

CGContextRef context = UIGraphicsGetCurrentContext(); [UIView beginAnimations:nil context:context]; [UIView setAnimationTransition:UIViewAnimationTransitionFlipFromLeft forView:window cache:YES]; [UIView setAnimationCurve:UIViewAnimationCurveEaseInOut]; [UIView setAnimationDuration:1.0]; [window exchangeSubviewAtIndex:1 withSubviewAtIndex:0]; [UIView commitAnimations];

Will man von rechts nach links drehen, nimmt man UIViewAnimationTransitionFlipFromRight statt dem obigen UIViewAnimationTransitionFlipFromLeft.

]]>
Thu, 05 Mar 2009 15:06:00 +0100 http://thomas.dohmke.de/items/view/221/iphone-code-schnipsel-subviews-umdrehen
Unit-Testing in Objective-C, Teil 3 http://thomas.dohmke.de/items/view/199/unit-testing-in-objective-c-teil-3

Im dritten Teil unserer Serie über Unit-Testing in Objective-C wollen wir das Beispielprojekt aus Teil 1 fortsetzen und dabei die Erstellung von Mock-Objekten mit OCMock kennen lernen. Der Beispielcode steht wieder als Git-Repository zur Verfügung, welches per

git clone git://www.komprovisation.de/objcut1.git

lokal geklont oder auf der Projektseite durchstöbert werden kann. Zudem ist im Artikel an dedizierten Stellen die jeweilige Revision vermerkt.

Gegeben¶

Aufbauend auf der letzten Revision des ersten Teils haben wir das Modell User bereits um eine einfache Datenbankanbindung erweitert. Als Datenbank wird SQLite zusammen mit der Bibliothek PLDatabase verwendet. Letztere hat den Vorteil, dass die Aufruf an SQLite gekapselt werden und damit ein Wechsel auf ein anderes Datenbanksystem leichter möglich sein sollte. Die Klasse User verwendet die Datenbank über die Hilfsklasse UserDatabase, die im Wesentlichen die folgenden zwei Methoden zur Verfügung stellt:

  • (User *)findWithEmail:(NSString *)email { [database open];

    User *user = nil; NSObject<PLResultSet> *results; results = [database executeQuery: @"SELECT * FROM users WHERE email = ?", email]; if ([results next]) { user = [[User alloc] init]; user.email = [results stringForColumn:@"email"]; user.hashedPassword = [results stringForColumn:@"hashed_password"]; } [results close];

    [database close];

    return [user autorelease]; }

  • (BOOL)saveWithEmail:(NSString *)email hashedPassword:(NSString *)hashedPassword { [database open];

    NSObject<PLPreparedStatement> *statement; statement = [database prepareStatement: @"INSERT INTO users (email, hashed_password) VALUES (?, ?)"]; [statement bindParameters: [NSArray arrayWithObjects: email, hashedPassword, nil]]; BOOL result = [statement executeUpdate]; [statement close];

    [database close];

    return result; }

    Die Methode findWithEmail: sucht einen Benutzer anhand der E-Mail-Adresse und liefert eine Instanz des Objektes User zurück. Die Methode saveWithEmail:hashedPassword: speichert einen Benutzer mit seiner E-Mail-Adresse und dem verschlüsselten Password. Die Verschlüsselung des Passwortes ist unabhängig von der Datenbank und erfolgt daher in User mittels der Methode encrypt:.

  • (NSString *)encrypt:(NSString *)password { unsigned char hash[CC_SHA256_DIGEST_LENGTH]; CC_SHA256([password UTF8String], [password lengthOfBytesUsingEncoding:NSUTF8StringEncoding], hash);

    NSMutableString *hashedPassword; hashedPassword = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH];

    for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; ++i) { [hashedPassword appendString:[NSString stringWithFormat:@"x", hash[i]]]; }

    return hashedPassword; }

    [Revision 7d181d7d]

    Gesucht¶

    Ziel ist es nun, die Methode loginWithEmail:password:

  • (BOOL)loginWithEmail:(NSString *)login password:(NSString *)password { return ([login isEqualToString:@"thomas@dohmke.de"] && [password isEqualToString:@"foobar"]); }

    so abzuändern, dass diese eine Klassenmethode ist und eine Instanz von User zurückliefert, sofern die Authentifizierung mit E-Mail-Adresse und Passwort erfolgreich war. E-Mail und Passwort sollen dabei mit den Werten in der Datenbank verglichen werden, d.h. die Methode muss im Gegensatz zur bisherigen Fake-Version korrekt funktionieren.

    Lösung¶

    Im ersten Schritt wandeln wir die beiden Testmethoden in UserTest.m so ab, dass sie der gewünschten Signatur entsprechend. Zur Erinnerung nachfolgend zunächst die bisherige Version:

-(void)testValidLogin { STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"foobar"], YES, @"should return YES"); }

-(void)testInvalidLogin { STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"wrong"], NO, @"should return NO"); }

Die Methode soll in eine Klassenmethode umgewandelt werden, daher müssen wir sie statt an die Instanz user an die Klasse User senden. Außerdem soll sie eine Instanz von User zurückliefern, entsprechend sind die Aufrufe von STAssertEquals durch STAssertNotNil bzw. STAssertNil zu ersetzen:

  • (void)testValidLogin { STAssertNotNil([User loginWithEmail:@"thomas@dohmke.de" password:@"foobar"], @"Should return user object."); }

  • (void)testInvalidLogin { STAssertNil([User loginWithEmail:@"thomas@dohmke.de" password:@"wrong"], @"Should return nil."); }

    Bei der Ausführung schlagen die Tests erwartungsgemäß fehl, da die erwartete Signatur der Methode nicht mit der Implementierung übereinstimmt. Entsprechend ändern wir die Deklaration in User.h

  • (User *)loginWithEmail:(NSString *)login password:(NSString *)password;

    und passen die Definition in User.m entsprechend an:

  • (User *)loginWithEmail:(NSString *)login password:(NSString *)password { User *user = [User findWithEmail:login]; if ([[User encrypt:password] isEqualToString:user.hashedPassword]) { return user; } else { return nil; } }

    Die Implementierung sucht den Benutzer mittels der Methode findWithEmail:, verschlüsselt den Parameter password und vergleicht das Ergebnis mit dem verschlüsselten Passwort in der von findWithEmail: gefundenen Instanz. [Revision 13222fee]

    Die Tests werden nun bestanden, allerdings ergibt sich ein neues Problem: Damit die Methode den Benutzer überhaupt in der Datenbank finden kann, müssen wir diesen dort speichern. Dies erfolgt bereits in der Methode setUp des Testfalls:

  • (void)setUp { user = [User userWithEmail:@"thomas@dohmke.de" password:@"foobar"]; STAssertEquals([user save], YES, @"save failed."); }

    Da das Speichern jedoch in derselben Datenbank erfolgt, die auch vom eigentlichen Programm benutzt wird, würden wir bei einem echten System möglicherweise reale Benutzerdaten überschreiben. Das ist unschön. Zudem wird der Benutzer mit jeder Testmethode neu angelegt wird, wie wir uns leicht auf der Konsole überzeugen können:

$ sqlite3 objcut1.db SQLite version 3.4.0 Enter ".help" for instructions sqlite> select * from users; 1|thomas@dohmke.de|c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2 2|thomas@dohmke.de|c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2 3|thomas@dohmke.de|c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2 4|thomas@dohmke.de|c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2

Hier kommt nun OCMock ins Spiel.

Bessere Lösung¶

OCMock erlaubt uns, die Klasse UserDatabase durch einen Dummy zu ersetzen und in Folge zu verhindern, dass tatsächlich in die Datenbank geschrieben wird. Entsprechend wird UserDatabase selbst nicht mehr getestet, was in Ordnung geht, da wir in UserTest.m nur die Klasse User testen wollen.

Zuerst installieren wir OCMock, in dem wir es von seiner Homepage runterladen (aktuell ist derzeit Version 1.29), das Archiv entpacken und das Verzeichnis OCMock.framework nach /Library/Framework kopieren. Dann klicken wir unter Groups & Files rechts auf Frameworks > Linked Frameworks, Menüpunkt Add > Existing Frameworks, suchen das gerade kopierte Verzeichnis und bestätigen mit Add.

Als nächstes fügen wir der Testklasse in UserTest.h eine Instanzvariable für das Mock-Objekt hinzu:

@interface UserTest : SenTestCase { // ...

id databaseMock;

}

In UserTest.m importieren wir OCMock

import <OCMock/OCMock.h>

und definieren eine neue Methode, die das Mock-Objekt erstellt:

  • (void)setUpMockDatabase { BOOL yes = YES; NSValue *wrappedValue = [NSValue valueWithBytes:&yes objCType:@encode(BOOL)];

    databaseMock = [OCMockObject mockForClass:[UserDatabase class]]; [[[databaseMock stub] andReturnValue:wrappedValue] saveWithEmail:user.email hashedPassword:user.hashedPassword]; [[[databaseMock stub] andReturn:user] findWithEmail:user.email]; [User setDatabase:databaseMock]; }

    Die ersten zwei Zeilen packen den booleschen Wert YES in eine Instanz der Klasse NSValue ein. In den folgenden drei Zeilen wird das Mock-Objekt für die Klasse UserDatabase mit zwei Stub-Methoden erstellt. Die Signaturen entsprechend dabei den Deklarationen in UserDatabase.h, wobei saveWithEmail:hashedPassword: stets YES und findWithEmail: die in setUp erstellte Instanz von User zurückgeben soll. In der letzten Zeile wird das Mock-Objekt der Klasse User übergeben, die dieses in Folge für alle Datenbankzugriffe verwendet.

    Schließlich rufen wir setUpMockDatabase in der setup-Methode auf:

  • (void)setUp { user = [User userWithEmail:@"thomas@dohmke.de" password:@"foobar"]; [self setUpMockDatabase]; STAssertEquals([user save], YES, @"save failed."); }

    Fertig. Die Tests werden weiterhin bestanden, greifen aber nicht mehr auf die Datenbank zu. [Revision aeb659e1]

    Erster Nachgang¶

    Neben stub können Stub-Methoden auch mit expect definiert werden. Der Unterschied besteht darin, dass eine mit expect definierte Methode genau einmal aufgerufen werden muss, während die Anzahl der Aufrufe bei "stub" gleichgültig ist, d.h. auch kein Aufruf ist erlaubt. Will man mehr als einen Aufruf kontrollieren, ist das expect-Statement entsprechend oft zu wiederholen.

    Als Beispiel verändern wir setUpMockDatabase wie folgt

  • (void)setUpMockDatabase { // ...

    [[[databaseMock expect] andReturnValue:wrappedValue] saveWithEmail:user.email hashedPassword:user.hashedPassword];

    // ...
    }

    und verifizieren die Anzahl der Aufruf in tearDown:

  • (void)tearDown { [databaseMock verify]; [user release]; }

    Die Methode verify überprüft nach jedem Test, ob die mit expect definierte Stub-Methoden jeweils genau einmal aufgerufen wurde (was im Beispiel der Fall ist, da vor jedem Test in setUp die Instanz user gespeichert wird). [Revision 11065e8d]

    Zweiter Nachgang¶

    Wie würde die Lösung aussehen, wenn man kein Mock-Objekt verwenden will, sondern die Anbindung der Datenbank in UserDatabase bewusst mittesten möchte? SQLite erlaubt die Erstellung einer Datenbank im Hauptspeicher, so dass wir eine neue Methode setUpTestDatabase in UserTest.m wie folgt definieren

  • (void)setUpTestDatabase { userDatabase = [[UserDatabase alloc] initWithPath:@":memory:"]; [userDatabase.database executeUpdate: @"DROP TABLE users;"]; STAssertTrue([userDatabase.database executeUpdate: @"CREATE TABLE users (id INTEGER PRIMARY KEY NOT NULL, email VARCHAR(255), hashed_password VARCHAR(255));"], @""); [User setDatabase:userDatabase]; }

    und statt setUpMockDatabase in setUp aufrufen könnten:

  • (void)setUp { user = [User userWithEmail:@"thomas@dohmke.de" password:@"foobar"]; [self setUpTestDatabase]; STAssertEquals([user save], YES, @"save failed."); }

    Es ist zu beachten, dass die SQL-Befehle in setUpTestDatabase hier nur beispielhaft aufgeführt werden, im realen Leben würde man die Klasse UserDatabase um entsprechende Methoden erweitern, die diese kapseln und damit Dopplungen im Code vermeiden.

]]>
Wed, 04 Mar 2009 16:16:00 +0100 http://thomas.dohmke.de/items/view/199/unit-testing-in-objective-c-teil-3
Drei Links zum Sonntag (VII) http://thomas.dohmke.de/items/view/189/drei-links-zum-sonntag-vii

Heute leicht mit Beschiss, da die drei Links zu einer mehrteiligen Artikelserie gehören (der vierte Teil ist angekündigt, aber noch nicht veröffentlicht):

An Asteroids-style game in CoreAnimation: Teil 1, Teil 2 und Teil 3 Matt Gallagher erläutert in seinem Blog Cocoa with Love, wie sich mit Hilfe von Core Animation ein Klon des Spiels Asteroids programmieren lässt. Er berücksichtigt dabei das Model-View-Controller Entwurfsmuster, erstellt ein auflösungsunabhängiges Design und schnell ist das Spiel auch noch.

]]>
Sun, 01 Mar 2009 19:06:00 +0100 http://thomas.dohmke.de/items/view/189/drei-links-zum-sonntag-vii
Unit-Testing in Objetive-C, Teil 2 http://thomas.dohmke.de/items/view/166/unit-testing-in-objetive-c-teil-2

Der zweite Teil unserer Serie zu Unit-Testing in Objective-C beschäftigt sich mit den Assertions. Sie beschreiben die Behauptungen bzw. Erwartungen, die innerhalb einer Testmethode an das Testobjekt gestellt werden. OCUnit stellt dafür verschiedene Makros zur Verfügung:

STAssertEquals¶

In Teil 1 haben wir bereits das Makro "STAssertEquals" benutzt.

Definition:
STAssertEquals(a1, a2, description, ...);

Beispiel: STAssertEquals(4.0, exp2(2), @"2 times 2 shall be 4.");

Es vergleicht die Werte "a1" und "a2" miteinander und gibt eine Fehlermeldung inklusive des Wertes von "description" aus, wenn diese nicht gleich sind. Für "description" kann ein Formatstring (wie für "printf") definiert werden, entsprechend folgen die durch Komma getrennten Parameter.

STAssertEqualsWithAccuracy¶

Insbesondere bei Verwendung von Fließkommazahlen empfiehlt es sich, statt "STAssertEquals" das Makro "STAssertEqualsWithAccuracy" zu verwenden. Dieses erlaubt die Festlegung einer Abweichung, innerhalb der der erwartete Wert liegen darf.

Definition:
STAssertEqualsWithAccuracy(left, right, accuracy, description, ...);

Beispiel: STAssertEqualsWithAccuracy(1.0, cos(0), 0.001, @"cos(0) shall be 1, but we allow a tolerance of 0.001.");

Der Parameter "accuracy" legt die maximale Differenz zwischen beiden Werten fest, im Beispiel darf der Wert von "cos(0)" also um 0.001 nach oben und unten von 1.0 abweichen.

STAssertEqualObjects¶

Beim Vergleich zweier Objekte ist häufig nicht die Frage, ob die Instanzen selbst identisch sind, sondern ob ihre Werte bzw. die Werte ihrer Instanzvariablen gleich sind. Ein typischer Fall ist der Vergleich zweier NSString-Objekte. OCUnit bietet dafür das Marko "STAssertEqualObjects", welches auf die "isEqual"-Methode der jeweiligen Klasse zurückgreift:

Definition:
STAssertEqualObjects(a1, a2, description, ...);

Beispiel: NSString *string = [NSString stringWithUTF8String:"Just a test"]; STAssertEqualObjects(string, @"Just a test", @"Both strings shall be equal.");

Wer den Unterschied zu "STAssertEquals" sehen will, kann probehalber auch folgenden Aufruf probieren:

STAssertEquals(string, @"Just a test", @"Both strings shall be equal.");

Während die erste Zusicherung erfüllt wird, schlägt die zweite fehl und Xcode zeigt die Meldung "<20dd1900> should be equal to <60610300>: Both strings shall be equal." an. Wie erwartet, werden die Speicheradressen der Objekte und nicht deren Werte verglichen.

STAssertTrue und STAssertFalse¶

Diese beiden Makros überprüfen boolesche Ausdrücke bzw. Funktionen, die boolesche Werte zurückgeben.

Definition:
STAssertTrue(expression, description, ...); STAssertFalse(expression, description, ...);

Beispiel: STAssertTrue([@"Hesse" isGreaterThan:@"Goethe"], @"Hesse is greater than Goethe"); STAssertFalse([@"Hesse" isGreaterThan:@"Rilke"], @"Hesse is not greater than Rilke");

Man sollte "STAssertTrue" nicht für Vergleiche mit "==" oder "isEqual" verwenden, da hier "STAssertEquals" sowie "STAssertEqualObjects" besser geeignet sind und die verglichenen Werte in der Fehlermeldung aufführen (fügt man diese per entsprechenden Token in den Formatstring von "description" ein, kann man selbiges auch mit "STAssertTrue" erreichen, dies ist aber nicht Sinn der Sache).

STAssertNil und STAssertNotNil¶

"STAssertNil" vergleicht den übergebenen Wert mit "nil", "STAssertNotNil" sichert zu, dass er nicht "nil" ist. Der vorrangige Einsatzzweck sind Funktionen, die Zeiger auf Objekte zurückgeben.

Definition:
STAssertNil(a1, description, ...); STAssertNotNil(a1, description, ...);

Beispiel: NSMutableDictionary *dict = [[NSMutableDictionary alloc] init]; [dict setObject:@"TextMate" forKey:@"editor"]; STAssertNil([dict objectForKey:@"viewer"], @"Shall return nil as key is not set in dictionary."); STAssertNotNil([dict objectForKey:@"editor"], @"Shall return not nil as key is set in dictionary.");

Im Beispiel wird ein "NSMutableDictionary" mit einem Eintrag mit dem Key "editor" erstellt, entsprechend liefert es für diesen Key ein Objekt zurück. Für andere Keys wird hingegen "nil" zurückgegeben.

STAssertThrows und STAssertNoThrow¶

Diese Assertions erwarten, dass eine Exception ausgelöst bzw. nicht ausgelöst wird.

Definition:
STAssertThrows(expression, description, ...); STAssertNoThrow(expression, description, ...);

Beispiel: NSMutableArray *array = [[NSMutableArray alloc] init]; [array addObject:@"Xcode"]; STAssertNoThrow([array objectAtIndex:0], @"No exception as index exists."); STAssertThrows([array objectAtIndex:1], @"Exception thrown as index is beyond bounds.");

Zusätzlich gibt es noch die Varianten "STAssertThrowsSpecific" und "STAssertThrowsSpecificNamed" sowie "STAssertNoThrowSpecific" und "STAssertNoThrowSpecificNamed", mit denen sich Typ und Name der Exception spezifieren lassen. Beispiel:

STAssertThrowsSpecificNamed([array objectAtIndex:1], NSException, NSRangeException, @"NSRangeException thrown as index is beyond bounds.");

Außerdem können mit "STAssertTrueNoThrow" und "STAssertFalseNoThrow" boolesche Ausdrücke ausgewertet und gleichzeitig überprüft werden, dass keine Exception erfolgt. Zwar zeigt Xcode auch ohne diese Varianten an, wenn eine Exception ausgelöst wurde, sie ermöglichen jedoch die Darstellung der Fehlermeldung direkt in der betroffenen Zeile.

STFail¶

Last, but not least, lässt das Makro "STFail" jeden Testfall fehlschlagen. Die Motivation dafür ist typischerweise, dass man die betroffene Methode als fehlerhaft oder unfertig markieren und dadurch deutlich machen will, dass noch Handlungsbedarf besteht.

Definition:
STFail(description, ...);

Beispiel: STFail(@"Test not finished yet.");

Damit endet dieser Artikel. Im nächsten Teil der Serie werden wir uns mit Mock-Objekten beschäftigen.

]]>
Fri, 13 Feb 2009 16:17:00 +0100 http://thomas.dohmke.de/items/view/166/unit-testing-in-objetive-c-teil-2
Unit-Testing in Objetive-C, Teil 1 http://thomas.dohmke.de/items/view/128/unit-testing-in-objetive-c-teil-1

Die testgetriebene Entwicklung (Test-Driven Development, abgekürzt TDD) ist eine mittlerweile weit verbreitete Praktik des Extreme Programming bzw. der agilen Entwicklungsmethodik. Mit TDD wird die eigentliche Funktionalität erst dann programmiert, wenn mindestens ein Test fehlschlägt. Für fast alle Programmiersprachen existieren entsprechende Testing-Frameworks, die die Definition, Ausführung und Verifikation der Tests maßgeblich unterstützen. Für Objetive-C wurde ein solches Framework names OCUnit ursprünglich von der schweizer Firma Sen:te entwickelt und im Jahr 2005 von Apple offiziell in Xcode 2.1 integriert. Dennoch liest man (im deutschsprachigen Raum) recht wenig über TDD in Zusammenhang mit Objective-C. Der nachfolgende Artikel soll dahingehend Abhilfe schaffen und ist der Anfang einer kleinen Serie über Unit-Testing für die Entwicklung von Programmen für OS X auf Mac und iPhone.

Ausgangsbasis¶

Im ersten Teil wollen wir beschreiben, wie man ein einfaches Cocoa-Projekt um die Möglichkeit des Unit-Testings erweitert und Testfälle definiert. Als Entwicklungsumgebung wird Xcode 3 benötigt, konkret haben wir Xcode 3.1.2 verwendet, welches mit iPhone SDK 2.2 ausgeliefert wurde. Ferner steht das Projekt als Git-Repository zur Verfügung. Man kann es entweder lokal klonen,

git clone git://www.komprovisation.de/objcut1.git

oder auf der Projektseite anschauen und durchstöbern. Dies hat den Vorteil, dass das Projekt nicht nur als finales Ergebnis runtergeladen werden kann, sondern auch die einzelnen Schritte nachvollziehbar sind. Entsprechende Stellen sind im Artikel durch die jeweilige Revision vermerkt.

Erste Iteration¶

Das Ziel der ersten Iteration ist es, ein neues Projekt in Xcode zu erstellen und dieses durch ein sogenanntes Unit Test Bundle zu erweitern. Sofern nicht schon geschehen, starten wir Xcode und wählen den Menüpunkt "File > New Project" aus. Im folgenden Dialog wählen wir in der linken Spalte unterhalb von Mac OS X den Punkt "Application" aus und dann das Template "Cocoa Application". Weiter geht's mit "Choose", wir geben dem Projekt den Namen "ObjCUT1", wählen ein Verzeichnis aus und erstellen das Projekt mit Klick auf den Button "Save". Es erscheint das nachfolgende Fenster mit dem Projekt. [Revision bcb32406]

Durch Klick auf den Button "Build & Go" oder alternativ Cmd-R kompilieren wir das Projekt und führen es anschließend aus. Es öffnet sich nach kurzer Zeit unser Programm mit einem leeren Fenster.

Zurück zum Projektfenster. Dort finden wir in der linken Spalte "Groups & Files" die Gruppe "Targets". Einziges Mitglied ist bisher das Target "ObjCUT1", welches für die gerade probierte Kompilierung unseres Programms zuständig ist. Durch einen Rechtsklick auf "Target" und die Auswahl des Menüpunkts "Add > New Target" fügen wir nun ein neues Target hinzu. Es öffnet sich ein ähnlicher Dialog wie vorher bei der Erstellung des Projektes, in der linken Spalte wählen wir dieses Mal "Cocoa" aus, dann im rechten Bereich das "Unit Test Bundle" und weiter geht's mit "Next". Im nächsten Dialog geben wir als Name des Target "UnitTests" ein und bestätigen mit "Finish". Die Wahl des Namens ist grundsätzlich egal, einzige Bedingung ist, dass nicht schon ein Target mit gleichem Namen existiert. Im Projektfenster sehen wir jetzt zwei Targets, zusätzlich öffnet sich automatisch das Info-Fenster für das neu erstellte Target (falls nicht, kann man das Fenster entweder per Rechtsklick auf das Target und den Menüpunkt "Get Info" oder durch Linksklick und Cmd-I öffnen).

Im Reiter "General" des Info-Fensters gibt es zwei Listen: "Direct Dependencies" und "Linked Libaries". Wir interessieren uns hier nur für die erste. Direct Dependencies sind solche Targets, von denen unser Unit-Testing-Target abhängig ist. Xcode stellt vor dem Kompilieren sicher, ob diese Abhängigkeiten auf dem neuesten Stand sind oder zunächst kompiliert werden müssen. Wir fügen der Liste einen neuen Eintrag hinzu, indem wir auf den Button mit dem Pluszeichen klicken, das Target "ObjCUT1" auswählen und mit "Add Target" bestätigen.

Als nächstes wechseln wir zum Reiter "Build aus", wo zwei Einstellungen zu setzen sind. Am einfachsten ist dies durch Eingabe des Namens der Einstellung in das Suchfeld. Die erste Einstellung heißt "Bundle Loader" und wir setzen sie auf

$(BUILT_PRODUCTS_DIR)/ObjCUT1.app/Contents/MacOS/ObjCUT1

"BUILT_PRODUCTS_DIR" ist dabei eine definierte Konstante innerhalb von Xcode, welche auf das build-Verzeichnis in unserem Projekt zeigt, z.B. ~/Projects/ObjCUT1/build/Debug. Der restliche Pfad referenziert die ausführbare Datei innerhalb des Application-Bundles.

Die zweite Einstellung heißt "Test Host" und wir setzen sie auf

$(BUNDLE_LOADER)

"BUNDLE_LOADER" ist wiederum eine definierte Konstante, die der obigen Einstellung entspricht. Mit anderen Worten haben beide Einstellungen damit denselben Wert, nur haben wir uns bei der zweiten Einstellung ein wenig Tippaufwand gespart. Abschließend können wir das Info-Fenster schließen.

Im Projektfenster wählen wir nun ganz links in der Symbolleiste unterhalb des Punktes "Active Target" das neue Target "UnitTests" aus und kompilieren mit "Build & Go". Wenn alles geklappt hat, kompiliert das Target ohne Fehlermeldung und es öffnet sich das Programm mit seinem leerem Fenster. [Revision 88f16a29]

Zweite Iteration¶

In der zweiten Iteration wollen wir nun eine einfache Klasse mit Namen "User" testgetrieben entwickeln. Die Klasse soll innerhalb einer Model-View-Controller Architektur ein Model für die Benutzerverwaltung darstellen. Wir werden nicht die vollständige Klasse entwickeln, sondern uns auf eine einzige Beispielmethode beschränken.

Im Sinne von TDD starten wir mit einem Testfall. Im Projektfenster klicken wir mit der rechten Maustaste auf die Gruppe "Classes" und wählen "Add > New File" aus. In der linken Spalte selektieren wir "Cocoa", im rechten Feld die Vorlage "Objective-C test case class". Weiter mit "Next". Der Name der Testfallklasse soll sich aus dem Namen der zu entwickelnden Klasse und dem Suffix "Test" zusammensetzen, d.h. wir geben "UserTest.m" ein. Außerdem deselektieren wir in der Liste "Targets" den Eintrag "ObjCUT1" und selektieren stattdessen das Target "UnitTests". Nach Klick auf "Finish" werden die Dateien erstellt. Mit "Build > Build" oder Cmd-B versichern wir uns, dass keine Fehler auftreten.

Um im TDD-Zyklus voran zu kommen, ist der nächste Schritt, einen Test fehlschlagen zu lassen. Dazu öffnen wir UserTest.m und erstellen eine Testmethode:

import "UserTest.h"

@implementation UserTest

-(void)testValidLogin { STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"foobar"], YES, @"should return YES"); }

@end

OCUnit identifiziert Testmethoden wie auch viele andere Testing Frameworks anhand der Präfixes "test", unsere Methode heißt endsprechend "testValidLogin". Innerhalb der Methode wird das Makro "STAssertEquals" aufgerufen, welches drei Parameter akzeptiert: Der dritte Parameter wird als Fehlermeldung ausgegeben, wenn die ersten zwei Parameter nicht übereinstimmen. In diesem Fall wird außerdem die Testmethode abgebrochen, d.h. weitere Anweisungen innerhalb derselben Methode werden nicht ausgeführt. Im Beispiel wird die Nachricht "loginWithEmail:password:" an ein Objekt "user" gesendet und der Rückgabewert soll dem Wert "YES" entsprechen.

Beim Kompilieren schlägt diese Testmethode offensichtlich fehl. Xcode zeigt uns dies direkt unter der betroffenen Zeile an.

Der zweite Schritt des TDD-Zyklusses ist es, diesen Test erfolgreich zu bestehen. Wir arbeiten uns dazu Schritt für Schritt durch die Fehlermeldungen. Der Compiler kennt das Objekt "user" nicht, Abhilfe schafft die entsprechende Deklaration und anschließende Initialisierung:

import "UserTest.h"

import "User.h"

@implementation UserTest

-(void)testValidLogin { User *user; user = [[User alloc] init]; STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"foobar"], YES, @"should return YES"); [user release] }

@end

Nun fügen wir die Klasse "User" zu unserem Projekt hinzu. Rechtsklick auf "Classes", "Add > New File", links sollte noch "Cocoa" ausgewählt sein, rechts wählen wir dieses Mal "Objective-C class", gehen weiter mit "Next", geben als Name "User.m" ein und selektieren beide Targets. Bestätigen mit "Finish" und kompilieren mit Cmd-B. [Revision cf86f69d]

Die nächste Fehlermeldung bemängelt, dass die Klasse "User" die Nachricht "loginWithEmail:password:" nicht kennt. Wir deklarieren diese in der Datei User.h

import <Cocoa/Cocoa.h>

@interface User : NSObject { }

-(BOOL)loginWithEmail:(NSString *)email password:(NSString *)password;

@end

und definieren sie anschließend in User.m:

import "User.h"

@implementation User

-(BOOL)loginWithEmail:(NSString *)email password:(NSString *)password { return YES; }

@end

Die Implementierung besteht getreu dem Motto "Fake it till you make it" lediglich aus dem Zurückgeben von "YES". Der Build-Prozess läuft nun ohne Fehler durch und wir können mit Cmd-R die Anwendung starten. [Revision 0afffba8]

Dritte Iteration¶

Die dritte Iteration startet mit einer weiteren Testmethode, die im Gegensatz zu oben den Negativfall, d.h. den Login mit falschen Benutzerdaten, überprüft:

-(void)testInvalidLogin { User *user; user = [[User alloc] init]; STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"wrong"], NO, @"should return NO"); [user release]; }

Zwei Punkte gefallen uns auf:

Ein Teil der Methode entspricht einer exakten Kopie der ersten Methode. Die "release"-Methode für "user" wird nie aufgerufen, da der Aufruf von "STAssertEquals" fehlschlägt.

Eine Lösung bietet die so genannte Test Fixture, welche den Kontext beschreibt, den die Testmethoden für ihre Ausführung voraussetzen. Sie wird in OCUnit wie in vielen anderen Testing Frameworks über die Methoden "setUp" und "tearDown" gebildet. setUp wird vor jeder Testmethode aufgerufen, tearDown garantiert danach und zwar unabhängig davon, ob der Test fehlgeschlagen ist oder nicht. Unser Beispiel sieht damit wie folgt aus: [Revision 5434f7d4]

import "UserTest.h"

@implementation UserTest

-(void)setUp { user = [[User alloc] init]; }

-(void)tearDown { [user release]; }

-(void)testValidLogin { STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"foobar"], YES, @"should return YES"); }

-(void)testInvalidLogin { STAssertEquals([user loginWithEmail:@"thomas@dohmke.de" password:@"wrong"], NO, @"should return NO"); }

@end

Die Variable "user" wird entsprechend in der Header-Datei definiert:

import <SenTestingKit/SenTestingKit.h>

import "User.h"

@interface UserTest : SenTestCase { User *user; }

@end

Zum Schluss wollen wir noch die Klasse "User" so verändern, dass beide Tests bestanden werden. Eine reale Lösung würde dazu auf eine Datenbank oder Konfigurationsdatei zurückgreifen, wir bleiben hier bei der einfachstmöglichen Lösung und vertagen den Rest auf später: [Revision a2e14c79]

-(BOOL)loginWithEmail:(NSString *)email password:(NSString *)password { return [email isEqualToString:@"thomas@dohmke.de"] && [password isEqualToString:@"foobar"]; }

Alle Tests werden bestanden und so endet dieser Artikel. Kommentare, Fragen und Kritik sind wie immer herzlich willkommen. Fortsetzung folgt... :)

]]>
Fri, 23 Jan 2009 12:33:00 +0100 http://thomas.dohmke.de/items/view/128/unit-testing-in-objetive-c-teil-1
JavaScript-Uhr mit automatischer Zeitzonenerkennung http://thomas.dohmke.de/items/view/83/javascript-uhr-mit-automatischer-zeitzonenerkennung

Letztens hatten wir in einem Projekt die Anforderung, die Uhrzeit mit automatischer Zeitzonenerkennung auf einer Webseite darzustellen. Automatisch heißt hier, dass der Benutzer in seinem System die Zeitzone nicht selbst einstellen muss. Beispiel: Ich fahre fliege mit meinem MacBook nach Australien, lasse die Systemzeit aber auf mitteleuropäische Zone eingestellt. Der folgende Artikel beschreibt, wie sich eine solche Uhr mit den Diensten Loki und GeoNames realisieren lässt.

Schritt 1: Die Uhr¶

Startpunkt in JavaScript ist die Klasse "Date" und deren Default-Konstruktor, der bei der Erzeugung einer Instanz die jeweils aktuelle Systemzeit des Clients zurückgibt.

var userTime = new Date();

Mittels der Methoden "getDate", "getMonth" und "getFullYear" lassen sich Tag, Monat (mit 0 für Januar) und das Jahr als vierstellige Zahl ermitteln.

var currentDay = userTime.getDate(); var currentMonth = userTime.getMonth() + 1; var currentYear = userTime.getFullYear();

Für die Uhrzeit werden die Methoden "getHours", "getMinutes" und "getSeconds" verwendet:

var currentHours = userTime.getHours(); var currentMinutes = userTime.getMinutes(); var currentSeconds = userTime.getSeconds();

Die sechs Variablen können anschließend zu einem String zusammengebaut werden:

var currentTimeString = currentDay + "." + currentMonth + "." + currentYear + " " + currentHours + ":" + currentMinutes + ":" + currentSeconds;

Die Darstellung soll in einer h1-Überschrift erfolgen, die die ID "clock" hat. Dazu benutzen wir die Variable "document\", die das Document Object Model (DOM) der Seite abbildet und rufen die Methode "getElementById" mit dem Parameter "clock" auf. Der Aufruf liefert eine Referenz auf die h1-Überschrift zurück, deren erstes Kindelement wir auf die oben erzeugte Zeichenkette setzen:

document.getElementById("clock").firstChild.nodeValue = currentTimeString;

Der Quelltext der kompletten Seite sieht dann wie folgt aus:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>Clock 1</title> <script type="text/javascript"> //<![CDATA[ function updateClock() { var userTime = new Date();

var currentDay = userTime.getDate();
var currentMonth = userTime.getMonth() + 1;
var currentYear = userTime.getFullYear();

var currentHours = userTime.getHours();
var currentMinutes = userTime.getMinutes();
var currentSeconds = userTime.getSeconds();

currentHours = ( currentHours &lt; 10 ? "0" : "" ) + currentHours;
currentMinutes = ( currentMinutes &lt; 10 ? "0" : "" ) + currentMinutes;
currentSeconds = ( currentSeconds &lt; 10 ? "0" : "" ) + currentSeconds;

currentDay = ( currentDay &lt; 10 ? "0" : "" ) + currentDay;
currentMonth = ( currentMonth &lt; 10 ? "0" : "" ) + currentMonth;

var currentTimeString = 
    currentDay + "." + currentMonth + "." + currentYear + " " + 
    currentHours + ":" + currentMinutes + ":" + currentSeconds;

document.getElementById("clock").firstChild.nodeValue = currentTimeString;

} //]]> </script> </head> <body onload="updateClock(); setInterval('updateClock()', 500)"> <h1 id="clock">&nbsp;</h1> </body> </html>

Im Attribute "onload" des body-Elements wird erst die Funktion "updateClock" aufgerufen und anschließend ein Timer gesetzt, der dieselbe Funktion alle 500ms triggert. Im Prinzip würde hier auch 1s (d.h. 1000ms) ausreichen, allerdings hängt dann die Sekundenanzeige im schlechtesten Fall fast 1s hinterher. Dieser Fall tritt ein, wenn der erste Aufruf der Seite kurz vor dem Wechsel auf die nächste Sekunde geschieht. Je kleiner man den Timer wählt, desto genauer ist die Anzeige, aber desto mehr Last wird auch erzeugt.

Beispielseite im selben Fenster oder im neuen Fenster ansehen.

Schritt 2: Position des Benutzers bestimmen¶

Wie oben bereits erwähnt, soll die Position mit Loki bestimmt werden. Loki ist ein Dienst der Firma Skyhook Wireless (bekannt u.A. durch die LocateMe Funktion im iPhone) und steht derzeit kostenlos zur Verfügung. Nach der Registrierung erhält man einen "API key", für unsere Webseite lautet er entsprechend der Domain "komprovisation.de".

Die Verwendung von Loki in unserem Skript ist einfach. Es wird die Datei loki.js vom Loki-Server eingebunden

<script type="text/javascript" src="http://loki.com/plugin/files/loki.js"></script>

und drei Funktionen definiert:

function processLocation(location) { document.getElementById("loki_status").firstChild.nodeValue = "Position (" + location.latitude + "; " + location.longitude + ") von Loki erhalten.";
}

function errorLoki() { document.getElementById("loki_status").firstChild.nodeValue = "Fehler von Loki. Bitte versuchen Sie, die Seite neu zu laden."; }

function loadLoki() { var loki = LokiAPI(); loki.onSuccess = function(location) { processLocation(location) } loki.onFailure = function(error) { errorLoki(); } loki.setKey('YOUR_KEY'); // Hier den eigenen Key einfügen loki.requestLocation(true, loki.NO_STREET_ADDRESS_LOOKUP); }

Die Funktion "load" erzeugt zunächst eine Instanz der Klasse "LokiApi", definiert dann je eine Callback-Funktion für die erfolgreiche Bestimmung der Position bzw. den Fehlerfall, setzt den "API key" und ruft schließlich die Funktion requestLocation auf. Deren erster Parameter gibt an, dass wir Längen- und Breitengrad des Benutzers ermitteln wollen, der zweite Parameter gibt an, dass wir keine Adresse benötigen.

Die beiden Callback-Funktionen geben einen Hinweistext aus, in dem sie diesen an das HTML-Element mit der ID "loki_status" einfügen. Insofern müssen wir dieses Element noch im Body definieren und außerdem die Methode "loadLoki" aufrufen:

<body onload="loadLoki(); updateClock(); setInterval('updateClock()', 500)"> <h1 id="clock">&nbsp;</h1> <p id="loki_status">&nbsp;</p> </body>

Beispielseite im selben Fenster oder im neuen Fenster ansehen. Beim Aufruf im Browser erscheint folgender Dialog:

Bestätigt man mit "Allow", so erscheint zunächst die Uhr und kurze Zeit später die Position von Loki.

Schritt 3: Zeitzone des Benutzers bestimmen¶

Sofern Loki erfolgreich war, bekommen wir als Rückgabewert die Position als Längen- und Breitengrad. Mit diesen können wir nun auf den passenden WebService von GeoNames zugreifen. Der Aufruf erfolgt über die URL http://ws.geonames.org/timezoneJSON?lat=47.01&lng=10.2, wobei die Parameter "lat" und "lng" entsprechend der Position von Loki zu setzen sind. Zurückgeliefert wird ein JSON-Objekt mit dem folgenden Inhalt:

{"time":"2009-01-13 17:07", "countryName":"Austria", "rawOffset":1, "dstOffset":2, "countryCode":"AT", "gmtOffset":1, "lng":10.2, "timezoneId":"Europe/Vienna", "lat":47.01}

Das Feld "gmtOffset" gibt den Offset zur Greenwich Mean Time für die Winterzeit an, für die Sommerzeit ist entsprechend "dstOffset" zu verwenden.

Der Aufruf des Webservice in unseren Skript wird in der Funktion "processLocation" realisiert:

function processLocation(location) { document.getElementById("loki_status").firstChild.nodeValue = "Position (" + location.latitude + "; " + location.longitude + ") von Loki erhalten.";

var head = document.getElementsByTagName("head")[0];         
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 
    "http://ws.geonames.org/timezoneJSON?lat=" + location.latitude + "&#38;lng=" + 
    location.longitude + "&#38;callback=processTimeZone";
head.appendChild(script);

}

Nach erfolgter Antwort wird erneut ein Callback aufgerufen:

function processTimeZone(feed) { document.getElementById("geo_status").firstChild.nodeValue = "Zeitzone GMT (" + (feed.gmtOffset > 0 ? "+" : "") + feed.gmtOffset + "h) von GeoNames erhalten.";
}

Wie schon bei Loki geben wir erst mal die Informationen auf der Seite aus, der Einfachheit halber in einem HTML-Element mit der ID "geo_status":

<body onload="load(); updateClock(); setInterval('updateClock()', 500)"> <h1 id="clock">&nbsp;</h1> <p id="loki_status">&nbsp;</p> <p id="geo_status">&nbsp;</p> </body>

Beispielseite im selben Fenster oder im neuen Fenster ansehen.

Schritt 4: Uhrzeit stellen¶

Im letzten Schritt wollen wir nun die Zeitzone verwenden, um die Uhr entsprechend einzustellen. Dazu wird die Zeile

var userTime = new Date();

durch den folgenden Code ersetzt:

var currentTime = new Date();

var localTime = currentTime.getTime(); var localOffset = currentTime.getTimezoneOffset() * 60000; var gmt = localTime + localOffset;

var userTime = new Date(gmt + (3600000 * offset));

Die Funktion "getTime" liefert die aktuelle Zeit in Millisekunden zurück, die Funktion "getTimezoneOffset" die Zeitzone des Betriebssystems in Minuten und wird durch Multiplikation mit 60000 ebenfalls in Millisekunden umgerechnet. Die Variable "gmt" enthält dann die Greenwich Mean Time in Sekunden und wird in der letzten Zeile wieder in ein Objekt der Klasse "Date" umgewandelt. Dabei wird bereits ein "offset" berücksichtigt, der in Stunden angeben werden kann (Variable "offset"). Um diesen der Funktion "updateClock" nur einmal übergeben zu müssen, erweitern sie um zwei Parameter: den Offset selbst und ein boolesches Flag, welches anzeigt, ob wir den Offset setzen wollen.

function updateClock(offset, setOffset) { if (setOffset) { updateClock.offset = offset; } else if (updateClock.offset) { offset = updateClock.offset; }

// ...

Der Trick ist hier, dass in JavaScript Funktionen wie Objekte behandelt werden. Hat der Parameter "setOffset" den Wert "true", dann erzeugen wir eine neue Objektvariable "updateClock.offset" und setzen diese mit dem Wert von "offset". Ansonsten verwenden wir den Wert von "updateClock.offset" als Offset für die Zeit.

Anschließend können wir die Zeitzone in der Funktion "processTimeZone" setzen:

function processTimeZone(feed) { document.getElementById("geo_status").firstChild.nodeValue = "Zeitzone GMT (" + (feed.gmtOffset > 0 ? "+" : "") + feed.gmtOffset + "h) von GeoNames erhalten.";

updateClock(feed.gmtOffset, true);

}

Zudem muss der Aufruf von "updateClock" im "onload"-Attribut des Body-Elements modifiziert werden:

<body onload="load(); updateClock(0, false); setInterval('updateClock(0, false)', 500)"> <h1 id="clock">&nbsp;</h1> <p id="loki_status">&nbsp;</p> <p id="geo_status">&nbsp;</p> </body>

Wir starten also mit einem Offset von 0h, d.h. mit Greenwich Mean Time, und setzen die richtige Zeitzone im Callback vom GeoNames.

Fertige Beispielseite, die noch etwas mit CSS verschönert wurde, im selben Fenster oder im neuen Fenster ansehen.

Nachbereitung¶

Das beschriebene Beispiel hat den Nachteil, dass die Dienste von Loki und GeoNames bei jeder Aktualisierung der Seite oder, sofern die Uhr beispielsweise in Kopf- oder Fußzeile einer Plattform verwendet werden soll, mit jeder Seite aufgerufen. Dies ist nicht nur langsam, sondern man stößt im Fall von Loki auch schnell an die in den Nutzungsbedingungen definierte Grenze von 10000 Abfragen pro Tag. Insofern erscheint es sinnvoll, die Zeitzone beim ersten Aufruf der Seite durch den jeweiligen Benutzer zu ermitteln und dann in seiner Session zu cachen. Besitzt die Plattform ferner die Möglichkeit der Benutzerregistrierung, kann man dem Benutzer eine Auswahl der Standardzeitzone anbieten, so dass die Uhr nicht mit GMT, sondern mit der gewünschten Zeitzone startet.

Ende. Kommentare, Fragen und Kritik sind wie immer herzlich willkommen. :)

]]>
Tue, 13 Jan 2009 18:40:00 +0100 http://thomas.dohmke.de/items/view/83/javascript-uhr-mit-automatischer-zeitzonenerkennung