Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Radio buttony dla elementów listy w MVC 4 – problem z unikalnością id

Załóżmy, że mamy pewien model, który posiada listę i chcemy wytworzyć radio buttony dla elementów tej listy. Rzuć okiem na poniższy przykład.

Główna klasa modelu zawierająca listę:

using System.Collections.Generic;

public class Team
{
    public string Name { get; set; }
    public List<Player> Players { get; set; }
}

Klasa dla elementów listy:

public class Player
{
    public string Name { get; set; }
    public string Level { get; set; }
}

Istnieją trzy akceptowalne wartości dla właściwości Level:  BEG (Beginner), INT (Intermediate) oraz ADV (Advanced), chcemy więc wyświetlić trzy radio buttony (wraz z etykietami) dla każdego z zawodników w drużynie. Normalnie pewnie użylibyśmy enum dla property Level ale w imię prostoty przykładu zostaniemy przy typie string...

Metoda kontrolera zwracająca przykładowe dane:

public ActionResult Index()
{
    var team = new Team() {
        Name = "Some Team",
        Players = new List<Player> {
               new Player() {Name = "Player A", Level="BEG"},
               new Player() {Name = "Player B", Level="INT"},
               new Player() {Name = "Player C", Level="ADV"}
        }
    };

    return View(team);
}

Oto widok Index.cshtml:

@model Team

<section>
    <h1>@Model.Name</h1>        

    @Html.EditorFor(model => model.Players)            
</section>

Zauważ, że markup dla właściwości Players nie jest tworzony wewnątrz pętli. Zamiast tego użyty jest EditorTemplate. Jest to zalecane podejście ponieważ sprawia, że kod jest bardziej przejrzysty i prostszy w utrzymaniu. Framwork jest na tyle sprtytny, że używa kodu z szablonu edytora dla każdego z zawodników na liście ponieważ Team.Players implementuje interfejs IEnumerable.

A oto wspomniany Players.cshtml EditorTemplate:

@model Player
<div>
    <strong>@Model.Name:</strong>

    @Html.RadioButtonFor(model => model.Level, "BEG")
    @Html.LabelFor(model => model.Level, "Beginner")

    @Html.RadioButtonFor(model => model.Level, "INT")
    @Html.LabelFor(model => model.Level, "Intermediate")

    @Html.RadioButtonFor(model => model.Level, "ADV")
    @Html.LabelFor(model => model.Level, "Advanced")        
</div>

Kod wygląda w porządku, używa silnie typowanych helperów opartych o wyrażenia lambda (dla wychwycenia błędów podczas kompilacji i łatwiejszego refaktoringu)... niestety, jest pewien problem: HTML wygenerowany przez taki kod ma bardzo poważną wadę. Sprawdź poniższy fragment kodu źródłowego strony, który został wygenerowany dla pierwszego zawodnika z listy:

<div>
    <strong>Player A:</strong>  
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" checked="checked" value="BEG">
    <label for="Players_0__Level">Beginner</label>
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" value="INT">
    <label for="Players_0__Level">Intermediate</label>
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" value="ADV">
    <label for="Players_0__Level">Advanced</label>        
</div>

Identyfikator Players_0__Level jest użyty dla trzech różnych radio buttonów! Brak unikalności nie tylko narusza specyfikacje HTML i utrudnia oskryptowanie, powoduje też, że etykiety (tagi label) nie działają prawidłowo (tj. kliknięcie nie powoduje zaznaczenia odpowiedniego elementu input). 

Na szczęście biblioteka MVC posiada klasę TemplateInfo z metodą GetFullHtmlFieldId. Metoda ta zwraca identyfikator dla elementu DOM. Identyfikator jest budowany poprzez połączenie argumentu przekazanego do metody z automatycznie wygenerowanym prefiksem. Prefiks uwzględnia poziom zagnieżdżenia i indeks elementu listy. Wewnętrznie, GetFullHtmlFieldId odwołuje się do właściwości TemplateInfo.HtmlFieldPrefix i metody TagBuilder.CreateSanitizedId więc w przypadku przekazania do sufiksu znaków niemogących być częścią id, znaki te zostaną usunięte.

