Windows Forms a .NET Compact Framework

Jak napisać kod, który da się łatwo przenieść z mobile do winforms i w drugą stronę? To pytanie, które zadaje sobie większość developerów upatrujących nowych możliwości dla swoich programów w rozwoju urządzeń przenośnych. Popularność oraz rosnąca wydajność tych urządzeń sprawia, że w pewnych warunkach są o wiele bardziej przydatne od zwykłych komputerów czy nawet netbooków.

Załóżmy, że tworzymy system rozproszony. Inteligencję naszej aplikacji zawrzemy w usłudze. Takie rozwiązanie pozwoli utworzyć klienta dla poszczególnych platform. API naszej usługi będzie dostępne w wybranym protokole (np. SOAP). Postanowiłem opisać sposób, w jaki można poradzić sobie z przenośnością między platformami za pomocą Windows Communication Foundation.

Na początek wyjaśnię, czym jest WCF. Ogólnie rzecz biorąc, to unifikacja różnych technologii komunikacyjnych dostarczanych przez firmę Microsoft. Architektura jest ukierunkowana na usługi (SOA) oraz programowanie obiektowe (OOP). Kolejną cechą WCF jest rozdzielenie implementacji usługi od protokołu komunikacyjnego, szyfrowania itp. Działająca usługa czeka na zgłoszenie klienta. Odebranie wiadomości zawierającej zapytanie powoduje wykonanie odpowiedniej operacji oraz zwrócenie wyniku do inicjującego.

Oto jak rozwiązać problem pisania aplikacji na kilka platform jednocześnie. Mamy jej projekt i chcemy, aby była dostępna z komputera oraz urządzenia mobilnego. Chodzi nam o to, by jak największą część kodu wykorzystać do napisania aplikacji na kolejną platformę. Rozwiązanie jest proste: umieśćmy część wspólną aplikacji jako usługę sieciową. Wykorzystując jej możliwości, zaimplementujmy GUI na komputerze oraz urządzeniu mobilnym. W tym celu posłużymy się WCF. Pozwala on na utworzenie globalnej usługi, z którą będziemy się komunikować przez protokół SOAP (to jedna z zalet wspomnianej technologii). Takie rozwiązanie zapewnia nam dodatkowo kompatybilność z J2EE oraz Microsoft Message Queuing (MSMQ). Dzięki temu wszystkie zdalne metody zobaczymy jako lokalne zasoby, wspólne dla obu klientów, lecz każde wywołanie będzie oznaczać komunikację z naszą globalną usługą za pomocą pakietów SOAP opartych na XML. Jak zbudować taki system, wyjaśnię na przykładzie prostej aplikacji, przesyłającej wiadomości z różnych klientów typu Windows i mobile. Najpierw zaprezentuję sposób programowania usługi. Główne zadania naszej usługi to:

  • zaloguj (utwórz nowego użytkownika),
  • wyślij wiadomość,
  • pobierz wszystkie wiadomości,
  • pobierz wszystkie wiadomości od wiadomości.

            Teraz zajmiemy się stworzeniem usługi. Każda musi mieć środowisko, w którym działa − proces, domenę aplikacji (ang. Aplication domain), oraz punkty dostępowe (endpoints). Endpoint zawiera:

  • Binding - określający, jaki protokół będzie używany podczas korzystania z endpoint,
  • Adres - adres URL lokalizujący maszynę oraz znajdujący się na niej endpoint,
  • Contract - określający, która klasa będzie udostępniona w danym endpoint.

Budowę serwisu rozpoczniemy od stworzenia tzw. ServiceContract.

W omawianym przypadku wygląda on następująco:

/// <summary>
    /// Definicja kontraktu usługi
    /// </summary>
    [ServiceContract(Namespace = "Marek.Wittkowski.WCF")]
    public interface IMessager
    {
        /// <summary>
        ///    funkcja rejestruje nowego użytkownika chatu
        /// </summary>
        /// <param name="nick"> nazwa nowego użytkownika zgłaszającego się do czatu</param>
        /// <returns> nowy  or null jeśli błąd  </returns>
        [OperationContract]
        ChatUser Login(string nick);

        /// <summary>
        /// funkcja wysyła nową wiadomość
        /// </summary>
        /// <param name="userId"> id użytkownika</param>
        /// <param name="message"> treść wiadomości</param>
        /// <returns>id nowej wiadomości</returns>
        [OperationContract]
        int SendMessage(int userId, String message);

        /// <summary>
        /// Pobiera wszystkie wiadomości z serwera
        /// </summary>
        /// <returns>lista wszystkich wiadomości na serwerze</returns>
        [OperationContract]
        List<Message> GetAllMessages();

        /// <summary>
        /// Zwraca wiadomości otrzymane po wiadomości o wskazanym id
        /// </summary>
        /// <param name="messageid">id wiadomości, po której chcemy otrzymać listę</param>
        /// <returns>lista wiadomości spełniających powyższe założenia</returns>
        [OperationContract]
        List<Message> GetAllMessagesFrom(int messageid);
    }

Jak widać, jest to zwykły interfejs. Opatrzony został atrybutem [ServiceContract(Namespace = "Marek.Wittkowski.WCF")] oznaczającym, że jest to interfejs usługi. Metody usługi zostały oznaczone atrybutem [OperationContract].

Moja usługa wykorzystuje dwa obiekty biznesowe: Message (wiadomość przesyłana do naszego systemu), ChatUser (użytkownik systemu). Pierwszy obiekt ma następujące właściwości:

  • Id (id wiadomości − to numer dostarczenia wiadomości do serwera, im wyższy, tym wiadomość została później wysłana),
  • Date (czas, w którym wiadomość dotarła do serwera),
  • Content (treść wiadomości),
  • User(użytkownik nadający wiadomość).

Struktura opisująca użytkownika jest znacznie prostsza, zawiera id oraz nazwę użytkownika.

Obiekty biznesowe to za mało. Należy więc pomyśleć o klasach zarządzających naszymi obiektami (tzw. klasach menedżerach). To właśnie ich zadaniem będzie przechowywanie wiadomości i zarządzanie nimi oraz użytkownikami.

Wszystkie operacje na wspomnianych wcześniej obiektach biznesowych są wykonywane przez menedżery MessagesManager i UserManager. Rozpocznę od drugiego, do którego zadań należy:

  • dodawanie nowego użytkownika do systemu,
  • udzielanie informacji o istniejących użytkownikach.

Ponieważ zakładamy, że użytkownik wyśle więcej niż jedną wiadomość, wyszukiwanie go po id będzie częstą operacją. Nazwy użytkowników systemu przechowuję zatem w słowniku. Dzięki temu wyszukiwanie jest szybsze, co skutkuje większą wydajnością aplikacji.

Kolejny składnik usługi to MessagesManager będący w zasadzie jej sercem. Zawiera we właściwościach UserManagera, więc po otrzymaniu treści wiadomości oraz id użytkownika jest w stanie określić, który użytkownik ją wysłał. Jego główne zadania to:

  • składowanie wiadomości,
  • udzielanie informacji o otrzymanych wiadomościach.

Po otrzymaniu treści wiadomości pakuje ją w specjalną strukturę i składuje na liście. Możemy zażądać od zadanego id całej listy bądź jej fragmentu. Jest jednak pewien problem. WCF pracuje w wielu wątkach, co przy prostym użyciu MessagesManagera doprowadzi do powstania kilku obiektów tej klasy, wzajemnie niewiedzących o sobie. To oznacza, że nasz system będzie bezużyteczny. Dlatego posłużyłem się singletonem. Prawdopodobnie nie wszyscy wiedzą, czym jest singleton, więc zamieszczam kilka słów wyjaśnienia. Singleton to wzorzec projektowy, którego podstawowym założeniem jest, że dana klasa posiada tylko jedną instancję obiektu. Istnieje wiele sposobów deklaracji singletonu, na przykład można zrobić to tak:

public sealed class MessagesManager
    {
     private static MessagesManager singleton = new MessagesManager();
        public static MessagesManager Instance
        {
            get
            {
                return singleton;
            }
        }

    ...
     }