Oto zmodyfikowany kod EditorTemplate:
@model Player
<div>
    <strong>@Model.Name:</strong>

    @{string rbBeginnerId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbBeginner"); }
    @Html.RadioButtonFor(model => model.Level, "BEG", new { id = rbBeginnerId })
    @Html.LabelFor(model => model.Level, "Beginner",  new { @for = rbBeginnerId} )

    @{string rbIntermediateId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbIntermediate"); }
    @Html.RadioButtonFor(model => model.Level, "INT", new { id = rbIntermediateId })
    @Html.LabelFor(model => model.Level, "Intermediate",  new { @for = rbIntermediateId })

    @{string rbAdvancedId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbAdvanced"); }
    @Html.RadioButtonFor(model => model.Level, "ADV", new { id = rbAdvancedId })
    @Html.LabelFor(model => model.Level, "Advanced",  new { @for = rbAdvancedId })
</div>

Wywołania metody ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId pozwalają na pozyskanie id dla radio buttonów, identyfikatory te są wykorzystane również do ustawienia atrybutów for elementów label. W MVC 3 nie było przeładowanej metody LabelFor, która akceptowałaby obiekt dla htmlAttributes. Na szczęście wersja 4 ma wbudowany stosowny overload.

Powyższy kod tworzy taki markup:

<div>
    <strong>Player A:</strong>
 
    <input name="Players[0].Level" id="Players_0__rbBeginner" type="radio" checked="checked" value="BEG">
    <label for="Players_0__rbBeginner">Beginner</label>
 
    <input name="Players[0].Level" id="Players_0__rbIntermediate" type="radio" value="INT">
    <label for="Players_0__rbIntermediate">Intermediate</label>
 
    <input name="Players[0].Level" id="Players_0__rbAdvanced" type="radio" value="ADV">
    <label for="Players_0__rbAdvanced">Advanced</label>
</div>

Teraz inputy mają unikalne identyfikatory a labele poprawnie odwołują się do radio buttonów poprzez atrybut for. I w porządku :)

Przy okazji: dziwna nazwa „radio button” pochodzi od odbiorników radiowych, które posiadały panele z przyciskami do wyboru stacji (wciśnięcie jednego guzika automatycznie wybijało pozostałe).

Dlaczego użycie GetPixel i SetPixel jest tak bardzo nieefektywne?

Klasa Bitmap dostarcza dwie proste metody: GetPixel i SetPixel służące odpowiednio do pobierania koloru punktu obrazu (jako struktury Color) oraz ustawienia punktu obrazu. Poniższy kod ilustruje sposób pobrania/ustawiania wszystkich pikseli bitmapy:

private void GetSetPixel(Bitmap image) {
   for (int x = 0; x < image.Width; x++) {
      for (int y = 0; y < image.Height; y++) {
         Color pixel = image.GetPixel(x, y);
         image.SetPixel(x, y, Color.Black);
      }
   } 
}

Jak widać przeglądnięcie i modyfikacja pikseli jest niezwykle prosta. Niestety za prostotą kodu kryje się poważna pułapka wydajnościowa. O ile dla niewielkiej ilości odwołań do punktów obrazu, prędkość z jaką działają metody GetPixel i SetPixel jest zadowalająca, o tyle dla większych rozmiarów obrazu jest ona zdecydowanie za mała. Za dowód może posłużyć wykres z wynikami 10 testów*, które polegały na 10-krotnym wywołaniu w/w metody GetSetPixel na obrazach 100x100 i 1000x1000 pikseli:

Wyniki testów prędkości operacji na pikselach obrazu z użyciem metod GetPixel i SetPixel klasy Bitmap.

Średni czas testu dla obrazu o wymiarach 100 na 100 pikseli wyniósł 543 milisekundy. Taka wydajność jest możliwa do zaakceptowania o ile przetwarzanie obrazu nie będzie wykonywane często. Problem wydajnościowy jest natomiast jasno widoczny przy próbie obsługi obrazu o rozmiarach 1000 na 1000 pikseli. Wykonanie testu zabiera w tym przypadku średnio ponad 41 sekund – ponad 4 sek. na jedno wywołanie GetSetPixel (sic!) .


Dlaczego tak wolno?

Niska wydajność spowodowana jest tym, że dostęp do piksela nie jest prostym odwołaniem do obszaru pamięci. Każde pobranie lub ustawienie koloru wiąże się z wywołaniem metody .NET Framework, będącej oprawą dla natywnej funkcji zawartej w bibliotece gdiplus.dll. Wywołanie to następuje za pomocą mechanizmu P/Invoke (Platform Invocation), który służy do komunikacji kodu zarządzanego z API niezarządzanym (API z poza .NET Framework). Tak więc np. dla bitmapy o rozmiarze 1000x1000 pikseli dojdzie do miliona wywołań metody GetPixel, która prócz walidacji parametrów korzysta z funkcji natywnej GdipBitmapGetPixel. Metoda z API GDI+ musi z kolei przed zwróceniem informacji o kolorze wykonać takie operację jak np. wyliczenie położenia bajtów odpowiedzialnych za opis szukanego piksela... Sytuacja analogiczna zachodzi w przypadku metody SetPixel.

Spójrz na poniższy kod metody Bitmap.GetPixel uzyskany dzięki .NET Reflector (System.Drawing.dll, .NET Framework 2.0):

public Color GetPixel(int x, int y) {
   int argb = 0;
   if ((x < 0) || (x >= base.Width)) {
      throw new ArgumentOutOfRangeException(“x”, SR.GetString(“ValidRangeX”));
   }
   if ((y < 0) || (y >= base.Height)) {
      throw new ArgumentOutOfRangeException(“y”, SR.GetString(“ValidRangeY”));
   }
   
   int status = SafeNativeMethods.Gdip.GdipBitmapGetPixel(new HandleRef(this, base.nativeImage), x, y, out argb);
   if (status != 0) {
      throw SafeNativeMethods.Gdip.StatusException(status);
   }
   return Color.FromArgb(argb);
}

A oto import funkcji z GDI+:

[DllImport(“gdiplus.dll”, CharSet=CharSet.Unicode, SetLastError=true, 
ExactSpelling=true)]
internal static extern int GdipBitmapGetPixel(HandleRef bitmap, int x, int y, out int argb);

Teraz już wiesz dlaczego masowe użycie Get/SetPixel jest takie powolne. Na szczęście istnieją inne (dużo szybsze) sposoby obsługi pikseli z poziomu .NET. Przy pewnym wysiłku można napisać kod, który szybciej obsłuży obraz megapikselowy niż prymitywna metoda poradzi sobie z bitmapą 100x100!. Ale o tym, gdy znajdę nieco czasu... ;)

Aktualizacja 2013-11-07: Napisałem artykuł o szybkich operacjach pikselowych. Koniec z ślamazarnym Get/SetPixel :)