Proponuję zwrócić szczególną uwagę na słówko sealed, oznaczające, że z naszej klasy nie można dziedziczyć. Na skutek tego nie da się stworzyć publicznego konstruktora. Jedyna instancja klasy MessagesManager jest przypisana do prywatnego statycznego pola klasy singleton. Ponadto wszystkie konstruktory klasy MessagesManager są prywatne, co uniemożliwia tworzenie instancji klasy MessagesManager w inny sposób niż prezentowany wyżej. Teraz mamy pewność, że nasze wiadomości bezpiecznie przechowywane są w jednym MessagesManagerze. Przechodzimy zatem do zaimplementowania naszego interfejsu IMessager, o którym była mowa wcześniej, wywołaniami metod menedżerów. Jeśli chcemy naszą usługę uruchomić jako aplikację konsolową, powinniśmy dodać metodę Main wyglądającą następująco:

// start usługi jako konsolowej aplikacji
        public static void Main()
        {
            using (ServiceHost serviceHost = new ServiceHost(typeof(MessagerService), new Uri("http://host_name:8000/MarekWittkowski/wcf.svc")))
            {
                serviceHost.Open();
                Console.WriteLine("Press <ENTER> to terminate service.");
                Console.ReadLine();
            }
        }

W miejsce host_name wstawiamy nazwę hosta, na którym umieszczamy naszą usługę. W tym momencie możemy już ją uruchomić. By sprawdzić, czy wszystko działa jak należy, radzę włączyć przeglądarkę i wejść na adres: http://host_name:8000/MarekWittkowski/wcf.svc. Powinniśmy ujrzeć ekran powitalny naszej usługi. Teraz przejdziemy do stworzenia klienta Windows. Serwis jest już stworzony, wystarczy użyć narzędzia svcutil.exe, np. w następujący sposób:

svcutil.exe /language:cs /out:generatedClientCore.cs /config:app.config http://host_name:8000/MarekWittkowski/wcf.svc

Po wykonaniu powyższego polecenia zostanie utworzona konfiguracja aplikacji oraz plik generatedClientCore.cs. Zajrzyjmy na chwilę do jego zawartości:

[System.Diagnostics.DebuggerStepThroughAttribute()]
    [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Runtime.Serialization", "4.0.0.0")]
    [System.Runtime.Serialization.DataContractAttribute(Name="ChatUser", Namespace="http://schemas.datacontract.org/2004/07/Marek.Wittkowski.WCF")]
    public partial class ChatUser : object, System.Runtime.Serialization.IExtensibleDataObject
    {
...
}


    [System.Diagnostics.DebuggerStepThroughAttribute()]
    [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Runtime.Serialization", "4.0.0.0")]
    [System.Runtime.Serialization.DataContractAttribute(Name="Message", Namespace="http://schemas.datacontract.org/2004/07/Marek.Wittkowski.WCF")]
    public partial class Message : object, System.Runtime.Serialization.IExtensibleDataObject
    {
...
}

Zwracane są nasze obiekty mające dokładnie te same właściwości, które zdefiniowaliśmy w usłudze. Spójrzmy głębiej:

[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel", "4.0.0.0")]
[System.ServiceModel.ServiceContractAttribute(Namespace="Marek.Wittkowski.WCF", ConfigurationName="IMessager")]
public interface IMessager
{
    
    [System.ServiceModel.OperationContractAttribute(Action="Marek.Wittkowski.WCF/IMessager/Login", ReplyAction="Marek.Wittkowski.WCF/IMessager/LoginResponse")]
    Marek.Wittkowski.WCF.ChatUser Login(string nick);
    
    [System.ServiceModel.OperationContractAttribute(Action="Marek.Wittkowski.WCF/IMessager/SendMessage", ReplyAction="Marek.Wittkowski.WCF/IMessager/SendMessageResponse")]
    int SendMessage(int userId, string message);
    …
}
…

Widzimy interfejs zdefiniowanej przez nas usługi ze wszystkimi metodami.

[System.Diagnostics.DebuggerStepThroughAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel", "4.0.0.0")]
public partial class MessagerClient : System.ServiceModel.ClientBase<IMessager>, IMessager
{
    public Marek.Wittkowski.WCF.ChatUser Login(string nick)
    {
        return base.Channel.Login(nick);
    }
    
    public int SendMessage(int userId, string message)
    {
        return base.Channel.SendMessage(userId, message);
    }
    
    public Marek.Wittkowski.WCF.Message[] GetAllMessages()
    {
        return base.Channel.GetAllMessages();
    }
    …
}

Implementacja naszej usługi zawiera również kilka technicznych dodatków. Jak widzimy, otrzymaliśmy dostęp do wnętrza usługi w taki sam sposób jak do lokalnego zasobu. Wystarczy utworzyć obiekt klasy MessagerClient, by korzystać z niego jak z lokalnego obiektu. Metody zostały zatem odwzorowane wiernie, z jedną różnicą:

public Marek.Wittkowski.WCF.Message[] GetAllMessages()

Jak pamiętamy, w usłudze zwracana była lista, w wygenerowanym kliencie zwracana jest tablica. Podsumowując: otrzymaliśmy dostęp do całego interfejsu oraz zwracanych obiektów, które mają te same właściwości i metody, ale ich zawartość jest zupełnie inna. Nie mamy w nich operacji, które znajdują się w usłudze, lecz zawartość powoduje wywoływanie zdalnych metod.

Zobaczmy teraz, jak w podobny sposób uzyskać dostęp do usługi na urządzeniu mobilnym. Posłużymy się w tym celu narzędziem netcfsvcutil.exe, wywołując komendę:

netcfsvcutil.exe /namespace:*,Marek.Wittkowski.WCF /out:generatedClientProxy.cs_
   http://host_name:8000/MarekWittkowski/wcf.svc.

Oczywiście jak poprzednio host_name zamieniamy na nazwę hosta. Otrzymujemy:

  • konfigurację aplikacji,
  • plik CFClientBase.cs,
  • plik generatedClientProxy.cs.

Sprawdźmy, co zawiera ostatni z wymienionych plików:

[System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel", "3.0.0.0")]
 public interface IMessager
 {
        
        Marek.Wittkowski.WCF.ChatUser Login(string nick);
        
        int SendMessage(int userId, string message);
        
        Message[] GetAllMessages();
        
        Message[] GetAllMessagesFrom(int messageid);
    }

Zawiera on interfejs naszej usługi, oczywiście - jak wspomniałem wcześniej - z listami zamienionym na tablice.

[System.CodeDom.Compiler.GeneratedCodeAttribute("NetCFSvcUtil", "3.5.0.0")]
    [System.SerializableAttribute()]
    [System.Diagnostics.DebuggerStepThroughAttribute()]
    [System.ComponentModel.DesignerCategoryAttribute("code")]
    [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.datacontract.org/2004/07/Marek.Wittkowski.WCF")]
public partial class ChatUser
{
[System.Xml.Serialization.XmlElementAttribute(IsNullable=true, Order=1)]
public string Name
{
    ….
      }
     ….
     }


    [System.CodeDom.Compiler.GeneratedCodeAttribute("NetCFSvcUtil", "3.5.0.0")]
    [System.SerializableAttribute()]
    [System.Diagnostics.DebuggerStepThroughAttribute()]
    [System.ComponentModel.DesignerCategoryAttribute("code")]
    [System.Xml.Serialization.XmlTypeAttribute(Namespace="http://schemas.datacontract.org/2004/07/Marek.Wittkowski.WCF")]
    public partial class Message
    {
    ...
     }

Zawartość to obiekty zwracane przez usługę. Dodatkowo:

[System.Diagnostics.DebuggerStepThroughAttribute()]
    [System.CodeDom.Compiler.GeneratedCodeAttribute("System.ServiceModel", "3.0.0.0")]
    public partial class MessagerClient : Microsoft.Tools.ServiceModel.CFClientBase<Marek.Wittkowski.WCF.IMessager>, Marek.Wittkowski.WCF.IMessager
    {
    ...
   }

Jest to implementacja naszej usługi. Możemy korzystać z otrzymanych obiektów w ten sam sposób jak w przypadku Windows. Podsumowując: użycie WCF. zapewni nam zlokalizowanie w jednym miejscu inteligencji aplikacji, z której będzie korzystać wiele typów klientów. Tworząc klienta, otrzymujemy pełny interfejs usługi, który możemy stosować w ten sam sposób jak lokalne zasoby.

Mamy już gotowy serwis, dostępny zarówno z komputera, jak i z urządzenia mobilnego. Zajmijmy się teraz stworzeniem graficznego klienta na obie platformy. Musi on zapewniać następujące funkcje:

  • logowanie użytkownika,
  • wysyłanie wiadmomości,
  • prezentacja wiadomości z odświeżaniem z serwera.

Pomysł na gui jest prosty. W prawym górnym rogu umieśćmytextbox do wpisania loginu, w centrum − textbox do odbierania wiadomości. Umieśćmy również przyciski zaloguj oraz wyślij, po kliknięciu na które wywołamy odpowiednie metody serwisu. Główną część naszego okna powinnien zajmować textbox tylko do odczytu, w którym będziemy umieszczać wiadomości pobierane z serwera. Pojawia się kolejny problem: w jaki sposób aktualizować wiadomości. Dodanie przycisku pobierz nie będzie dobrze przyjęte przez użytkownika. Nie możemy również blokować gui. Po krótkim zastanowieniu dochodzimy do wniosku, że niezbędny będzie dodatkowy wątek działający w tle i sprawdzający co określony czas, czy na serwerze są nowe wiadomości, oraz pobierający je jeśli zajdzie taka potrzeba. Zarówno na komputerze, jak na urządzeniu mobilnym schemat działania jest podobny i wygląda następująco:

public void DoWork()
        {
            while (true)
            {
                List<Marek.Wittkowski.WCF.Message> newMessages = null;

                lock (Core.Locker)
                {
                    newMessages = Core.MessagerClient.GetAllMessagesFrom(Core.LastMessageId).ToList<Marek.Wittkowski.WCF.Message>();
                    if (newMessages != null && newMessages.Count > 0)
                    {
                        Core.LastMessageId = newMessages.Last<Marek.Wittkowski.WCF.Message>().Id;
                    }
                }

                if (newMessages != null && newMessages.Count > 0)
                {
                        Form.UpdateTextWindow(newMessages);
                }

                Thread.Sleep(1000);
            }
        }

Jedyną różnicą między aplikacją Windows a mobilną jest linia: Form.UpdateTextWindow(newMessages).Wrócimy do tej różnicy później, teraz zajmijmy się pozostałą częścią metody DoWork(). W celu optymalizacjiwydajności nie pobieramy za każdym razem wszystkich wiadomości (całej listy), tylko te, których jeszcze nie posiadamy. Funkcje pobrania wiadomości oraz ustawienia id ostatniej pobranej wiadomości znajdują się w synchronicznym bloku (który jest teraz operacją atomową). Jeśli otrzymaliśmy nowe wiadomości, musimy o tym powiadomić gui. Ponieważ nie możemy modyfikować kontrolek gui ot, tak sobie, z dowolnego wątku musimy zlecić to zadanie głównemu wątkowigui. Tu pojawia się różnica między komputerem osobistym a urządzeniem mobilnym. Na komputerze wygląda to następująco:

public void UpdateTextWindow(List<Marek.Wittkowski.WCF.Message> newMessages)
        {
            this.BeginInvoke(new MethodInvoker(delegate()
            {
                List<string> lines = mainMessageBox.Lines.ToList<string>();

                foreach (Marek.Wittkowski.WCF.Message mesg in newMessages)
                {
                    lines.Add(String.Format("[{0}]: {1}", mesg.User.Name, mesg.Content));
                }

                mainMessageBox.Lines = lines.ToArray();
            }));
        }

To metoda naszego formularza, dzięki której możemy bezpiecznie modyfikować właściwości kontrolek przez dodatkowe wątki. Na urządzeniu mobilnym wygląda to tak:

public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
            FormUpdateTextWindows = new UpdateTextWindow(UpdateTextWindowMethod);

        }
        public delegate void UpdateTextWindow(List<Marek.Wittkowski.WCF.Message> newMessages);
        public UpdateTextWindow FormUpdateTextWindows; 

        public void UpdateTextWindowMethod(List<Marek.Wittkowski.WCF.Message> newMessages)
        {
            StringBuilder lines = new StringBuilder();
            lines.Append(mainMessageBox.Text);

            foreach (Marek.Wittkowski.WCF.Message mesg in newMessages)
            {
                lines.Append(String.Format("[{0}]: {1}\r\n", mesg.User.Name, mesg.Content));
            }

            mainMessageBox.Text = lines.ToString();
        }
}

Z wątku w tle metodę UpdateTextWindowMethod wywołujemy następująco:

Form.Invoke(Form.FormUpdateTextWindows,newMessages);

W ten sposób zbudowaliśmy aplikację dostępną zarówno na komputerze osobistym, jak na urządzeniu mobilnym.