Aktualizacja 2018-01-08: Jeśli zależy Ci na naprawdę wydajnym przetwarzaniu obrazu powinieneś sięgnąć po wyspecjalizowaną bibliotekę, taką jak OpenCV. Kilka miesięcy temu napisałem serie postów "Detecting a Drone - OpenCV in .NET for Beginners (Emgu CV 3.2, Visual Studio 2017)", które pomogą Ci zacząć...

* Testowałem na takim laptopie: HP Pavilion dv5, procesor AMD Turion X2 Dual-Core Mobile RM-70, 3 GB RAM, Vista Home Premium

404 dla plików ASPX na IIS 6 (Win 2003)

Przy przenoszeniu aplikacji z maszyny testowej na środowisko produkcyjne zawsze pojawiają się jakieś problemy (zwykle drobne - jak w opisywanym przypadku). Czas leci a w myślach "WTF?"...

Do testów aplikacji służyły nam komputery z XP mające IIS 5.1. Na produkcji zainstalowano jednak Windows 2003 z IIS 6.0. Po skopiowaniu wszystkiego co potrzeba i skonfigurowaniu katalogów wirtualnych okazało się, że można uzyskać dostęp jedynie do statycznych stron np. *.html. Przy próbie otwarcia strony *.aspx serwer odpowiadał kodem 404. U nas działo a tutaj nie chce... Przyczyna okazała się bardzo prosta: domyślnie po instalacji rozszerzenie IIS ASP.NET v2.0 miało status ustawiony na "Prohibited". Aby dynamiczny content ASPX mógł być serwowany to ustawienie musi zostać zmienione na "Allowed".

Kroki potrzebne do rozwiązania problemu:

  • Uruchom konsole IIS (inetmgr.exe)
  • Po lewej stronie konsoli zaznacz interesujący Cię serwer
  • Kliknij węzeł "Web Service Extensions" (po prawej stronie okna pokaże się lista dostępnych rozszerzeń)
  • Zaznacz na liście nazwę rozszerzenia odpowiedzialnego za wersję ASP.NET, z której korzysta Twoja aplikacja
  • Naciśnij przycisk "Allow"

I to tyle! Superproste (jeśli wiesz o tym).

Disabled CheckBoxa usuwa zaznaczenie!

Podczas korzystania z kontrolki CheckBox i ustawiania jej po stronie klienta jako nieaktywnej, natchnąłem się na problem, który (przeoczony) może sporo namieszać w pracy aplikacji. Otóż jeśli CheckBox jest zaznaczony i zablokujesz go ustawiając właściwość disabled w JavaScript to po postbacku zobaczysz, że właściwość Checked == false!

Ten fragment JS ustawia właściwość disabled (CheckBox staje się szary i przestaje reagować na akcje użytkownika):

document.getElementById('<%= CheckBox1.ClientID %>').disabled = true;

Po "powrocie na serwer" kontrolka CheckBox nie będzie zaznaczona. Dzieje się tak dlatego, że przeglądarka nie wysyła w requescie informacji o polu input type="checkbox" z atrybutem disabled.

Testowałem w IE6/8 i FF2 na .NET 2.0/3.5.